@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
@@ -15,12 +15,26 @@ This job runs OUTSIDE any drafting context, on the operator Mac only:
15
15
  style), so invention must NOT fan out per install the way topic
16
16
  invention does. One central daily run is the correct scope; that is
17
17
  the deliberate difference from invent_topics.py's per-install kicker.
18
- - The prompt carries the existing style universe and explicitly forbids
19
- the dominant structural family; the ask is a style from an
20
- UNREPRESENTED family, not a riff on the champion.
18
+ - VERBALIZED SAMPLING (2026-07-10, arXiv 2510.01171): a single "invent
19
+ one style" ask reliably returns the modal answer (typicality bias from
20
+ preference training), which is why 653 of ~950 registered styles
21
+ cluster at 53-98 target_chars and one rhetorical family. Instead the
22
+ prompt asks for N_CANDIDATES candidate styles WITH a verbalized
23
+ typicality probability each, and selection takes the LOWEST-probability
24
+ candidate that survives dedup: we sample the tail of the distribution,
25
+ not the mode. This is the principled fix; the family blocklist below is
26
+ only a backstop.
27
+ - The existing registry is framed as OCCUPIED NICHES to stay out of
28
+ (quality-diversity framing, arXiv 2310.13032), NOT as reference
29
+ inspiration; showing top performers as exemplars is what anchored the
30
+ retired inline path to the champion family.
31
+ - target_chars DIVERSITY: the prompt shows live length-band occupancy
32
+ and requires candidates to spread across bands, biased to the
33
+ least-occupied ones, so invention stops minting 80-char styles.
21
34
  - Post-hoc semantic dedup (token-Jaccard on description+example, plus a
22
- reframe-family heuristic) rejects near-clones; a rejection re-prompts
23
- with a grown avoid-list, at most DUPE_RETRIES times.
35
+ reframe-family heuristic) rejects near-clones; when every candidate is
36
+ rejected we re-prompt with a grown avoid-list, at most DUPE_RETRIES
37
+ times.
24
38
  - Accepted styles are registered via engagement_styles.register_style
25
39
  (kind='model_invented'), same as the retired inline path, so pickers
26
40
  see them on their next tick with zero other wiring.
@@ -52,6 +66,12 @@ SCRIPT_TAG = "invent-styles"
52
66
  CALL_TIMEOUT_SEC = 420
53
67
  DUPE_RETRIES = 3
54
68
  SIMILARITY_THRESHOLD = 0.5 # Jaccard on description+example tokens
69
+ N_CANDIDATES = 5 # verbalized-sampling fan-out per call
70
+
71
+ # Length bands for target_chars diversity. Occupancy is computed live from
72
+ # the registry and shown in the prompt; candidates must spread across bands
73
+ # and bias toward the least-occupied ones.
74
+ LENGTH_BANDS = [(30, 60), (60, 100), (100, 150), (150, 200), (200, 250)]
55
75
 
56
76
  # Heuristic markers of the saturated agree-then-relocate/reframe family.
57
77
  # A proposal whose description/example leans on these is a clone of the
@@ -98,6 +118,23 @@ def _find_near_dupe(proposal, universe):
98
118
  return None
99
119
 
100
120
 
121
+ def band_occupancy(universe):
122
+ """[(lo, hi, count), ...] for LENGTH_BANDS over the registry's
123
+ target_chars. The live histogram the prompt shows so candidates can
124
+ claim underrepresented bands."""
125
+ counts = [0] * len(LENGTH_BANDS)
126
+ for e in universe.values():
127
+ try:
128
+ tc = int(e.get("target_chars") or 0)
129
+ except (TypeError, ValueError):
130
+ continue
131
+ for i, (lo, hi) in enumerate(LENGTH_BANDS):
132
+ if lo <= tc < hi:
133
+ counts[i] += 1
134
+ break
135
+ return [(lo, hi, counts[i]) for i, (lo, hi) in enumerate(LENGTH_BANDS)]
136
+
137
+
101
138
  def build_prompt(universe, avoid):
102
139
  names = sorted(universe.keys())
103
140
  # Detail only a bounded sample (prompt-size guard): the seeds plus the
@@ -117,25 +154,42 @@ def build_prompt(universe, avoid):
117
154
  "\nAlready proposed and REJECTED this run (do not resubmit or "
