@m13v/s4l 1.7.4-rc.4 → 1.7.4

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 (86) hide show
  1. package/README.md +134 -101
  2. package/bin/cli.js +23 -0
  3. package/mcp/dist/index.js +24 -5
  4. package/mcp/dist/runtime.js +110 -0
  5. package/mcp/dist/version.js +163 -54
  6. package/mcp/dist/version.json +2 -2
  7. package/mcp/manifest.json +1 -1
  8. package/mcp/menubar/s4l_card.py +55 -19
  9. package/mcp/menubar/s4l_menubar.py +179 -56
  10. package/mcp/menubar/s4l_state.py +41 -1
  11. package/mcp/package.json +1 -1
  12. package/package.json +4 -2
  13. package/scripts/active_experiments.py +30 -6
  14. package/scripts/autopilot_stall_watch.py +185 -63
  15. package/scripts/cdp_ready_check.py +94 -0
  16. package/scripts/engage_twitter_helper.py +83 -17
  17. package/scripts/engagement_styles.py +221 -26
  18. package/scripts/enrich_reply_parents.py +68 -22
  19. package/scripts/feedback_digest.py +24 -1
  20. package/scripts/generate_daily_human_style.py +31 -7
  21. package/scripts/invent_styles.py +103 -26
  22. package/scripts/linkedin_cadence.py +234 -0
  23. package/scripts/linkedin_killswitch.py +43 -0
  24. package/scripts/memory_snapshot.py +57 -0
  25. package/scripts/patches/browser-harness-loopback-cdp-proxy.patch +118 -0
  26. package/scripts/recent_self_posts.py +6 -0
  27. package/scripts/reddit_browser.py +57 -3
  28. package/scripts/s4l_box_update.sh +20 -1
  29. package/scripts/s4l_mode.py +3 -4
  30. package/scripts/scan_x_profile.py +25 -7
  31. package/scripts/schedule_state.py +23 -0
  32. package/scripts/scheduled_task_selfheal.py +213 -16
  33. package/scripts/setup_twitter_auth.py +6 -1
  34. package/scripts/snapshot.py +179 -61
  35. package/scripts/stall_guard.py +70 -0
  36. package/scripts/test_scheduled_task_selfheal.py +121 -0
  37. package/scripts/top_performers.py +34 -0
  38. package/scripts/twitter_browser.py +146 -8
  39. package/scripts/twitter_post_plan.py +10 -1
  40. package/scripts/twitter_scan.py +26 -0
  41. package/scripts/watchdog_hung_runs.py +117 -3
  42. package/skill/amplitude-24h-signups.sh +1 -1
  43. package/skill/audit-dm-staleness.sh +1 -1
  44. package/skill/audit-linkedin.sh +1 -1
  45. package/skill/audit-reddit-resurrect.sh +1 -1
  46. package/skill/audit.sh +5 -5
  47. package/skill/check-web-chats.sh +1 -1
  48. package/skill/dm-outreach-linkedin.sh +4 -4
  49. package/skill/dm-outreach-reddit.sh +1 -1
  50. package/skill/dm-outreach-twitter.sh +1 -1
  51. package/skill/engage-dm-replies.sh +16 -3
  52. package/skill/engage-linkedin.sh +2 -2
  53. package/skill/engage-moltbook.sh +1 -1
  54. package/skill/engage-twitter.sh +4 -4
  55. package/skill/github-engage.sh +1 -1
  56. package/skill/lib/browser-launch.sh +54 -0
  57. package/skill/lib/linkedin-backend.sh +22 -15
  58. package/skill/lib/reddit-backend.sh +17 -11
  59. package/skill/lib/twitter-backend.sh +137 -16
  60. package/skill/link-edit-github.sh +1 -1
  61. package/skill/link-edit-moltbook.sh +1 -1
  62. package/skill/link-edit-reddit.sh +1 -1
  63. package/skill/linkedin-cadence.sh +20 -0
  64. package/skill/linkedin-presence.sh +1 -1
  65. package/skill/lock.sh +30 -6
  66. package/skill/precompute-stats.sh +1 -1
  67. package/skill/refresh-instagram-tokens.sh +1 -1
  68. package/skill/refresh-twitter-following.sh +1 -1
  69. package/skill/run-generate-daily-style.sh +1 -1
  70. package/skill/run-instagram-daily.sh +1 -1
  71. package/skill/run-instagram-render.sh +1 -1
  72. package/skill/run-linkedin-launchd.sh +1 -1
  73. package/skill/run-linkedin-unipile.sh +1 -1
  74. package/skill/run-linkedin.sh +1 -1
  75. package/skill/run-reddit-search.sh +2 -2
  76. package/skill/run-reddit-threads.sh +1 -1
  77. package/skill/run-twitter-cycle.sh +190 -55
  78. package/skill/scan-instagram-replies.sh +1 -1
  79. package/skill/sentry-digest.sh +1 -1
  80. package/skill/stats-instagram.sh +1 -1
  81. package/skill/stats-linkedin.sh +2 -2
  82. package/skill/stats.sh +1 -1
  83. package/skill/sweep-link-clicks.sh +1 -1
  84. package/scripts/_lock_preempt_test.py +0 -60
  85. package/scripts/saps_activity.py +0 -18
  86. package/scripts/saps_mode.py +0 -18
