@m13v/s4l 1.7.6-rc.2 → 1.7.6-rc.21
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/mcp/dist/index.js +256 -92
- package/mcp/dist/telemetry.js +2 -1
- package/mcp/dist/version.json +2 -2
- package/mcp/manifest.json +1 -1
- package/mcp/menubar/s4l_browser_foreground.py +7 -1
- package/mcp/menubar/s4l_card.py +144 -18
- package/mcp/menubar/s4l_card_canvas.py +135 -14
- package/mcp/menubar/s4l_menubar.py +216 -73
- package/mcp/menubar/s4l_state.py +45 -130
- package/mcp/package.json +1 -1
- package/package.json +1 -1
- package/scripts/account_resolver.py +35 -1
- package/scripts/autopilot_stall_watch.py +8 -1
- package/scripts/browser_lifecycle.py +90 -0
- package/scripts/cdp_ready_check.py +81 -50
- package/scripts/draft_prompt_core.py +100 -17
- package/scripts/engagement_styles.py +135 -98
- package/scripts/merge_review_queue.py +192 -129
- package/scripts/platform_strike_events.py +54 -5
- package/scripts/post_reddit.py +10 -0
- package/scripts/preview_reddit_card.py +38 -0
- package/scripts/reap_stale_claude_sessions.py +7 -6
- package/scripts/reddit_ban_check.py +220 -0
- package/scripts/reddit_browser.py +24 -0
- package/scripts/release-mcpb.sh +38 -7
- package/scripts/setup_twitter_auth.py +20 -96
- package/scripts/store_patch.py +111 -0
- package/scripts/strike_alert.py +161 -12
- package/scripts/test_no_silent_fallbacks.py +11 -0
- package/scripts/test_stall_watch_batch_progression.py +99 -0
- package/scripts/top_performers.py +24 -0
- package/scripts/twitter_browser.py +22 -52
- package/skill/archive-old-logs.sh +24 -0
- package/skill/lib/reddit-backend.sh +1 -0
- package/skill/run-twitter-cycle.sh +146 -66
|
@@ -168,6 +168,14 @@ def _reddit_plan_to_candidates(plan: dict) -> list:
|
|
|
168
168
|
"thread_url": d.get("thread_url"),
|
|
169
169
|
"thread_author": d.get("thread_author"),
|
|
170
170
|
"thread_text": d.get("thread_title") or "",
|
|
171
|
+
# Reddit threads carry a title (thread_text, above) AND a body
|
|
172
|
+
# (selftext). Both matter to a reviewer: the title alone often
|
|
173
|
+
# can't be judged without the post body it heads. The card renders
|
|
174
|
+
# the title bold and the selftext beneath it in the same quote box
|
|
175
|
+
# (2026-07-17 user direction). Link posts / title-only threads have
|
|
176
|
+
# an empty selftext, so the card falls back to title-only. Decisions
|
|
177
|
+
# carry it as `selftext` (from the candidate) or `thread_selftext`.
|
|
178
|
+
"thread_selftext": d.get("selftext") or d.get("thread_selftext") or "",
|
|
171
179
|
"matched_project": plan.get("project_name"),
|
|
172
180
|
"reply_text": d.get("text"),
|
|
173
181
|
"engagement_style": d.get("engagement_style"),
|
|
@@ -265,17 +273,45 @@ def _sync_with_backend(cands: list) -> tuple[int, int]:
|
|
|
265
273
|
and not (c.get("experiments") or {}).get("sandbox")
|
|
266
274
|
and _thread_url(c)
|
|
267
275
|
]
|
|
268
|
-
|
|
276
|
+
# Approved-but-unposted cards get the INVERSE sync: never pruned (the
|
|
277
|
+
# approval is a settled human decision, see docstring above), but if the
|
|
278
|
+
# backend row says the reply already LANDED (status='posted'), stamp
|
|
279
|
+
# posted=True here so the store agrees with the database. Without this
|
|
280
|
+
# there is no path that ever corrects a lost posted stamp: the drain's
|
|
281
|
+
# in-process stamp can lose a whole-file write race against the menubar
|
|
282
|
+
# or a peer drain, after which the card shows failed/terminal forever
|
|
283
|
+
# while the reply is live on X (2026-07-17 incident: 6 posted replies
|
|
284
|
+
# rendered as duplicate_thread_pre_post kills).
|
|
285
|
+
approved_open = [
|
|
286
|
+
c
|
|
287
|
+
for c in cands
|
|
288
|
+
if c.get("approved")
|
|
289
|
+
and not c.get("posted")
|
|
290
|
+
and c.get("platform") != "reddit"
|
|
291
|
+
and not (c.get("experiments") or {}).get("sandbox")
|
|
292
|
+
and _thread_url(c)
|
|
293
|
+
]
|
|
294
|
+
if not pending and not approved_open:
|
|
269
295
|
return 0, 0
|
|
270
|
-
urls = sorted({_thread_url(c) for c in pending})[:500]
|
|
296
|
+
urls = sorted({_thread_url(c) for c in pending} | {_thread_url(c) for c in approved_open})[:500]
|
|
271
297
|
try:
|
|
272
298
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
273
299
|
from http_api import api_get
|
|
274
300
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
301
|
+
# Scope to OUR candidate rows: the bulk GET has no tenant isolation,
|
|
302
|
+
# so without install_id another install's row for the same tweet_url
|
|
303
|
+
# (e.g. two customers discovering one viral thread) could be read
|
|
304
|
+
# back as "our" status and mis-stamp posted/terminal on our card.
|
|
305
|
+
query = {"tweet_urls": ",".join(urls), "limit": 500}
|
|
306
|
+
try:
|
|
307
|
+
from identity import get_identity
|
|
308
|
+
|
|
309
|
+
own_install = (get_identity() or {}).get("install_id")
|
|
310
|
+
if own_install:
|
|
311
|
+
query["install_id"] = own_install
|
|
312
|
+
except Exception:
|
|
313
|
+
pass # fail open: unscoped lookup, same behavior as before
|
|
314
|
+
resp = api_get("/api/v1/twitter-candidates", query=query)
|
|
279
315
|
rows = (resp.get("data") or {}).get("candidates") or []
|
|
280
316
|
except BaseException as e: # http_api raises SystemExit on terminal failure
|
|
281
317
|
print(f"[merge_review_queue] backend sync skipped: {e}", file=sys.stderr)
|
|
@@ -295,6 +331,14 @@ def _sync_with_backend(cands: list) -> tuple[int, int]:
|
|
|
295
331
|
c["terminal"] = True
|
|
296
332
|
c["discard_reason"] = f"backend_status_{status}"
|
|
297
333
|
pruned += 1
|
|
334
|
+
for c in approved_open:
|
|
335
|
+
row = by_url.get(_thread_url(c))
|
|
336
|
+
if row and row.get("status") == "posted":
|
|
337
|
+
c["posted"] = True
|
|
338
|
+
c["terminal"] = False
|
|
339
|
+
c.pop("post_failed", None)
|
|
340
|
+
c.pop("post_error", None)
|
|
341
|
+
stamped += 1
|
|
298
342
|
return stamped, pruned
|
|
299
343
|
|
|
300
344
|
|
|
@@ -347,137 +391,156 @@ def main() -> int:
|
|
|
347
391
|
# cycle process and only that process knows them.
|
|
348
392
|
|
|
349
393
|
dst = store_path()
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
394
|
+
# ---- store lock ---------------------------------------------------
|
|
395
|
+
# Hold the SAME fcntl.flock the menubar's _store_update and the MCP's
|
|
396
|
+
# store_patch.py take, for the whole read->merge->write span. Without
|
|
397
|
+
# it this function was a last-writer-wins whole-file rewrite: a card
|
|
398
|
+
# decision stamped between our read (below) and _atomic_write was
|
|
399
|
+
# silently erased (the 2026-07-17 truth-loss family, including the
|
|
400
|
+
# resurrected sandbox approval that posted for real). The lock covers
|
|
401
|
+
# one bulk API call (_sync_with_backend); worst case a menubar decision
|
|
402
|
+
# write blocks for a few seconds once per cycle, which beats losing it.
|
|
403
|
+
import fcntl
|
|
404
|
+
_lk = open(dst + ".lock", "w")
|
|
405
|
+
fcntl.flock(_lk, fcntl.LOCK_EX)
|
|
406
|
+
try:
|
|
407
|
+
existing = []
|
|
408
|
+
plan_created_at = None
|
|
409
|
+
if os.path.exists(dst):
|
|
410
|
+
try:
|
|
411
|
+
with open(dst) as f:
|
|
412
|
+
prev = json.load(f)
|
|
413
|
+
existing = prev.get("candidates") or []
|
|
414
|
+
plan_created_at = prev.get("created_at")
|
|
415
|
+
except Exception:
|
|
416
|
+
existing = []
|
|
417
|
+
plan_created_at = None
|
|
418
|
+
# Absorb a REAL file at the legacy /tmp location (pre-upgrade store, or one
|
|
419
|
+
# written by old code after a reboot) so no pending draft or decision flag
|
|
420
|
+
# is lost, then ensure_store_symlink() below replaces it with the link.
|
|
421
|
+
legacy = plan_path(REVIEW_QUEUE_ID)
|
|
422
|
+
if os.path.exists(legacy) and not os.path.islink(legacy):
|
|
423
|
+
try:
|
|
424
|
+
with open(legacy) as f:
|
|
425
|
+
lp = json.load(f)
|
|
426
|
+
lc = lp.get("candidates") or []
|
|
427
|
+
have = {_dedup_key(c) for c in existing}
|
|
428
|
+
absorbed = [c for c in lc if _dedup_key(c) not in have]
|
|
429
|
+
existing.extend(absorbed)
|
|
430
|
+
plan_created_at = plan_created_at or lp.get("created_at")
|
|
431
|
+
if absorbed:
|
|
432
|
+
print(
|
|
433
|
+
f"[merge_review_queue] absorbed {len(absorbed)} candidate(s) "
|
|
434
|
+
"from legacy /tmp plan into the durable store",
|
|
435
|
+
file=sys.stderr,
|
|
436
|
+
)
|
|
437
|
+
except Exception as e:
|
|
438
|
+
print(f"[merge_review_queue] legacy plan absorb failed: {e}", file=sys.stderr)
|
|
439
|
+
# Generation stamp: set ONLY when starting a fresh plan (the /tmp plan dies
|
|
440
|
+
# on reboot/tmp-sweep and numbering restarts at 1). The menu bar's durable
|
|
441
|
+
# approved-queue ledger uses this to ignore decisions that belong to a dead
|
|
442
|
+
# plan generation; without it, stale (batch, n) entries silently swallow
|
|
443
|
+
# every new draft after a reset. An existing unstamped plan is left
|
|
444
|
+
# unstamped: back-stamping it "now" would invalidate live decisions.
|
|
445
|
+
if not existing and not plan_created_at:
|
|
446
|
+
plan_created_at = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
|
447
|
+
|
|
448
|
+
seen = {_dedup_key(c) for c in existing}
|
|
449
|
+
added = 0
|
|
450
|
+
merged = list(existing)
|
|
451
|
+
for c in new_cands:
|
|
452
|
+
k = _dedup_key(c)
|
|
453
|
+
if k in seen:
|
|
454
|
+
continue
|
|
455
|
+
seen.add(k)
|
|
456
|
+
merged.append(c)
|
|
457
|
+
added += 1
|
|
458
|
+
|
|
459
|
+
stamped, pruned = _sync_with_backend(merged)
|
|
460
|
+
if stamped:
|
|
461
|
+
print(f"[merge_review_queue] stamped stats on {stamped} candidate(s)", file=sys.stderr)
|
|
462
|
+
if pruned:
|
|
463
|
+
print(
|
|
464
|
+
f"[merge_review_queue] pruned {pruned} candidate(s) already retired by the "
|
|
465
|
+
"backend (expired/etc.) before they were reviewed",
|
|
466
|
+
file=sys.stderr,
|
|
467
|
+
)
|
|
468
|
+
|
|
469
|
+
plan_obj = {"candidates": merged}
|
|
470
|
+
if plan_created_at:
|
|
471
|
+
plan_obj["created_at"] = plan_created_at
|
|
472
|
+
|
|
473
|
+
# This is the actual delivery: if anything below throws, the cycle's drafts
|
|
474
|
+
# were computed but never reached the store the menu bar reads, and the
|
|
475
|
+
# wrapper (run-draft-and-publish.sh) captures this process's whole stdout+
|
|
476
|
+
# stderr with `|| true`, so a crash here previously vanished into a local
|
|
477
|
+
# log nothing central reads — the exact blind spot that cost the 2026-07-08
|
|
478
|
+
# Karol investigation its root cause. Report it like any other handled
|
|
479
|
+
# pipeline failure (see twitter_post_plan.py's post-failure capture).
|
|
366
480
|
try:
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
481
|
+
_atomic_write(dst, plan_obj)
|
|
482
|
+
ensure_store_symlink()
|
|
483
|
+
|
|
484
|
+
# Refresh the review-request marker the menu bar polls. count = cards
|
|
485
|
+
# actually awaiting review (mirrors s4l_state.candidate_state()'s
|
|
486
|
+
# awaiting_review bucket): approved-unposted and post_failed rows are
|
|
487
|
+
# settled decisions, counting them inflated the badge and misled every
|
|
488
|
+
# human/agent reading the marker.
|
|
489
|
+
pending_count = len(
|
|
490
|
+
[
|
|
491
|
+
c
|
|
492
|
+
for c in merged
|
|
493
|
+
if not c.get("posted")
|
|
494
|
+
and not c.get("terminal")
|
|
495
|
+
and not c.get("post_failed")
|
|
496
|
+
and not c.get("approved")
|
|
497
|
+
]
|
|
498
|
+
)
|
|
499
|
+
project = ns.project or batch.get("project") or (new_cands[0].get("matched_project") if new_cands else None)
|
|
500
|
+
_atomic_write(
|
|
501
|
+
review_request_path(),
|
|
502
|
+
{
|
|
503
|
+
"batch_id": REVIEW_QUEUE_ID,
|
|
504
|
+
"project": project,
|
|
505
|
+
"count": pending_count,
|
|
506
|
+
"plan_path": dst,
|
|
507
|
+
"created_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
508
|
+
},
|
|
509
|
+
)
|
|
380
510
|
except Exception as e:
|
|
381
|
-
print(f"[merge_review_queue]
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
continue
|
|
398
|
-
seen.add(k)
|
|
399
|
-
merged.append(c)
|
|
400
|
-
added += 1
|
|
401
|
-
|
|
402
|
-
stamped, pruned = _sync_with_backend(merged)
|
|
403
|
-
if stamped:
|
|
404
|
-
print(f"[merge_review_queue] stamped stats on {stamped} candidate(s)", file=sys.stderr)
|
|
405
|
-
if pruned:
|
|
511
|
+
print(f"[merge_review_queue] delivery failed (drafts NOT merged into cards): {e}", file=sys.stderr)
|
|
512
|
+
try:
|
|
513
|
+
import sentry_init
|
|
514
|
+
|
|
515
|
+
sentry_init.init()
|
|
516
|
+
sentry_init.capture_message(
|
|
517
|
+
f"merge_review_queue delivery failed: {e}",
|
|
518
|
+
level="error",
|
|
519
|
+
tags={"component": "merge_review_queue", "added": str(added)},
|
|
520
|
+
extra={"plan_src": src},
|
|
521
|
+
)
|
|
522
|
+
sentry_init.flush(2.0)
|
|
523
|
+
except Exception:
|
|
524
|
+
pass
|
|
525
|
+
return 1
|
|
526
|
+
|
|
406
527
|
print(
|
|
407
|
-
f"[merge_review_queue]
|
|
408
|
-
"
|
|
528
|
+
f"[merge_review_queue] merged {added} new draft(s) into {REVIEW_QUEUE_ID} "
|
|
529
|
+
f"({pending_count} pending total) from {os.path.basename(src)}",
|
|
409
530
|
file=sys.stderr,
|
|
410
531
|
)
|
|
411
|
-
|
|
412
|
-
plan_obj = {"candidates": merged}
|
|
413
|
-
if plan_created_at:
|
|
414
|
-
plan_obj["created_at"] = plan_created_at
|
|
415
|
-
|
|
416
|
-
# This is the actual delivery: if anything below throws, the cycle's drafts
|
|
417
|
-
# were computed but never reached the store the menu bar reads, and the
|
|
418
|
-
# wrapper (run-draft-and-publish.sh) captures this process's whole stdout+
|
|
419
|
-
# stderr with `|| true`, so a crash here previously vanished into a local
|
|
420
|
-
# log nothing central reads — the exact blind spot that cost the 2026-07-08
|
|
421
|
-
# Karol investigation its root cause. Report it like any other handled
|
|
422
|
-
# pipeline failure (see twitter_post_plan.py's post-failure capture).
|
|
423
|
-
try:
|
|
424
|
-
_atomic_write(dst, plan_obj)
|
|
425
|
-
ensure_store_symlink()
|
|
426
|
-
|
|
427
|
-
# Refresh the review-request marker the menu bar polls. count = cards
|
|
428
|
-
# actually awaiting review (mirrors s4l_state.candidate_state()'s
|
|
429
|
-
# awaiting_review bucket): approved-unposted and post_failed rows are
|
|
430
|
-
# settled decisions, counting them inflated the badge and misled every
|
|
431
|
-
# human/agent reading the marker.
|
|
432
|
-
pending_count = len(
|
|
433
|
-
[
|
|
434
|
-
c
|
|
435
|
-
for c in merged
|
|
436
|
-
if not c.get("posted")
|
|
437
|
-
and not c.get("terminal")
|
|
438
|
-
and not c.get("post_failed")
|
|
439
|
-
and not c.get("approved")
|
|
440
|
-
]
|
|
441
|
-
)
|
|
442
|
-
project = ns.project or batch.get("project") or (new_cands[0].get("matched_project") if new_cands else None)
|
|
443
|
-
_atomic_write(
|
|
444
|
-
review_request_path(),
|
|
445
|
-
{
|
|
446
|
-
"batch_id": REVIEW_QUEUE_ID,
|
|
447
|
-
"project": project,
|
|
448
|
-
"count": pending_count,
|
|
449
|
-
"plan_path": dst,
|
|
450
|
-
"created_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
451
|
-
},
|
|
452
|
-
)
|
|
453
|
-
except Exception as e:
|
|
454
|
-
print(f"[merge_review_queue] delivery failed (drafts NOT merged into cards): {e}", file=sys.stderr)
|
|
532
|
+
# Clean up the consumed batch plan so /tmp doesn't fill with orphans.
|
|
455
533
|
try:
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
sentry_init.init()
|
|
459
|
-
sentry_init.capture_message(
|
|
460
|
-
f"merge_review_queue delivery failed: {e}",
|
|
461
|
-
level="error",
|
|
462
|
-
tags={"component": "merge_review_queue", "added": str(added)},
|
|
463
|
-
extra={"plan_src": src},
|
|
464
|
-
)
|
|
465
|
-
sentry_init.flush(2.0)
|
|
534
|
+
os.remove(src)
|
|
466
535
|
except Exception:
|
|
467
536
|
pass
|
|
468
|
-
return
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
# Clean up the consumed batch plan so /tmp doesn't fill with orphans.
|
|
476
|
-
try:
|
|
477
|
-
os.remove(src)
|
|
478
|
-
except Exception:
|
|
479
|
-
pass
|
|
480
|
-
return 0
|
|
537
|
+
return 0
|
|
538
|
+
finally:
|
|
539
|
+
try:
|
|
540
|
+
fcntl.flock(_lk, fcntl.LOCK_UN)
|
|
541
|
+
except Exception:
|
|
542
|
+
pass
|
|
543
|
+
_lk.close()
|
|
481
544
|
|
|
482
545
|
|
|
483
546
|
if __name__ == "__main__":
|
|
@@ -106,12 +106,20 @@ def _venue(post: dict) -> str | None:
|
|
|
106
106
|
return None
|
|
107
107
|
|
|
108
108
|
|
|
109
|
-
def _removed_event(post: dict) -> dict:
|
|
109
|
+
def _removed_event(post: dict, venue_count: int = 0) -> dict:
|
|
110
110
|
venue = _venue(post)
|
|
111
111
|
is_own_thread = bool(post.get("thread_url")) and post.get("thread_url") == post.get("our_url")
|
|
112
112
|
kind = "top-level post" if is_own_thread else "comment/reply"
|
|
113
113
|
posted = str(post.get("posted_at") or "")[:10]
|
|
114
114
|
detected = str(post.get("status_checked_at") or "")[:10]
|
|
115
|
+
# venue_count = how many of our posts were removed in this same venue inside
|
|
116
|
+
# the sweep window. Surfaces clustering (one venue removing many of our
|
|
117
|
+
# posts is a venue-fit lesson, not N unrelated content lessons) directly in
|
|
118
|
+
# the note the digest reads.
|
|
119
|
+
cluster = ""
|
|
120
|
+
if venue and venue_count > 1:
|
|
121
|
+
cluster = (f" This is 1 of {venue_count} of our posts removed in"
|
|
122
|
+
f" {venue} in the recent window (a venue-level pattern).")
|
|
115
123
|
note = (
|
|
116
124
|
f"Platform moderation strike: our {post.get('platform')} {kind}"
|
|
117
125
|
f"{' in ' + venue if venue else ''} is now status='{post.get('status')}'"
|
|
@@ -120,6 +128,7 @@ def _removed_event(post: dict) -> dict:
|
|
|
120
128
|
f" account {post.get('our_account')}."
|
|
121
129
|
f" Machine-derived signal, not a human card decision: a moderator or"
|
|
122
130
|
f" platform filter judged this content unwelcome in that venue."
|
|
131
|
+
f"{cluster}"
|
|
123
132
|
)
|
|
124
133
|
return {
|
|
125
134
|
"event_uuid": str(uuid.uuid5(EVENT_NS, f"platform_removed:post:{post['id']}")),
|
|
@@ -156,7 +165,7 @@ def _ban_event(entry: dict) -> dict:
|
|
|
156
165
|
}
|
|
157
166
|
|
|
158
167
|
|
|
159
|
-
def collect_events(window_days: int) -> tuple[list[dict], int, int]:
|
|
168
|
+
def collect_events(window_days: int, dry_run: bool = False) -> tuple[list[dict], int, int]:
|
|
160
169
|
"""Returns (events, removed_count, ban_count)."""
|
|
161
170
|
cfg = _load_config()
|
|
162
171
|
local_projects = {p.get("name") for p in (cfg.get("projects") or []) if p.get("name")}
|
|
@@ -170,7 +179,7 @@ def collect_events(window_days: int) -> tuple[list[dict], int, int]:
|
|
|
170
179
|
})
|
|
171
180
|
posts = ((resp or {}).get("data") or {}).get("posts") or []
|
|
172
181
|
|
|
173
|
-
|
|
182
|
+
fresh = []
|
|
174
183
|
for p in posts:
|
|
175
184
|
if p.get("project_name") not in local_projects:
|
|
176
185
|
continue
|
|
@@ -179,7 +188,47 @@ def collect_events(window_days: int) -> tuple[list[dict], int, int]:
|
|
|
179
188
|
detected = _parse_ts(p.get("status_checked_at"))
|
|
180
189
|
if detected is None or detected < cutoff:
|
|
181
190
|
continue
|
|
182
|
-
|
|
191
|
+
fresh.append(p)
|
|
192
|
+
|
|
193
|
+
# Per-venue removal counts (clustering signal surfaced in each event note).
|
|
194
|
+
venue_counts: dict[str, int] = {}
|
|
195
|
+
for p in fresh:
|
|
196
|
+
v = _venue(p)
|
|
197
|
+
if v:
|
|
198
|
+
venue_counts[v] = venue_counts.get(v, 0) + 1
|
|
199
|
+
removed_events = [_removed_event(p, venue_counts.get(_venue(p) or "", 0))
|
|
200
|
+
for p in fresh]
|
|
201
|
+
|
|
202
|
+
# Deterministic community-ban detection (2026-07-17): before reading the
|
|
203
|
+
# ban denylist, inspect the logged-in ban state of every reddit subreddit
|
|
204
|
+
# that struck us this window and persist newly-confirmed bans (reason
|
|
205
|
+
# 'account_blocked_in_sub'). This is what turns a pile of per-post removals
|
|
206
|
+
# into a single platform_banned learning signal, and upgrades weak manual
|
|
207
|
+
# denylist entries so their ban event can finally fire. Read-only browser
|
|
208
|
+
# attach; no-ops gracefully (returns unknown) on installs without a live
|
|
209
|
+
# Reddit session, and can be disabled with S4L_STRIKE_BAN_CHECK=0.
|
|
210
|
+
if os.environ.get("S4L_STRIKE_BAN_CHECK", "1") not in ("0", "false", "no"):
|
|
211
|
+
reddit_subs = sorted({
|
|
212
|
+
re.search(r"reddit\.com/r/([^/]+)/", (p.get("thread_url") or p.get("our_url") or "")).group(1)
|
|
213
|
+
for p in fresh
|
|
214
|
+
if re.search(r"reddit\.com/r/([^/]+)/", (p.get("thread_url") or p.get("our_url") or ""))
|
|
215
|
+
})
|
|
216
|
+
if reddit_subs:
|
|
217
|
+
try:
|
|
218
|
+
import reddit_ban_check
|
|
219
|
+
if dry_run:
|
|
220
|
+
# read-only: detect and report, never persist
|
|
221
|
+
st = reddit_ban_check.banned_state(reddit_subs)
|
|
222
|
+
banned = [s for s, v in st.items() if v is True]
|
|
223
|
+
log(f"ban_check (dry run) subs={len(reddit_subs)} banned={banned}")
|
|
224
|
+
else:
|
|
225
|
+
res = reddit_ban_check.record_confirmed_bans(reddit_subs)
|
|
226
|
+
log(f"ban_check subs={len(reddit_subs)} banned={len(res.get('banned') or [])}"
|
|
227
|
+
f" recorded={res.get('recorded')} upgraded={res.get('upgraded')}")
|
|
228
|
+
if res.get("recorded") or res.get("upgraded"):
|
|
229
|
+
cfg = _load_config() # reload so new ban entries below are seen
|
|
230
|
+
except Exception as e:
|
|
231
|
+
log(f"ban_check_error (non-fatal): {e}")
|
|
183
232
|
|
|
184
233
|
ban_events = []
|
|
185
234
|
for entry in ((cfg.get("subreddit_bans") or {}).get("comment_blocked") or []):
|
|
@@ -194,7 +243,7 @@ def collect_events(window_days: int) -> tuple[list[dict], int, int]:
|
|
|
194
243
|
|
|
195
244
|
|
|
196
245
|
def sweep(window_days: int = 14, dry_run: bool = False) -> dict:
|
|
197
|
-
events, n_removed, n_banned = collect_events(window_days)
|
|
246
|
+
events, n_removed, n_banned = collect_events(window_days, dry_run=dry_run)
|
|
198
247
|
inserted = 0
|
|
199
248
|
duplicates = 0
|
|
200
249
|
if dry_run:
|
package/scripts/post_reddit.py
CHANGED
|
@@ -438,6 +438,12 @@ def _db_pick_salvage_candidates(batch_id, limit=1):
|
|
|
438
438
|
return None
|
|
439
439
|
return {
|
|
440
440
|
"project_name": data.get("project_name") or "general",
|
|
441
|
+
# batch_id must ride the plan file into --phase draft: the
|
|
442
|
+
# snapshot filename, the prompt's top_performers --invoked-by,
|
|
443
|
+
# and the proposed_excludes 2-distinct-batch activation gate all
|
|
444
|
+
# read plan["batch_id"] there (it was silently None before
|
|
445
|
+
# 2026-07-17, which defeated the excludes gate's distinctness).
|
|
446
|
+
"batch_id": batch_id,
|
|
441
447
|
"decisions": data.get("decisions") or [],
|
|
442
448
|
"cost": float(data.get("cost") or 0.0),
|
|
443
449
|
"salvaged": bool(data.get("salvaged", True)),
|
|
@@ -2243,7 +2249,11 @@ def _discover_iteration(args, config, reddit_username, already_picked):
|
|
|
2243
2249
|
except Exception as e:
|
|
2244
2250
|
print(f"[post_reddit] WARNING: seed backfill failed: {e}", file=sys.stderr)
|
|
2245
2251
|
|
|
2252
|
+
# batch_id rides the plan file into --phase draft (snapshot filename,
|
|
2253
|
+
# top_performers --invoked-by, proposed_excludes activation gate). Same
|
|
2254
|
+
# reason as the salvage plan's batch_id above.
|
|
2246
2255
|
return {"project_name": project_name, "decisions": selected,
|
|
2256
|
+
"batch_id": queue_batch,
|
|
2247
2257
|
"cost": 0.0, "session_id": None,
|
|
2248
2258
|
"phase": "discover"}
|
|
2249
2259
|
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# Scratch preview: renders one Reddit review card with a title + selftext,
|
|
2
|
+
# to visually verify the bold-title-over-body thread box (2026-07-17).
|
|
3
|
+
import os, sys
|
|
4
|
+
os.environ["S4L_STATE_DIR"] = "/tmp/s4l-card-preview-state"
|
|
5
|
+
os.makedirs("/tmp/s4l-card-preview-state", exist_ok=True)
|
|
6
|
+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "mcp", "menubar"))
|
|
7
|
+
from AppKit import NSApplication
|
|
8
|
+
import s4l_card
|
|
9
|
+
|
|
10
|
+
drafts = [{
|
|
11
|
+
"n": 1,
|
|
12
|
+
"platform": "reddit",
|
|
13
|
+
"thread_author": "SureThing07",
|
|
14
|
+
"thread_text": "Im fu**ed. I've realised the whole game is a placeholder",
|
|
15
|
+
"thread_selftext": (
|
|
16
|
+
"I've been building this solo for eight months and I just opened the "
|
|
17
|
+
"codebase after a week off. Half of it is stubs I meant to come back "
|
|
18
|
+
"to. The save system? Placeholder. The combat? Placeholder wired to a "
|
|
19
|
+
"real animation so it LOOKED done. I genuinely can't tell how much of "
|
|
20
|
+
"this game actually exists anymore. How do you all deal with this?"
|
|
21
|
+
),
|
|
22
|
+
"reply_text": (
|
|
23
|
+
"my rule of thumb from solo projects: by month six, maybe a third of "
|
|
24
|
+
"the code is actually load-bearing and the rest is scaffolding you "
|
|
25
|
+
"forgot you wrote."
|
|
26
|
+
),
|
|
27
|
+
"thread_url": "https://www.reddit.com/r/gamedev/comments/abc123/placeholder/",
|
|
28
|
+
"candidate_id": "rd-preview1",
|
|
29
|
+
"project": "podlog",
|
|
30
|
+
"engagement_style": "empathize",
|
|
31
|
+
"search_topic": "solo dev burnout",
|
|
32
|
+
"stats": {"replies": 34, "author_followers": 0,
|
|
33
|
+
"tweet_posted_at": "2026-07-17T14:00:00Z"},
|
|
34
|
+
}]
|
|
35
|
+
app = NSApplication.sharedApplication()
|
|
36
|
+
s4l_card.present_review(drafts, on_decision=lambda d: None,
|
|
37
|
+
on_complete=lambda ds: app.terminate_(None))
|
|
38
|
+
app.run()
|
|
@@ -820,12 +820,13 @@ def main() -> int:
|
|
|
820
820
|
# (it stays in pending), never a lost draft — a DRAFTING session is protected by
|
|
821
821
|
# claim_pids, not by grace, so no grace value can kill a real draft.
|
|
822
822
|
#
|
|
823
|
-
# Default
|
|
824
|
-
# cold agent-mode boot (skill load + MCP init) and this reaper's own
|
|
825
|
-
# latency. COUPLING: must stay >= QUEUE_WORKER_POLL_SECONDS + margin
|
|
826
|
-
# constant grows, bump this default (or S4L_REAPER_CLAIM_GRACE_SEC) to
|
|
827
|
-
# the reaper will SIGTERM a legitimately-polling worker before it
|
|
828
|
-
|
|
823
|
+
# Default 1020s (2026-07-17): QUEUE_WORKER_POLL_SECONDS (900s) + ~120s margin
|
|
824
|
+
# for cold agent-mode boot (skill load + MCP init) and this reaper's own
|
|
825
|
+
# ps-scan latency. COUPLING: must stay >= QUEUE_WORKER_POLL_SECONDS + margin;
|
|
826
|
+
# if that constant grows, bump this default (or S4L_REAPER_CLAIM_GRACE_SEC) to
|
|
827
|
+
# match, or the reaper will SIGTERM a legitimately-polling worker before it
|
|
828
|
+
# ever claims. (Was 300s while the poll window was 240s.)
|
|
829
|
+
claim_grace = _env_int("S4L_REAPER_CLAIM_GRACE_SEC", 1020)
|
|
829
830
|
|
|
830
831
|
inflight = count_running_jobs() # None => queue unreadable => age-gate fallback
|
|
831
832
|
claim_pids = running_claim_pids() # agent-session pids actively holding a claim
|