118
155
  "paraphrase): " + ", ".join(avoid) + "\n"
119
156
  )
157
+ bands = band_occupancy(universe)
158
+ band_lines = "\n".join(
159
+ f"- {lo}-{hi} chars: {count} existing styles"
160
+ f"{' <- UNDERREPRESENTED, prefer this band' if count == min(c for _, _, c in bands) else ''}"
161
+ for lo, hi, count in bands
162
+ )
120
163
  return f"""You maintain the engagement-style registry for a social reply system. A style is a named rhetorical TEMPLATE (description + one example reply) that drafters follow when writing short replies on X/Twitter and Reddit.
121
164
 
122
- PROBLEM: the registry is saturated with ONE structural family: agree-then-relocate (concede the surface point, move the spotlight to the hidden/harder/unmeasured part, often "X is easy, Y is the real work" or "X was never the point, Y is"). Do NOT invent another member of that family, however disguised.
165
+ The registry below is OCCUPIED TERRITORY, not inspiration. Your job is to find empty niches: structural families and length bands that no existing style covers. Do not riff on what is already there.
166
+
167
+ The registry is saturated with ONE structural family: agree-then-relocate (concede the surface point, move the spotlight to the hidden/harder/unmeasured part, often "X is easy, Y is the real work" or "X was never the point, Y is"). Do NOT propose any member of that family, however disguised.
123
168
 
124
169
  EXISTING STYLE NAMES ({len(names)} total):
125
170
  {", ".join(names)}
126
171
 
127
- REPRESENTATIVE DETAILS (sample):
172
+ REPRESENTATIVE DETAILS (occupied niches, sample):
128
173
  {chr(10).join(detailed)}
174
+
175
+ LENGTH-BAND OCCUPANCY (live registry histogram of target_chars):
176
+ {band_lines}
129
177
  {avoid_block}
130
- TASK: invent exactly ONE genuinely new style from a structural family that is missing or rare above. Families worth mining (pick ONE, or another you identify): direct answer with zero framing; first-person confession of a specific failure; pure curious question with no thesis; dry understatement one-liner; enthusiastic cosign with one concrete addition; flat disagreement stated plainly without conceding anything first; a tiny numbered checklist; a vivid analogy that does NOT end in a lesson; deadpan humor riffing on the thread's wording.
178
+ TASK (verbalized sampling): generate {N_CANDIDATES} CANDIDATE styles. For each, verbalize a `probability`: how likely this exact style would be as a language model's single first answer to "invent a new reply style" (0.0-1.0, honest, they need not sum to 1). Deliberately include tail candidates: at least 3 of the {N_CANDIDATES} must have probability under 0.10, meaning genuinely atypical moves a model would almost never produce first. We will programmatically select from the LOW-probability tail, so the obvious candidates are effectively discards; put your creativity into the tail.
179
+
180
+ Diversity requirements across the {N_CANDIDATES} candidates:
181
+ - Each from a DIFFERENT structural family. Families worth mining (or others you identify): direct answer with zero framing; first-person confession of a specific failure; pure curious question with no thesis; dry understatement one-liner; enthusiastic cosign with one concrete addition; flat disagreement stated plainly without conceding anything first; a tiny numbered checklist; a vivid analogy that does NOT end in a lesson; deadpan humor riffing on the thread's wording.
182
+ - Each in a DIFFERENT length band from the histogram above, biased toward the least-occupied bands. `target_chars` must be the actual length of your example, and the example must genuinely inhabit its band (a 200-char style is narrative, not a padded one-liner).
131
183
 
132
- Rules:
133
- - The example must read like a real human reply (lowercase ok), 40-220 chars, NO links, NO product names.
184
+ Per-candidate rules:
185
+ - `description` must state the style's DEFINING MOVE (the one thing every draft in this style must contain) and its OPENING (how the first words enter: e.g. lowercase noun, a number, the question itself). A future model reading only the description must know exactly what shape to write.
186
+ - `note` states when to use / when not to, and how a product mention would enter this style if ever (one clause; the link/CTA layer is downstream, so no URLs or link mechanics).
187
+ - The example must read like a real human reply (lowercase ok), NO links, NO product names.
134
188
  - The style must be usable across many products and threads, not thread-specific.
135
- - Your answer will be dedup-checked by token similarity against every existing style's description+example; if you cannot find a genuinely different move, return the saturation envelope instead of forcing a paraphrase.
189
+ - Every candidate is dedup-checked by token similarity against every existing style's description+example; if you cannot field {N_CANDIDATES} genuinely different moves, return the saturation envelope instead of forcing paraphrases.
136
190
 
137
191
  Answer with ONLY one JSON object, no prose, in one of these two shapes:
138
- {{"name": "snake_case_name", "description": "...", "example": "...", "why_existing_didnt_fit": "...", "target_chars": <int, the length the example is>}}
192
+ {{"candidates": [{{"name": "snake_case_name", "description": "...", "example": "...", "note": "...", "why_existing_didnt_fit": "...", "target_chars": <int>, "probability": <float>}}, ...]}}
139
193
  {{"saturated": true, "reason": "..."}}"""