@@ -70,7 +70,7 @@ PERSONA_REQUIRED_FIELDS = ["name", "description", "voice", "search_topics"]
70
70
  CURRENT_WORKER_TASK_IDS = ("s4l-worker", "saps-worker")
71
71
  LEGACY_WORKER_TASK_IDS = ("saps-phase1-query", "saps-phase2b-draft")
72
72
  UPDATER_LABEL = "com.m13v.social-autoposter-update"
73
- AUTOPILOT_STALL_MS = 180_000
73
+ AUTOPILOT_STALL_MS = 1_200_000
74
74
 
75
75
  # Milestones overlaid with LIVE state for display (the rest keep their ledger
76
76
  # value). Mirrors the overlay in buildSnapshot().
@@ -325,6 +325,81 @@ def _x_status():
325
325
  _ver_cache = {"at": 0.0, "latest": None, "tag": None, "channel": None, "checked": False}
326
326
  _VER_TTL = 55.0
327
327
 
328
+ # SHARED CROSS-PROCESS CACHE (2026-07-13): <state dir>/latest-release.json.
329
+ # Every surface that resolves the newest release (this module, mcp/src/
330
+ # version.ts, scripts/s4l_box_update.sh) reads and writes THIS one file, so a
331
+ # box makes at most one real GitHub probe per _SHARED_TTL no matter how many
332
+ # short-lived processes spin up (MCP servers respawn per s4l-worker session,
333
+ # buildSnapshot shells this module as a fresh subprocess, the menu bar ticks).
334
+ # The persisted ETag makes even those probes quota-free between releases (304s
335
+ # do not count against the anonymous 60/h-per-IP quota, and before this cache
336
+ # each process held its OWN in-process ETag that died with the process, so
337
+ # every respawn paid a full 200). Added after the box's aggregate probing
338
+ # (~80-100 req/h across processes) burned the quota on 2026-07-13 and silenced
339
+ # the update banner. Failures (version=None) are cached too, same rationale as
340
+ # _ver_cache. Keep the file shape in lockstep with version.ts::SharedCache:
341
+ # {"at": <epoch s>, "channel": ..., "version": ..., "tag": ..., "etag": ...}
342
+ _SHARED_TTL = 600.0 # banner latency ceiling: a new release surfaces within 10 min
343
+ _PROBE_LOCK_STALE = 30.0
344
+
345
+
346
+ def _shared_cache_path():
347
+ return os.path.join(_state_dir(), "latest-release.json")
348
+
349
+
350
+ def _read_shared_cache(channel):
351
+ d = _read_json(_shared_cache_path())
352
+ if not isinstance(d, dict) or d.get("channel") != channel:
353
+ return None
354
+ if not isinstance(d.get("at"), (int, float)):
355
+ return None
356
+ return {
357
+ "at": float(d["at"]),
358
+ "channel": channel,
359
+ "version": d.get("version") if isinstance(d.get("version"), str) else None,
360
+ "tag": d.get("tag") if isinstance(d.get("tag"), str) else None,
361
+ "etag": d.get("etag") if isinstance(d.get("etag"), str) else None,
362
+ }
363
+
364
+
365
+ def _write_shared_cache(channel, version, tag, etag):
366
+ try:
367
+ os.makedirs(_state_dir(), exist_ok=True)
368
+ p = _shared_cache_path()
369
+ tmp = "%s.tmp.%d" % (p, os.getpid())
370
+ with open(tmp, "w") as f:
371
+ json.dump({"at": time.time(), "channel": channel,
372
+ "version": version, "tag": tag, "etag": etag}, f)
373
+ os.replace(tmp, p)
374
+ except Exception:
375
+ pass # best effort; worst case another process re-probes
376
+
377
+
378
+ def _try_probe_lock():
379
+ """Single-flight guard so N concurrent processes with an expired shared
380
+ cache don't all probe at once. Returns (acquired, lock_path). acquired is
381
+ False ONLY when another probe holds a FRESH lock (< _PROBE_LOCK_STALE s);
382
+ any other failure returns (True, None) so we probe anyway rather than go
383
+ blind on a broken state dir."""
384
+ lock = os.path.join(_state_dir(), "latest-release.lock")
385
+ try:
386
+ os.makedirs(_state_dir(), exist_ok=True)
387
+ fd = os.open(lock, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
388
+ os.close(fd)
389
+ return True, lock
390
+ except FileExistsError:
391
+ try:
392
+ if time.time() - os.path.getmtime(lock) > _PROBE_LOCK_STALE:
393
+ os.unlink(lock) # stale: holder died mid-probe
394
+ fd = os.open(lock, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
395
+ os.close(fd)
396
+ return True, lock
397
+ except Exception:
398
+ pass
399
+ return False, None
400
+ except Exception:
401
+ return True, None
402
+
328
403
  _RELEASES_LATEST_URL = "https://github.com/m13v/s4l/releases/latest"
329
404
  _RELEASES_LATEST_API = "https://api.github.com/repos/m13v/s4l/releases/latest"
330
405
  # Staging resolves the newest release OVERALL from the releases LIST, since
@@ -367,56 +442,64 @@ def _latest_from_github_redirect():
367
442
  return None
368
443
 
369
444
 
370
- # In-process conditional-request state. Long-lived processes (the menu bar) send
371
- # If-None-Match on every probe and get free 304s; short-lived shell-outs pay one
372
- # 200 per process, which is rare enough to stay far under quota.
373
- _api_state = {"etag": None, "latest": None}
374
-
375
-
376
- def _latest_from_github_api():
445
+ # Conditional-request state lives in the SHARED cache file (latest-release.json)
446
+ # so the ETag survives process boundaries: short-lived shell-outs used to pay a
447
+ # full 200 per process; now every probe sends If-None-Match and gets a free 304
448
+ # between releases.
449
+ def _curl_conditional(url, etag):
450
+ """GET url with optional If-None-Match. Returns (status, new_etag, body)."""
451
+ args = ["/usr/bin/curl", "-sS", "-m", "10",
452
+ "-H", "Accept: application/vnd.github+json"]
453
+ if etag:
454
+ args += ["-H", "If-None-Match: %s" % etag]
455
+ args += ["-w", "\n__CURL_STATUS__:%{http_code}\n__CURL_ETAG__:%header{etag}", url]
456
+ res = subprocess.run(args, capture_output=True, text=True, timeout=12)
457
+ status, new_etag, body = 0, None, []
458
+ for line in (res.stdout or "").splitlines():
459
+ if line.startswith("__CURL_STATUS__:"):
460
+ status = int(line.split(":", 1)[1].strip() or 0)
461
+ elif line.startswith("__CURL_ETAG__:"):
462
+ new_etag = line.split(":", 1)[1].strip() or None
463
+ else:
464
+ body.append(line)
465
+ return status, new_etag, "\n".join(body)
466
+
467
+
468
+ def _latest_from_github_api(etag=None, cached=None):
469
+ """Stable probe (releases/latest). Returns (version, etag). On 304 serves
470
+ the caller-supplied cached version with the same etag; If-None-Match is only
471
+ sent when there IS a cached value to serve."""
377
472
  try:
378
- args = ["/usr/bin/curl", "-sS", "-m", "10",
379
- "-H", "Accept: application/vnd.github+json"]
380
- if _api_state["etag"]:
381
- args += ["-H", "If-None-Match: %s" % _api_state["etag"]]
382
- args += ["-w", "\n__CURL_STATUS__:%{http_code}\n__CURL_ETAG__:%header{etag}",
383
- _RELEASES_LATEST_API]
384
- res = subprocess.run(args, capture_output=True, text=True, timeout=12)
385
- status, etag, body = 0, None, []
386
- for line in (res.stdout or "").splitlines():
387
- if line.startswith("__CURL_STATUS__:"):
388
- status = int(line.split(":", 1)[1].strip() or 0)
389
- elif line.startswith("__CURL_ETAG__:"):
390
- etag = line.split(":", 1)[1].strip() or None
391
- else:
392
- body.append(line)
473
+ status, new_etag, body = _curl_conditional(
474
+ _RELEASES_LATEST_API, etag if cached else None)
393
475
  if status == 304:
394
- return _api_state["latest"]
476
+ return cached, etag
395
477
  if status != 200:
396
- return None
397
- tag = (json.loads("\n".join(body)) or {}).get("tag_name")
478
+ return None, None
479
+ tag = (json.loads(body) or {}).get("tag_name")
398
480
  v = _parse_semverish(tag.lstrip("v").strip()) if isinstance(tag, str) else None
399
- if v:
400
- _api_state.update(etag=etag, latest=v)
401
- return v
481
+ return (v, new_etag) if v else (None, None)
402
482
  except Exception:
403
- return None
483
+ return None, None
404
484
 
405
485
 
406
- def _latest_from_github_list_staging():
486
+ def _latest_from_github_list_staging(etag=None, cached=None):
407
487
  """Staging channel: newest release OVERALL (prereleases included) from the
408
- releases LIST endpoint. Returns (version, tag) or (None, None). Drafts are
409
- skipped. 'Newest' is by the rc-aware key so 1.6.193 outranks 1.6.193-rc.N and
410
- rc.2 outranks rc.1."""
488
+ releases LIST endpoint. Returns (version, tag, etag) or (None, None, None).
489
+ Drafts are skipped. 'Newest' is by the rc-aware key so 1.6.193 outranks
490
+ 1.6.193-rc.N and rc.2 outranks rc.1. On 304 serves the caller-supplied
491
+ cached (version, tag) with the same etag."""
411
492
  try:
412
- res = subprocess.run(
413
- ["/usr/bin/curl", "-sS", "-m", "10",
414
- "-H", "Accept: application/vnd.github+json",
415
- _RELEASES_LIST_API],
416
- capture_output=True, text=True, timeout=12)
417
- rels = json.loads(res.stdout or "[]")
493
+ cached_v, cached_tag = cached if isinstance(cached, tuple) else (None, None)
494
+ status, new_etag, body = _curl_conditional(
495
+ _RELEASES_LIST_API, etag if (cached_v and cached_tag) else None)
496
+ if status == 304:
497
+ return cached_v, cached_tag, etag
498
+ if status != 200:
499
+ return None, None, None
500
+ rels = json.loads(body or "[]")
418
501
  if not isinstance(rels, list):
419
- return None, None
502
+ return None, None, None
420
503
  best_v, best_tag, best_key = None, None, None
421
504
  for r in rels:
422
505
  if not isinstance(r, dict) or r.get("draft"):
@@ -430,9 +513,11 @@ def _latest_from_github_list_staging():
430
513
  k = _ver_key(v)
431
514
  if best_key is None or k > best_key:
432
515
  best_v, best_tag, best_key = v, tag, k
433
- return best_v, best_tag
516
+ if best_v is None:
517
+ return None, None, None
518
+ return best_v, best_tag, new_etag
434
519
  except Exception:
435
- return None, None
520
+ return None, None, None
436
521
 
437
522
 
438
523
  def _latest_from_npm():
@@ -460,31 +545,64 @@ def _resolve_version() -> str:
460
545
  def _latest_published(channel=None):
461
546
  """(version, tag) for the newest release on this box's channel. The tag is
462
547
  what the staging download URL is built from; stable callers can ignore it and
463
- use releases/latest/download. Cached with the channel so a mid-process
464
- channel flip re-probes instead of serving the other channel's cached value."""
548
+ use releases/latest/download. Two cache layers, both keyed by channel so a
549
+ mid-process flip re-probes instead of serving the other channel's value:
550
+ 1. in-process _ver_cache (55s) — spares the file read on tight tick loops;
551
+ 2. shared latest-release.json (_SHARED_TTL) — spares the NETWORK probe
552
+ across ALL processes on the box.
553
+ Failures (latest=None) are cached in both, like version.ts: a tick loop
554
+ re-probing an unreachable/rate-limited GitHub every few seconds would burn
555
+ the unauthenticated API quota (60/h per IP) and lock itself out for good."""
465
556
  if channel is None:
466
557
  channel = _channel()
467
558
  now = time.time()
468
- # Cache failures (latest=None) too, like version.ts: a menu-bar tick loop
469
- # re-probing an unreachable/rate-limited GitHub every few seconds would burn
470
- # the unauthenticated API quota (60/h per IP) and lock itself out for good.
471
559
  if (_ver_cache["checked"] and _ver_cache["channel"] == channel
472
560
  and now - _ver_cache["at"] < _VER_TTL):
473
561
  return _ver_cache["latest"], _ver_cache["tag"]
474
- if channel == "staging":
475
- latest, tag = _latest_from_github_list_staging()
476
- # Fallback to the stable probes if the list endpoint fails, so a staging
477
- # box degrades to "at least track stable" rather than going blind.
478
- if latest is None:
479
- latest = _latest_from_github_api() or _latest_from_github_redirect()
562
+ shared = _read_shared_cache(channel)
563
+ if shared and now - shared["at"] < _SHARED_TTL:
564
+ _ver_cache.update(at=now, latest=shared["version"], tag=shared["tag"],
565
+ channel=channel, checked=True)
566
+ return shared["version"], shared["tag"]
567
+ acquired, lock = _try_probe_lock()
568
+ if not acquired:
569
+ # Another process is probing right now; serve the stale shared value
570
+ # (or None) instead of doubling the request. Short in-process TTL means
571
+ # we pick up its fresh result within a minute.
572
+ latest = shared["version"] if shared else None
573
+ tag = shared["tag"] if shared else None
574
+ _ver_cache.update(at=now, latest=latest, tag=tag, channel=channel, checked=True)
575
+ return latest, tag
576
+ etag = shared["etag"] if shared else None
577
+ try:
578
+ if channel == "staging":
579
+ latest, tag, new_etag = _latest_from_github_list_staging(
580
+ etag, (shared["version"], shared["tag"]) if shared else None)
581
+ # Fallback to the stable probes if the list endpoint fails, so a
582
+ # staging box degrades to "at least track stable" than going blind.
583
+ # new_etag stays None: a releases/latest etag must never be replayed
584
+ # against the LIST endpoint on the next staging probe.
585
+ if latest is None:
586
+ latest, _ = _latest_from_github_api()
587
+ new_etag = None
588
+ if latest is None:
589
+ latest = _latest_from_github_redirect()
590
+ tag = ("v" + latest) if latest else None
591
+ else:
592
+ latest, new_etag = _latest_from_github_api(
593
+ etag, shared["version"] if shared else None)
594
+ if latest is None:
595
+ latest, new_etag = _latest_from_github_redirect(), None
596
+ if latest is None:
597
+ latest = _latest_from_npm()
480
598
  tag = ("v" + latest) if latest else None
481
- else:
482
- latest = _latest_from_github_api()
483
- if latest is None:
484
- latest = _latest_from_github_redirect()
485
- if latest is None:
486
- latest = _latest_from_npm()
487
- tag = ("v" + latest) if latest else None
599
+ _write_shared_cache(channel, latest, tag, new_etag)
600
+ finally:
601
+ if lock:
602
+ try:
603
+ os.unlink(lock)
604
+ except Exception:
605
+ pass
488
606
  _ver_cache.update(at=now, latest=latest, tag=tag, channel=channel, checked=True)
489
607
  return latest, tag
490
608
 
@@ -0,0 +1,70 @@
1
+ #!/usr/bin/env python3
2
+ """Shared zero-progress deadline for browser-touching pipeline steps.
3
+
4
+ Any step that relays CDP to the harness Chrome (scan queries, thread media
5
+ capture, ...) can hang FOREVER against a half-wedged browser or a dead
6
+ renderer: the call never returns, the step holds the browser lock, and the
7
+ cycle-entry health checks that could heal Chrome can't run because the next
8
+ cycle can't start. 2026-07-13: a scan hung 23 min at zero queries; the same
9
+ night media capture hung 34+ min at candidate 24/92 and only the watchdog's
10
+ 90-min lock-liveness net was ever going to reap it.
11
+
12
+ One implementation, used by every step (twitter_scan.py Phase-1 queries,
13
+ twitter_browser.py media capture). Usage:
14
+
15
+ from stall_guard import stall_guard
16
+ with stall_guard("thread_media", url): # default deadline 240s
17
+ <one bounded unit of browser work>
18
+
19
+ If the guarded block does not finish inside the deadline the guard:
20
+ 1. prints a loud machine-greppable marker to stderr
21
+ ([stall_guard] STALL_ABORT step=<step> ...)
22
+ 2. stamps the port's wedge strike file, so the next cycle's two-strike
23
+ gate in skill/lib/twitter-backend.sh converges to a kill+relaunch if
24
+ Chrome is genuinely sick
25
+ 3. hard-exits the process with EX_TEMPFAIL (75) so the cycle fails fast
26
+ and releases the browser lock + cycle slot.
27
+
28
+ A completed block cancels its timer; a healthy pipeline never notices the
29
+ guard exists. Deadline override: S4L_BROWSER_OP_DEADLINE_S (seconds).
30
+ """
31
+ from __future__ import annotations
32
+
33
+ import contextlib
34
+ import os
35
+ import pathlib
36
+ import sys
37
+ import threading
38
+
39
+ DEFAULT_DEADLINE_S = float(os.environ.get("S4L_BROWSER_OP_DEADLINE_S", "240"))
40
+
41
+
42
+ def _strike_file(port: int) -> str:
43
+ # Must match the two-strike gate in skill/lib/twitter-backend.sh.
44
+ return f"/tmp/s4l_cdp_wedge_strike_{port}"
45
+
46
+
47
+ @contextlib.contextmanager
48
+ def stall_guard(step: str, detail: str = "", deadline_s: float | None = None, port: int = 9555):
49
+ limit = DEFAULT_DEADLINE_S if deadline_s is None else float(deadline_s)
50
+
51
+ def _fire():
52
+ sys.stderr.write(
53
+ f"[stall_guard] STALL_ABORT step={step} detail={detail!r} "
54
+ f"deadline={limit:.0f}s — browser call hung (wedged Chrome or dead "
55
+ "renderer); stamping wedge strike and aborting so the lock frees\n"
56
+ )
57
+ sys.stderr.flush()
58
+ try:
59
+ pathlib.Path(_strike_file(port)).touch()
60
+ except OSError:
61
+ pass
62
+ os._exit(75) # EX_TEMPFAIL
63
+
64
+ t = threading.Timer(limit, _fire)
65
+ t.daemon = True
66
+ t.start()
67
+ try:
68
+ yield
69
+ finally:
70
+ t.cancel()
@@ -0,0 +1,121 @@
1
+ #!/usr/bin/env python3
2
+ """Focused tests for fresh-account scheduled-task registry creation."""
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import os
8
+ import sqlite3
9
+ import sys
10
+ import tempfile
11
+ import unittest
12
+ from unittest import mock
13
+
14
+ sys.path.insert(0, os.path.dirname(__file__))
15
+ import scheduled_task_selfheal as selfheal # noqa: E402
16
+
17
+
18
+ ACCOUNT_UUID = "f598e7b5-55be-46e8-815e-e8ddb394fc73"
19
+ ORG_UUID = "d03d2d69-12d0-45bf-9f67-ae085d6848e5"
20
+
21
+
22
+ def _write_cookie_db(root: str, *, value: str = "", encrypted: bytes = b"v10fixture"):
23
+ db = os.path.join(root, "Cookies")
24
+ conn = sqlite3.connect(db)
25
+ try:
26
+ conn.execute(
27
+ """
28
+ CREATE TABLE cookies (
29
+ host_key TEXT,
30
+ name TEXT,
31
+ value TEXT,
32
+ encrypted_value BLOB,
33
+ last_access_utc INTEGER
34
+ )
35
+ """
36
+ )
37
+ conn.execute(
38
+ "INSERT INTO cookies VALUES (?, 'lastActiveOrg', ?, ?, 1)",
39
+ (selfheal.CLAUDE_COOKIE_HOST, value, encrypted),
40
+ )
41
+ conn.commit()
42
+ finally:
43
+ conn.close()
44
+
45
+
46
+ class ScheduledTaskSelfhealTest(unittest.TestCase):
47
+ def test_decrypted_cookie_strips_chromium_host_digest(self):
48
+ with tempfile.TemporaryDirectory() as root:
49
+ _write_cookie_db(root)
50
+ clear = (
51
+ hashlib.sha256(selfheal.CLAUDE_COOKIE_HOST.encode()).digest()
52
+ + ORG_UUID.encode()
53
+ )
54
+ with (
55
+ mock.patch.object(
56
+ selfheal, "_claude_safe_storage_password", return_value=b"fixture"
57
+ ),
58
+ mock.patch.object(selfheal, "_aes_128_cbc_decrypt", return_value=clear),
59
+ ):
60
+ self.assertEqual(selfheal._active_org_uuid_from_cookie(root), ORG_UUID)
61
+
62
+ def test_capability_probe_does_not_read_keychain(self):
63
+ with tempfile.TemporaryDirectory() as root:
64
+ os.makedirs(root, exist_ok=True)
65
+ config = os.path.join(root, "config.json")
66
+ with open(config, "w") as fh:
67
+ json.dump({"lastKnownAccountUuid": ACCOUNT_UUID}, fh)
68
+ _write_cookie_db(root)
69
+ with (
70
+ mock.patch.object(
71
+ selfheal.schedule_state, "_config_json_paths", return_value=[config]
72
+ ),
73
+ mock.patch.object(
74
+ selfheal,
75
+ "_claude_safe_storage_password",
76
+ side_effect=AssertionError("passive probe touched Keychain"),
77
+ ),
78
+ ):
79
+ self.assertTrue(selfheal.can_create_for_active_account())
80
+
81
+ def test_heal_creates_exact_account_org_registry_without_session_dir(self):
82
+ with tempfile.TemporaryDirectory() as root:
83
+ config = os.path.join(root, "config.json")
84
+ with open(config, "w") as fh:
85
+ json.dump({"lastKnownAccountUuid": ACCOUNT_UUID}, fh)
86
+ worker_cwd = os.path.join(root, "worker")
87
+ registry_glob = os.path.join(
88
+ root, "claude-code-sessions", "*", "*", "scheduled-tasks.json"
89
+ )
90
+ expected = os.path.join(
91
+ root,
92
+ "claude-code-sessions",
93
+ ACCOUNT_UUID,
94
+ ORG_UUID,
95
+ "scheduled-tasks.json",
96
+ )
97
+ with (
98
+ mock.patch.object(
99
+ selfheal.schedule_state, "_config_json_paths", return_value=[config]
100
+ ),
101
+ mock.patch.object(
102
+ selfheal, "_active_org_uuid_from_cookie", return_value=ORG_UUID
103
+ ),
104
+ mock.patch.object(selfheal, "_ensure_worker_skill_md", return_value=True),
105
+ mock.patch.object(selfheal, "WORKER_CWD", worker_cwd),
106
+ mock.patch.object(selfheal, "SCHED_REGISTRY_GLOB", registry_glob),
107
+ mock.patch.object(selfheal, "DEPRECATED_TASK_IDS", ()),
108
+ mock.patch.object(selfheal, "LEGACY_WORKER_TASK_IDS", ()),
109
+ ):
110
+ summary = selfheal.heal()
111
+
112
+ self.assertEqual(summary["created"], [expected])
113
+ with open(expected) as fh:
114
+ registry = json.load(fh)
115
+ self.assertEqual(registry["recordedSkips"], [])
116
+ self.assertEqual(registry["scheduledTasks"][0]["id"], selfheal.WORKER_TASK_ID)
117
+ self.assertEqual(registry["scheduledTasks"][0]["cwd"], worker_cwd)
118
+
119
+
120
+ if __name__ == "__main__":
121
+ unittest.main()
@@ -567,9 +567,43 @@ def main():
567
567
  "summary, no bottom posts). This is the lean "
568
568
  "on-demand shape the drafting session calls "
569
569
  "after routing a candidate to a project."))
570
+ parser.add_argument("--invoked-by", default=None,
571
+ help=("Caller tag recorded in the on-demand invocation "
572
+ "ledger (the drafting prompt passes the cycle's "
573
+ "batch_id). Tracking only; no behavior change."))
570
574
  parser.add_argument("--json", action="store_true", help="Output as JSON")
571
575
  args = parser.parse_args()
572
576
 
577
+ # On-demand invocation ledger (2026-07-10). Any --project call is the
578
+ # on-demand per-project winners lookup the draft prompt offers; record it
579
+ # at the TOOL level (model self-reports are unreliable) so we can measure
580
+ # whether drafting sessions actually use the query. One JSON line per
581
+ # call in $S4L_STATE_DIR/top-performers-invocations.jsonl; the cycle
582
+ # counts lines for its batch_id after prep and logs a
583
+ # [project_top_performers] marker. Best-effort: never block the report.
584
+ if args.project:
585
+ try:
586
+ import datetime
587
+ state_dir = os.environ.get(
588
+ "S4L_STATE_DIR",
589
+ os.path.expanduser("~/.social-autoposter-mcp"))
590
+ os.makedirs(state_dir, exist_ok=True)
591
+ with open(os.path.join(state_dir,
592
+ "top-performers-invocations.jsonl"),
593
+ "a") as fh:
594
+ fh.write(json.dumps({
595
+ "ts": datetime.datetime.now(
596
+ datetime.timezone.utc).isoformat(),
597
+ "project": args.project,
598
+ "platform": args.platform,
599
+ "brief": bool(args.brief),
600
+ "top": args.top,
601
+ "invoked_by": args.invoked_by,
602
+ }) + "\n")
603
+ except Exception as exc:
604
+ print(f"[top_performers] invocation ledger write failed: {exc!r}",
605
+ file=sys.stderr)
606
+
573
607
  (summary, style_perf, top, bottom, fallback_top,
574
608
  top_by_group, top_by_style) = _fetch_report_via_api(
575
609
  platform=args.platform, project=args.project, top=args.top, bottom=args.bottom,