@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.
Files changed (35) hide show
  1. package/mcp/dist/index.js +256 -92
  2. package/mcp/dist/telemetry.js +2 -1
  3. package/mcp/dist/version.json +2 -2
  4. package/mcp/manifest.json +1 -1
  5. package/mcp/menubar/s4l_browser_foreground.py +7 -1
  6. package/mcp/menubar/s4l_card.py +144 -18
  7. package/mcp/menubar/s4l_card_canvas.py +135 -14
  8. package/mcp/menubar/s4l_menubar.py +216 -73
  9. package/mcp/menubar/s4l_state.py +45 -130
  10. package/mcp/package.json +1 -1
  11. package/package.json +1 -1
  12. package/scripts/account_resolver.py +35 -1
  13. package/scripts/autopilot_stall_watch.py +8 -1
  14. package/scripts/browser_lifecycle.py +90 -0
  15. package/scripts/cdp_ready_check.py +81 -50
  16. package/scripts/draft_prompt_core.py +100 -17
  17. package/scripts/engagement_styles.py +135 -98
  18. package/scripts/merge_review_queue.py +192 -129
  19. package/scripts/platform_strike_events.py +54 -5
  20. package/scripts/post_reddit.py +10 -0
  21. package/scripts/preview_reddit_card.py +38 -0
  22. package/scripts/reap_stale_claude_sessions.py +7 -6
  23. package/scripts/reddit_ban_check.py +220 -0
  24. package/scripts/reddit_browser.py +24 -0
  25. package/scripts/release-mcpb.sh +38 -7
  26. package/scripts/setup_twitter_auth.py +20 -96
  27. package/scripts/store_patch.py +111 -0
  28. package/scripts/strike_alert.py +161 -12
  29. package/scripts/test_no_silent_fallbacks.py +11 -0
  30. package/scripts/test_stall_watch_batch_progression.py +99 -0
  31. package/scripts/top_performers.py +24 -0
  32. package/scripts/twitter_browser.py +22 -52
  33. package/skill/archive-old-logs.sh +24 -0
  34. package/skill/lib/reddit-backend.sh +1 -0
  35. 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
- if not pending:
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
- resp = api_get(
276
- "/api/v1/twitter-candidates",
277
- query={"tweet_urls": ",".join(urls), "limit": 500},
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
- existing = []
351
- plan_created_at = None
352
- if os.path.exists(dst):
353
- try:
354
- with open(dst) as f:
355
- prev = json.load(f)
356
- existing = prev.get("candidates") or []
357
- plan_created_at = prev.get("created_at")
358
- except Exception:
359
- existing = []
360
- plan_created_at = None
361
- # Absorb a REAL file at the legacy /tmp location (pre-upgrade store, or one
362
- # written by old code after a reboot) so no pending draft or decision flag
363
- # is lost, then ensure_store_symlink() below replaces it with the link.
364
- legacy = plan_path(REVIEW_QUEUE_ID)
365
- if os.path.exists(legacy) and not os.path.islink(legacy):
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
- with open(legacy) as f:
368
- lp = json.load(f)
369
- lc = lp.get("candidates") or []
370
- have = {_dedup_key(c) for c in existing}
371
- absorbed = [c for c in lc if _dedup_key(c) not in have]
372
- existing.extend(absorbed)
373
- plan_created_at = plan_created_at or lp.get("created_at")
374
- if absorbed:
375
- print(
376
- f"[merge_review_queue] absorbed {len(absorbed)} candidate(s) "
377
- "from legacy /tmp plan into the durable store",
378
- file=sys.stderr,
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] legacy plan absorb failed: {e}", file=sys.stderr)
382
- # Generation stamp: set ONLY when starting a fresh plan (the /tmp plan dies
383
- # on reboot/tmp-sweep and numbering restarts at 1). The menu bar's durable
384
- # approved-queue ledger uses this to ignore decisions that belong to a dead
385
- # plan generation; without it, stale (batch, n) entries silently swallow
386
- # every new draft after a reset. An existing unstamped plan is left
387
- # unstamped: back-stamping it "now" would invalidate live decisions.
388
- if not existing and not plan_created_at:
389
- plan_created_at = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
390
-
391
- seen = {_dedup_key(c) for c in existing}
392
- added = 0
393
- merged = list(existing)
394
- for c in new_cands:
395
- k = _dedup_key(c)
396
- if k in seen:
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] pruned {pruned} candidate(s) already retired by the "
408
- "backend (expired/etc.) before they were reviewed",
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
- import sentry_init
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 1
469
-
470
- print(
471
- f"[merge_review_queue] merged {added} new draft(s) into {REVIEW_QUEUE_ID} "
472
- f"({pending_count} pending total) from {os.path.basename(src)}",
473
- file=sys.stderr,
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
- removed_events = []
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
- removed_events.append(_removed_event(p))
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:
@@ -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 300s (2026-07-06): QUEUE_WORKER_POLL_SECONDS (240s) + ~60s margin for
824
- # cold agent-mode boot (skill load + MCP init) and this reaper's own ps-scan
825
- # latency. COUPLING: must stay >= QUEUE_WORKER_POLL_SECONDS + margin — if that
826
- # constant grows, bump this default (or S4L_REAPER_CLAIM_GRACE_SEC) to match, or
827
- # the reaper will SIGTERM a legitimately-polling worker before it ever claims.
828
- claim_grace = _env_int("S4L_REAPER_CLAIM_GRACE_SEC", 300)
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