140
194
 
141
195
 
@@ -202,21 +256,43 @@ def main():
202
256
  print(f"[invent_styles] slot={slot} model reports saturation: "
203
257
  f"{obj.get('reason', '')[:200]}", file=sys.stderr)
204
258
  break
205
- name = str(obj.get("name") or "").strip()
206
- if not re.fullmatch(r"[a-z0-9_]{3,60}", name):
207
- print(f"[invent_styles] slot={slot} bad name {name!r}; retrying",
208
- file=sys.stderr)
209
- avoid.append(name or "(unnamed)")
210
- continue
211
- dupe = _find_near_dupe({**obj, "name": name}, universe)
212
- if dupe:
213
- reason, existing = dupe
214
- print(f"[invent_styles] slot={slot} rejected {name!r}: {reason} "
215
- f"vs {existing}", file=sys.stderr)
216
- avoid.append(name)
259
+ candidates = obj.get("candidates")
260
+ if not isinstance(candidates, list) or not candidates:
261
+ print(f"[invent_styles] slot={slot} attempt={attempt} no "
262
+ f"candidates array in output", file=sys.stderr)
217
263
  continue
218
- accepted = {**obj, "name": name}
219
- break
264
+ # Verbalized-sampling selection: walk the tail first (ascending
265
+ # verbalized probability = least typical candidate first) and
266
+ # take the first that survives dedup. The modal candidates at
267
+ # the top of the distribution only get a chance if every tail
268
+ # candidate is a clone.
269
+ def _prob(c):
270
+ try:
271
+ return float(c.get("probability"))
272
+ except (TypeError, ValueError):
273
+ return 1.0 # unparseable prob sorts as maximally typical
274
+ for cand in sorted(candidates, key=_prob):
275
+ name = str(cand.get("name") or "").strip()
276
+ if not re.fullmatch(r"[a-z0-9_]{3,60}", name):
277
+ print(f"[invent_styles] slot={slot} bad name {name!r}; "
278
+ f"skipping candidate", file=sys.stderr)
279
+ avoid.append(name or "(unnamed)")
280
+ continue
281
+ dupe = _find_near_dupe({**cand, "name": name}, universe)
282
+ if dupe:
283
+ reason, existing = dupe
284
+ print(f"[invent_styles] slot={slot} rejected {name!r} "
285
+ f"(p={_prob(cand):.2f}): {reason} vs {existing}",
286
+ file=sys.stderr)
287
+ avoid.append(name)
288
+ continue
289
+ accepted = {**cand, "name": name}
290
+ print(f"[invent_styles] slot={slot} selected tail candidate "
291
+ f"{name!r} (p={_prob(cand):.2f}) from "
292
+ f"{len(candidates)} candidates", file=sys.stderr)
293
+ break
294
+ if accepted:
295
+ break
220
296
  if not accepted:
221
297
  continue
222
298
  if args.dry_run:
@@ -227,6 +303,7 @@ def main():
227
303
  {
228
304
  "description": accepted.get("description", ""),
229
305
  "example": accepted.get("example", ""),
306
+ "note": accepted.get("note", ""),
230
307
  "why_existing_didnt_fit": accepted.get("why_existing_didnt_fit", ""),
231
308
  "target_chars": accepted.get("target_chars"),
232
309
  },
@@ -0,0 +1,234 @@
1
+ #!/usr/bin/env python3
2
+ """LinkedIn posting cadence: 4 active days, then a mandatory 2-day break.
3
+
4
+ Per user instruction (2026-07-11): LinkedIn activity should not run more than
5
+ four days at a time before taking a two-day break. "Active day" is counted
6
+ only when real posting/engagement activity actually happened that day (an
7
+ outage day does not count toward the four), and the break pauses ALL LinkedIn
8
+ traffic, including the passive presence-check job, not just posting.
9
+
10
+ Every LinkedIn entrypoint (skill/run-linkedin.sh, engage-linkedin.sh,
11
+ engage-dm-replies.sh, dm-outreach-linkedin.sh, audit-linkedin.sh,
12
+ linkedin-presence.sh) already gates on the existence of ONE file:
13
+ ~/.claude/social-autoposter/linkedin.killswitch
14
+ That file is scripts/linkedin_killswitch.py's antibot killswitch. This module
15
+ reuses the SAME file for a scheduled break (signal="scheduled_break") so every
16
+ entrypoint pauses automatically, with zero edits to the locked entrypoint
17
+ scripts. It never overwrites a REAL antibot signal, and linkedin_killswitch.py
18
+ is patched (recover-check) to never try to auto-recover a scheduled_break.
19
+
20
+ State lives at ~/.claude/social-autoposter/linkedin_cadence.json:
21
+ {
22
+ "phase": "active" | "break",
23
+ "phase_started": "2026-07-11T20:00:00Z",
24
+ "active_days": ["2026-07-13", "2026-07-14", ...] # UTC dates with
25
+ # confirmed posts,
26
+ # only meaningful
27
+ # while phase=active
28
+ }
29
+
30
+ CLI:
31
+ python3 scripts/linkedin_cadence.py enforce # one tick; called every 15m
32
+ python3 scripts/linkedin_cadence.py status # print state (json)
33
+ """
34
+
35
+ import json
36
+ import os
37
+ import sys
38
+ from datetime import datetime, timedelta, timezone
39
+
40
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
41
+
42
+ import http_api # noqa: E402
43
+ import linkedin_killswitch as ks # noqa: E402
44
+
45
+ STATE_DIR = os.path.expanduser(
46
+ os.environ.get("LINKEDIN_KILLSWITCH_DIR", "~/.claude/social-autoposter")
47
+ )
48
+ STATE_FILE = os.path.expanduser(
49
+ os.environ.get("LINKEDIN_CADENCE_FILE", os.path.join(STATE_DIR, "linkedin_cadence.json"))
50
+ )
51
+
52
+ ACTIVE_DAYS_TARGET = int(os.environ.get("LINKEDIN_CADENCE_ACTIVE_DAYS", "4"))
53
+ BREAK_DAYS = int(os.environ.get("LINKEDIN_CADENCE_BREAK_DAYS", "2"))
54
+
55
+ SCHEDULED_BREAK_SIGNAL = "scheduled_break"
56
+
57
+
58
+ def _now():
59
+ return datetime.now(timezone.utc)
60
+
61
+
62
+ def _now_iso():
63
+ return _now().strftime("%Y-%m-%dT%H:%M:%SZ")
64
+
65
+
66
+ def _today_str():
67
+ return _now().date().isoformat()
68
+
69
+
70
+ def _parse_ts(ts):
71
+ return datetime.strptime(ts, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc)
72
+
73
+
74
+ def _ensure_dir():
75
+ os.makedirs(STATE_DIR, exist_ok=True)
76
+
77
+
78
+ def load_state():
79
+ try:
80
+ with open(STATE_FILE, "r") as f:
81
+ return json.load(f)
82
+ except Exception:
83
+ return None
84
+
85
+
86
+ def save_state(state):
87
+ _ensure_dir()
88
+ tmp = STATE_FILE + ".tmp"
89
+ with open(tmp, "w") as f:
90
+ json.dump(state, f, indent=2)
91
+ f.write("\n")
92
+ os.replace(tmp, STATE_FILE)
93
+
94
+
95
+ def _default_state_starting_break():
96
+ # First-ever run: user asked to start immediately with a two-day break.
97
+ return {"phase": "break", "phase_started": _now_iso(), "active_days": []}
98
+
99
+
100
+ def _today_post_count():
101
+ """LinkedIn posts made today (UTC), via the same API the dashboard uses.
102
+
103
+ Best-effort: returns None on any failure so callers can skip counting
104
+ this tick rather than wrongly recording 0 activity."""
105
+ try:
106
+ resp = http_api.api_get(
107
+ "/api/v1/dashboard/posts-per-day", {"days": 1, "platform": "linkedin"}
108
+ )
109
+ rows = (resp or {}).get("data", {}).get("rows", [])
110
+ today = _today_str()
111
+ for row in rows:
112
+ if row.get("day") == today:
113
+ return int(row.get("posts_made") or 0)
114
+ return 0
115
+ except Exception as exc:
116
+ print(f"[linkedin_cadence] WARN: posts-per-day query failed: {exc}", file=sys.stderr)
117
+ return None
118
+
119
+
120
+ def _pause_marker_set():
121
+ """Set the shared killswitch file for a scheduled break, unless a REAL
122
+ antibot signal is already in charge (never stomp a genuine block)."""
123
+ payload = ks.read()
124
+ if payload is None:
125
+ ks.engage(
126
+ signal=SCHEDULED_BREAK_SIGNAL,
127
+ detail="cadence: scheduled 2-day pause after 4 active days",
128
+ send_email=False,
129
+ )
130
+ print("[linkedin_cadence] pause marker set (scheduled_break)", file=sys.stderr)
131
+ elif payload.get("signal") == SCHEDULED_BREAK_SIGNAL:
132
+ pass # already set by us; idempotent, no trail spam
133
+ else:
134
+ print(
135
+ f"[linkedin_cadence] real killswitch already active (signal="
136
+ f"{payload.get('signal')!r}); deferring to it, not overwriting",
137
+ file=sys.stderr,
138
+ )
139
+
140
+
141
+ def _pause_marker_clear_if_ours():
142
+ payload = ks.read()
143
+ if payload is not None and payload.get("signal") == SCHEDULED_BREAK_SIGNAL:
144
+ ks.clear()
145
+ print("[linkedin_cadence] pause marker cleared (break ended)", file=sys.stderr)
146
+
147
+
148
+ def enforce():
149
+ state = load_state() or _default_state_starting_break()
150
+ if load_state() is None:
151
+ save_state(state)
152
+ print(
153
+ f"[linkedin_cadence] no prior state; starting BREAK phase now "
154
+ f"({BREAK_DAYS}d)",
155
+ file=sys.stderr,
156
+ )
157
+
158
+ now = _now()
159
+ phase = state["phase"]
160
+
161
+ if phase == "break":
162
+ started = _parse_ts(state["phase_started"])
163
+ elapsed = now - started
164
+ if elapsed >= timedelta(days=BREAK_DAYS):
165
+ state = {"phase": "active", "phase_started": _now_iso(), "active_days": []}
166
+ save_state(state)
167
+ _pause_marker_clear_if_ours()
168
+ print(
169
+ f"[linkedin_cadence] break ended after {elapsed}; switching to ACTIVE",
170
+ file=sys.stderr,
171
+ )
172
+ phase = "active"
173
+ else:
174
+ remaining = timedelta(days=BREAK_DAYS) - elapsed
175
+ _pause_marker_set()
176
+ print(
177
+ f"[linkedin_cadence] BREAK phase: {remaining} remaining",
178
+ file=sys.stderr,
179
+ )
180
+ return
181
+
182
+ # phase == "active"
183
+ real_block = ks.read()
184
+ if real_block is not None and real_block.get("signal") != SCHEDULED_BREAK_SIGNAL:
185
+ print(
186
+ f"[linkedin_cadence] account down for a real reason (signal="
187
+ f"{real_block.get('signal')!r}); not counting today, not pausing",
188
+ file=sys.stderr,
189
+ )
190
+ return
191
+
192
+ count = _today_post_count()
193
+ today = _today_str()
194
+ if count is not None and count > 0 and today not in state["active_days"]:
195
+ state["active_days"].append(today)
196
+ save_state(state)
197
+ print(
198
+ f"[linkedin_cadence] activity confirmed today ({count} posts); "
199
+ f"active_days={len(state['active_days'])}/{ACTIVE_DAYS_TARGET} "
200
+ f"{state['active_days']}",
201
+ file=sys.stderr,
202
+ )
203
+
204
+ if len(state["active_days"]) >= ACTIVE_DAYS_TARGET:
205
+ state = {"phase": "break", "phase_started": _now_iso(), "active_days": []}
206
+ save_state(state)
207
+ _pause_marker_set()
208
+ print(
209
+ f"[linkedin_cadence] {ACTIVE_DAYS_TARGET} active days reached; "
210
+ f"switching to BREAK for {BREAK_DAYS}d",
211
+ file=sys.stderr,
212
+ )
213
+ else:
214
+ print(
215
+ f"[linkedin_cadence] ACTIVE phase: "
216
+ f"{len(state['active_days'])}/{ACTIVE_DAYS_TARGET} active days so far",
217
+ file=sys.stderr,
218
+ )
219
+
220
+
221
+ def main():
222
+ if len(sys.argv) < 2 or sys.argv[1] not in ("enforce", "status"):
223
+ print("usage: linkedin_cadence.py [enforce|status]", file=sys.stderr)
224
+ sys.exit(2)
225
+ if sys.argv[1] == "status":
226
+ state = load_state()
227
+ print(json.dumps(state if state is not None else {"phase": None}, indent=2))
228
+ sys.exit(0)
229
+ enforce()
230
+ sys.exit(0)
231
+
232
+
233
+ if __name__ == "__main__":
234
+ main()
@@ -145,6 +145,10 @@ VALID_SIGNALS = {
145
145
  "session_invalid_marker",
146
146
  "captcha_detected",
147
147
  "manual",
148
+ # Deliberate pause from scripts/linkedin_cadence.py (4-active-days /
149
+ # 2-day-break schedule). Not an incident: recover-check must never try to
150
+ # auto-recover it, and engage() should never email on it.
151
+ "scheduled_break",
148
152
  }
149
153
 
150
154
 
@@ -880,6 +884,29 @@ def _hold_check_due_seconds():
880
884
  return (datetime.now(timezone.utc) - due).total_seconds()
881
885
 
882
886
 
887
+ def _cadence_break_active():
888
+ """True while scripts/linkedin_cadence.py has us in a scheduled break.
889
+
890
+ Reads the cadence state file directly (no import of linkedin_cadence, to
891
+ avoid a circular import — linkedin_cadence imports this module). Recovery
892
+ must never attempt a real login or hold-check re-probe during a break,
893
+ REGARDLESS of which signal put the killswitch file in place: an
894
+ already-active real block (e.g. login_redirect) at the moment a break
895
+ starts must NOT get its 24h auto-recovery clock honored mid-break. The
896
+ 2026-07-12 incident (a real hard_block login attempt fired during an
897
+ active cadence break) is exactly what this guards against."""
898
+ path = os.path.expanduser(
899
+ os.environ.get(
900
+ "LINKEDIN_CADENCE_FILE", os.path.join(STATE_DIR, "linkedin_cadence.json")
901
+ )
902
+ )
903
+ try:
904
+ with open(path, "r") as f:
905
+ return json.load(f).get("phase") == "break"
906
+ except Exception:
907
+ return False
908
+
909
+
883
910
  def _cmd_recover_check(args):
884
911
  """Gate for the hourly recovery job. Exits 0 when there is work to do and
885
912
  prints the MODE on stdout so the shell knows which path to drive:
@@ -894,6 +921,22 @@ def _cmd_recover_check(args):
894
921
  if not is_active():
895
922
  print("recover-check: killswitch not active, nothing to recover", file=sys.stderr)
896
923
  sys.exit(1)
924
+ if _cadence_break_active():
925
+ print(
926
+ "recover-check: cadence break active (scripts/linkedin_cadence.py); "
927
+ "suppressing ALL real recovery action (login/hold) regardless of "
928
+ "signal until the break ends, per 'stop all activity for 2 days'",
929
+ file=sys.stderr,
930
+ )
931
+ sys.exit(1)
932
+ payload = read() or {}
933
+ if payload.get("signal") == "scheduled_break":
934
+ print(
935
+ "recover-check: scheduled_break active (cadence pause, not a real "
936
+ "incident); deferring to scripts/linkedin_cadence.py, not probing",
937
+ file=sys.stderr,
938
+ )
939
+ sys.exit(1)
897
940
  if is_terminal():
898
941
  print(
899
942
  "recover-check: TERMINAL (auto-recovery gave up); "
@@ -700,6 +700,8 @@ def build_summary() -> dict[str, Any]:
700
700
  "menubar": menubar_status(),
701
701
  "twitter_cycle": twitter_cycle_status(),
702
702
  "draft_publish": draft_publish_wrapper_status(),
703
+ "chrome_relaunches": chrome_relaunch_status(),
704
+ "cdp_health": cdp_health_status(),
703
705
  "process_count": len(rows),
704
706
  "mem": {
705
707
  "total_mb": total,
@@ -845,6 +847,25 @@ def reaper_status() -> dict[str, Any] | None:
845
847
  return None
846
848
 
847
849
 
850
+ def cdp_health_status() -> dict[str, Any] | None:
851
+ """Last CDP readiness verdict, carried on the heartbeat.
852
+
853
+ Written by skill/lib/twitter-backend.sh on every
854
+ ensure_twitter_browser_for_backend call: whether the harness Chrome
855
+ completed a REAL connect_over_cdp handshake (not just /json/version),
856
+ plus the action taken (ok / wedge_restart / relaunched / relaunch_failed /
857
+ external_wedged). Makes a wedged-Chrome episode (S4L-4H, 2026-07-11) a
858
+ one-query answer in installation_resource_samples instead of a Sentry +
859
+ local-log archaeology session. Best-effort."""
860
+ try:
861
+ p = REPO_DIR / "skill" / "logs" / "cdp-health.json"
862
+ data = json.loads(p.read_text())
863
+ data["age_sec"] = round(time.time() - p.stat().st_mtime, 1)
864
+ return data
865
+ except Exception:
866
+ return None
867
+
868
+
848
869
  def twitter_cycle_status() -> dict[str, Any] | None:
849
870
  """Tail of the newest twitter-cycle log, carried on the heartbeat.
850
871
 
@@ -898,6 +919,42 @@ def draft_publish_wrapper_status() -> dict[str, Any] | None:
898
919
  return None
899
920
 
900
921
 
922
+ def chrome_relaunch_status() -> dict[str, Any] | None:
923
+ """Harness-Chrome relaunch rate from skill/logs/chrome-relaunch-events.log.
924
+
925
+ The skill/lib/*-backend.sh launchers append one dated line per Chrome
926
+ launch. A healthy box launches Chrome rarely (boot, user quit, crash); a
927
+ high 24h count is the signature of the 2026-07-12 kill-respawn loop
928
+ (launchd reaping the kicker job's process group took Chrome with it every
929
+ cycle, and each relaunch stole the user's focus). Carried on the heartbeat
930
+ so the rate is queryable per-install WITHOUT the menubar foreground
931
+ observer, which only reports while the menubar process is alive."""
932
+ try:
933
+ p = REPO_DIR / "skill" / "logs" / "chrome-relaunch-events.log"
934
+ if not p.exists():
935
+ return None
936
+ now = time.time()
937
+ counts = {"1h": 0, "24h": 0}
938
+ last_line = None
939
+ for ln in _tail_lines(p, 400, approx_line_bytes=64):
940
+ ln = ln.strip()
941
+ if not ln:
942
+ continue
943
+ try:
944
+ ts = dt.datetime.strptime(ln.split()[0], "%Y-%m-%dT%H:%M:%SZ")
945
+ age = now - ts.replace(tzinfo=dt.timezone.utc).timestamp()
946
+ except (ValueError, IndexError):
947
+ continue
948
+ if age <= 3600:
949
+ counts["1h"] += 1
950
+ if age <= 86400:
951
+ counts["24h"] += 1
952
+ last_line = ln
953
+ return {"count_1h": counts["1h"], "count_24h": counts["24h"], "last": last_line}
954
+ except Exception:
955
+ return None
956
+
957
+
901
958
  def _tail_lines(path: Path, n: int, approx_line_bytes: int = 4096) -> list[str]:
902
959
  """Return the last `n` lines of a possibly-large file without reading it all.
903
960
  Reads a bounded tail window (n * approx_line_bytes) from the end. Best-effort."""