@m13v/s4l 1.7.1-rc.2 → 1.7.1-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 (45) hide show
  1. package/bin/cli.js +23 -0
  2. package/mcp/dist/index.js +332 -24
  3. package/mcp/dist/onboarding.js +3 -10
  4. package/mcp/dist/panel.html +42 -31
  5. package/mcp/dist/product-link.html +1 -1
  6. package/mcp/dist/runtime.js +101 -1
  7. package/mcp/dist/telemetry.js +80 -3
  8. package/mcp/dist/version.json +2 -2
  9. package/mcp/manifest.json +9 -1
  10. package/mcp/menubar/dashboard_server.py +12 -0
  11. package/mcp/menubar/s4l_card.py +472 -96
  12. package/mcp/menubar/s4l_menubar.py +269 -26
  13. package/mcp/menubar/s4l_state.py +169 -23
  14. package/mcp/package.json +1 -1
  15. package/mcp/shared/doctor.cjs +8 -0
  16. package/mcp/shared/onboarding-ledger.cjs +1 -0
  17. package/package.json +3 -1
  18. package/scripts/active_users.py +3 -6
  19. package/scripts/autopilot_stall_watch.py +200 -16
  20. package/scripts/claude_job.py +6 -0
  21. package/scripts/feedback_digest.py +34 -6
  22. package/scripts/get-latest-staging-mcpb.sh +50 -0
  23. package/scripts/harness_overlay.py +45 -0
  24. package/scripts/identity.py +26 -0
  25. package/scripts/learned_preferences.py +241 -32
  26. package/scripts/link_tail.py +167 -91
  27. package/scripts/mark_event.py +128 -0
  28. package/scripts/memory_snapshot.py +62 -0
  29. package/scripts/pick_twitter_thread_target.py +1 -1
  30. package/scripts/release-mcpb.sh +48 -0
  31. package/scripts/reset-test-machine.sh +85 -3
  32. package/scripts/s4l_mode.py +7 -5
  33. package/scripts/scheduled_tasks_snapshot.py +107 -0
  34. package/scripts/send_gmail_report.py +76 -0
  35. package/scripts/sentry_digest.py +81 -189
  36. package/scripts/sentry_init.py +43 -15
  37. package/scripts/setup_twitter_auth.py +76 -38
  38. package/scripts/snapshot.py +20 -0
  39. package/scripts/twitter_post_plan.py +39 -13
  40. package/skill/dm-outreach-twitter.sh +19 -0
  41. package/skill/engage-twitter.sh +27 -0
  42. package/skill/run-draft-and-publish.sh +13 -12
  43. package/skill/run-linkedin.sh +9 -1
  44. package/skill/run-twitter-cycle.sh +199 -27
  45. package/skill/sentry-digest.sh +125 -12
@@ -13,6 +13,15 @@ Haiku) that:
13
13
  4. Writes 1 short bridge sentence that names a concrete benefit and
14
14
  ends with the URL — no period after, no "click here".
15
15
 
16
+ APPEND-ONLY, deterministically (2026-07-07): the model is asked for the
17
+ bridge sentence ONLY, never the full reply. main() concatenates it onto the
18
+ UNMODIFIED reply_text in Python, so the model's output is never substituted
19
+ for the drafted reply, only appended after it — it cannot rewrite the
20
+ drafted body no matter what it returns. The bridge prompt also embeds the
21
+ matched project's learned_preferences (draft_style_notes / edit_examples,
22
+ same as the main drafting prompt) so human-review feedback reaches the
23
+ bridge sentence too, not just the initial draft.
24
+
16
25
  Why not Haiku: bridge writing requires reading two pieces of context (thread
17
26
  + our reply) and producing language that doesn't read as bolted-on. The
18
27
  cheap model fails this; tested via the existing studyly Twitter dataset
@@ -54,6 +63,9 @@ import sys
54
63
  import time
55
64
  from pathlib import Path
56
65
 
66
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
67
+ import learned_preferences # noqa: E402
68
+
57
69
  REPO_DIR = os.path.expanduser("~/social-autoposter")
58
70
  RUN_CLAUDE_SH = os.path.join(REPO_DIR, "scripts", "run_claude.sh")
59
71
  CONFIG_PATH = os.path.join(REPO_DIR, "config.json")
@@ -68,6 +80,16 @@ URL_WEIGHT = 23
68
80
  TWITTER_TEXT_BUDGET = TWEET_LIMIT - URL_WEIGHT # 257 chars for everything but the URL
69
81
  _URL_RE = re.compile(r"https?://\S+")
70
82
 
83
+ # The model is asked for the BRIDGE SENTENCE ONLY, never a rewrite of the
84
+ # already-drafted reply (see build_prompt / main). These bound that sentence:
85
+ # below MIN_BRIDGE_CHARS of budget there's no room to say anything meaningful
86
+ # before the URL, so main() skips the Claude call and falls straight to a
87
+ # mechanical concat; above MAX_BRIDGE_CHARS the model has ignored the
88
+ # "sentence only" contract and is attempting a full rewrite, so the quality
89
+ # gate rejects it as a structural violation, not a phrasing nitpick.
90
+ MIN_BRIDGE_CHARS = 20
91
+ MAX_BRIDGE_CHARS = 400
92
+
71
93
 
72
94
  def x_weighted_len(text: str) -> int:
73
95
  """Character count the way X computes it: every URL counts as 23."""
@@ -115,28 +137,42 @@ def enforce_budget(text: str, link_url: str,
115
137
  return _trim_to_chars(text, limit), True
116
138
 
117
139
 
118
- def resolve_voice_relationship(project: str) -> str:
119
- """Look up the matched project's `voice_relationship` field in config.json.
120
-
121
- Returns "first_party" or "third_party". Defaults to "first_party" if the
122
- project is missing or the field is absent, matching the historical
123
- pre-2026-05-27 behavior so we never silently mute first-party voice.
124
- The link_tail subprocess runs with --disallowed-tools that bans Read/Glob,
125
- so the value must be resolved here in Python rather than inside the prompt.
140
+ def _load_project_entry(project: str) -> dict:
141
+ """Load the matched project's raw config.json entry (case-insensitive
142
+ name match), or {} if config.json is unreadable or the project is
143
+ missing. Shared by voice_relationship + learned_preferences lookups so
144
+ both read config.json once via the same path. The link_tail subprocess
145
+ runs with --disallowed-tools that bans Read/Glob/MCP, so these values
146
+ must be resolved here in Python and handed to the prompt as plain text.
126
147
  """
127
148
  try:
128
149
  with open(CONFIG_PATH, "r") as f:
129
150
  cfg = json.load(f)
130
151
  except Exception:
131
- return "first_party"
152
+ return {}
132
153
  name_lc = (project or "").lower()
133
154
  for p in cfg.get("projects", []):
134
155
  if (p.get("name") or "").lower() == name_lc:
135
- val = (p.get("voice_relationship") or "").strip().lower()
136
- if val in ("first_party", "third_party"):
137
- return val
138
- return "first_party"
139
- return "first_party"
156
+ return p
157
+ return {}
158
+
159
+
160
+ def resolve_voice_relationship(project_entry: dict) -> str:
161
+ """Returns "first_party" or "third_party" from the project's
162
+ `voice_relationship` field. Defaults to "first_party" if the field is
163
+ absent, matching the historical pre-2026-05-27 behavior so we never
164
+ silently mute first-party voice."""
165
+ val = (project_entry.get("voice_relationship") or "").strip().lower()
166
+ return val if val in ("first_party", "third_party") else "first_party"
167
+
168
+
169
+ def resolve_learned_preferences_block(project_entry: dict) -> str:
170
+ """Render this project's learned_preferences (draft_style_notes,
171
+ edit_examples, audience/thread avoid-prefer) via the shared
172
+ learned_preferences.prompt_block() renderer — the same text the main
173
+ drafting prompt embeds. Empty string when the block is disabled or has
174
+ no entries yet."""
175
+ return learned_preferences.prompt_block(project_entry)
140
176
 
141
177
  # Paths to the Claude Code CLI in order of preference. run_claude.sh resolves
142
178
  # `claude` from PATH; we fall back to a direct nvm path if PATH lookup fails
@@ -169,17 +205,28 @@ def resolve_claude_cli() -> str:
169
205
 
170
206
  def build_prompt(*, reply_text: str, link_url: str, thread_text: str,
171
207
  project: str, platform: str,
172
- voice_relationship: str = "first_party") -> str:
173
- """Compose the one-shot prompt for the bridge sentence.
174
-
175
- Kept tight on purpose: the model gets only the four pieces of context it
176
- needs, plus a precise output contract. No tools, no MCP, no file access.
208
+ voice_relationship: str = "first_party",
209
+ learned_prefs_block: str = "",
210
+ bridge_budget: int | None = None) -> str:
211
+ """Compose the one-shot prompt for the bridge sentence ONLY.
212
+
213
+ The model is never asked to reproduce or rewrite the reply already
214
+ drafted; it's shown that text for context and told explicitly not to
215
+ repeat it. main() appends whatever comes back to the UNMODIFIED
216
+ reply_text in Python afterward — the model's output is never substituted
217
+ for the drafted reply, only concatenated after it, so it structurally
218
+ cannot rewrite the drafted body regardless of what it outputs.
177
219
 
178
220
  `voice_relationship` ("first_party" | "third_party") is resolved by the
179
221
  caller from config.json and selects the example sentences + voice rule
180
222
  embedded in the prompt. third_party projects (Agora, Runner, Podlog,
181
223
  studyly, NightOwl, PieLine as of 2026-05-27) MUST be referred to in
182
224
  third-person; first_party projects own the "we ship / we built" voice.
225
+
226
+ `learned_prefs_block` is the project's rendered learned_preferences
227
+ (learned_preferences.prompt_block()), the same distilled human-review
228
+ feedback the main drafting prompt treats as mandatory; empty when the
229
+ project has no notes yet.
183
230
  """
184
231
  if voice_relationship == "third_party":
185
232
  voice_rule = (
@@ -209,27 +256,35 @@ def build_prompt(*, reply_text: str, link_url: str, thread_text: str,
209
256
  f" - \"we ship the same recall-on-revisit pattern in {project}, scores against a 4-axis rubric, {link_url}\""
210
257
  )
211
258
 
212
- # X-specific hard length budget. Pass the agent the current body length AND
213
- # the budget so it self-fits by compressing the body, keeping the link at
214
- # the end. Other platforms (reddit/linkedin) have far larger ceilings, so we
215
- # add no tight cap there.
259
+ # X-specific hard length budget. The reply's body is FIXED (never resized
260
+ # by the model); only the bridge sentence's own budget matters, computed
261
+ # by the caller from how much room the already-drafted body leaves.
262
+ # Other platforms (reddit/linkedin) have far larger ceilings, so we add
263
+ # no tight cap there.
216
264
  length_rule = ""
217
- if platform == "twitter":
218
- body_len = len(reply_text or "")
265
+ if platform == "twitter" and bridge_budget is not None:
219
266
  length_rule = (
220
- f"HARD LENGTH LIMIT (X counts EVERY link as exactly 23 characters, "
221
- f"no matter how long it looks):\n"
222
- f"- The entire final reply must be \u2264 {TWEET_LIMIT} characters with "
223
- f"the URL counted as {URL_WEIGHT}. That means everything EXCEPT the "
224
- f"URL must total \u2264 {TWITTER_TEXT_BUDGET} characters.\n"
225
- f"- The drafted body is currently {body_len} characters. If body + "
226
- f"bridge would exceed {TWITTER_TEXT_BUDGET} chars of text, COMPRESS "
227
- f"the body to make room: tighten wording, drop the weakest clause, "
228
- f"but keep the single strongest claim and keep the URL at the very "
229
- f"end. Never drop or move the link.\n"
267
+ f"HARD LENGTH LIMIT for YOUR SENTENCE ONLY (X counts EVERY link as "
268
+ f"exactly {URL_WEIGHT} characters, no matter how long it looks; "
269
+ f"that's already reserved below):\n"
270
+ f"- Everything you write BEFORE the URL must be \u2264 {max(bridge_budget, 0)} "
271
+ f"characters. The reply above is fixed length; you are not resizing "
272
+ f"it, only adding a short sentence after it.\n"
230
273
  )
231
274
 
232
- return f"""You are writing the FINAL bridge sentence that folds a product link into a social media reply we already drafted. This is a one-shot task. Output ONLY the bridge sentence (no preamble, no explanation, no quotes).
275
+ learned_prefs_section = ""
276
+ if learned_prefs_block:
277
+ learned_prefs_section = (
278
+ learned_prefs_block.strip() + "\n"
279
+ "The draft_style_notes / edit_examples entries above are MANDATORY "
280
+ "for how you phrase your bridge sentence, same as they are for the "
281
+ "original draft; on conflict they override the example sentences "
282
+ "below. The audience/thread avoid-prefer entries are about "
283
+ "candidate judging, already applied earlier in the pipeline \u2014 not "
284
+ "your job here.\n"
285
+ )
286
+
287
+ return f"""You are writing ONLY the bridge sentence that gets APPENDED after a social media reply we already drafted (shown below). This is a one-shot task. Output ONLY the bridge sentence itself: no preamble, no explanation, no quotes, and do NOT repeat, paraphrase, or rewrite any part of the reply below \u2014 it is already final and will be appended before your output automatically, verbatim.
233
288
 
234
289
  PLATFORM: {platform}
235
290
  PROJECT: {project}
@@ -237,33 +292,32 @@ LANDING PAGE URL: {link_url}
237
292
 
238
293
  {voice_rule}
239
294
 
295
+ {learned_prefs_section}
296
+
240
297
  {length_rule}
241
298
 
242
299
  ORIGINAL THREAD WE ARE REPLYING TO:
243
300
  {thread_text}
244
301
 
245
- REPLY WE ALREADY DRAFTED (its last sentence is what your bridge will REPLACE / EXTEND):
302
+ REPLY ALREADY DRAFTED (fixed, DO NOT repeat or modify \u2014 your sentence is appended after this automatically):
246
303
  {reply_text}
247
304
 
248
305
  YOUR TASK:
249
- Rewrite the reply so the LAST sentence is a 1-sentence (≤ 22 words) bridge that:
250
- 1. References the SINGLE strongest specific claim, mechanism, or detail from the existing reply (e.g. "rephrasing on revisit", "a 4-axis rubric", "200ms p95", "automatic distractor scoring") — pick ONE concrete thing, not a category.
251
- 2. Names a CONCRETE PRODUCT MECHANISM that delivers it (verb + noun, inferred from the URL slug + project context). Do NOT say "a tool for this", "something that helps", "made this for it" — those are banned.
306
+ Write ONE bridge sentence (\u2264 22 words) that:
307
+ 1. References the SINGLE strongest specific claim, mechanism, or detail from the reply above (e.g. "rephrasing on revisit", "a 4-axis rubric", "200ms p95", "automatic distractor scoring") \u2014 pick ONE concrete thing, not a category.
308
+ 2. Names a CONCRETE PRODUCT MECHANISM that delivers it (verb + noun, inferred from the URL slug + project context). Do NOT say "a tool for this", "something that helps", "made this for it" \u2014 those are banned.
252
309
  3. Ends with the URL exactly as given. No period after. No "click here", "check it out", "give it a try".
253
- 4. Reads in the voice of the reply (lowercase if reply is lowercase, casual if reply is casual).
310
+ 4. Reads in the voice of the reply above (lowercase if reply is lowercase, casual if reply is casual).
254
311
  5. Obeys the VOICE RELATIONSHIP rule above. This rule overrides any default phrasing instinct.
255
-
256
- REPLACEMENT RULE:
257
- - If the reply has a clear empathy/advice body, KEEP that body verbatim and append the bridge as a new sentence (separated by a single space).
258
- - If the reply already trails off with weak filler, REPLACE just the trailing weak portion with the bridge.
312
+ 6. Obeys the LEARNED PREFERENCES above when present. This rule overrides the example sentences below on conflict.
259
313
 
260
314
  OUTPUT FORMAT (strict):
261
- Output the FULL FINAL REPLY TEXT (body + bridge sentence ending in URL) on a single line. Nothing else. No JSON, no markdown, no quotes.
315
+ Output ONLY your bridge sentence, ending in the URL, on a single line. Nothing else: no JSON, no markdown, no quotes, and absolutely none of the reply text shown above.
262
316
 
263
- Example bridge sentences (do NOT copy verbatim — these are FORM examples, voice-matched to this project):
317
+ Example bridge sentences (do NOT copy verbatim \u2014 these are FORM examples, voice-matched to this project):
264
318
  {example_block}
265
319
 
266
- Write the final reply now."""
320
+ Write the bridge sentence now."""
267
321
 
268
322
 
269
323
  def _unwrap_queue_envelope(text: str) -> str:
@@ -411,52 +465,51 @@ THIRD_PARTY_VOICE_VIOLATIONS = (
411
465
  )
412
466
 
413
467
 
414
- def passes_quality_gate(final_text: str, link_url: str,
468
+ def passes_quality_gate(bridge: str, link_url: str,
415
469
  voice_relationship: str = "first_party",
416
- limit: int | None = None
417
470
  ) -> tuple[bool, str]:
418
471
  """Return (passes, reason_if_not).
419
472
 
473
+ Validates the model-written BRIDGE SENTENCE ALONE. reply_text is trusted,
474
+ already-vetted content that this function never sees and never judges —
475
+ the append-only contract means reply_text can't fail this gate, only the
476
+ newly generated bridge can.
477
+
420
478
  Hard rules:
421
479
  - must contain link_url
422
480
  - must end with link_url (allow trailing whitespace, nothing else)
423
481
  - must NOT contain banned phrases
424
- - must not be shorter than reply text would have been (silly model fail)
482
+ - must not be wildly longer than a sentence (MAX_BRIDGE_CHARS): the
483
+ model ignored the "bridge sentence only" contract and is attempting
484
+ a full rewrite — a structural violation, not a phrasing nitpick
425
485
  - on third_party projects, must NOT use first-person-plural product
426
486
  ownership phrases ("we ship", "we built", "our product", ...). The
427
- link_tail prompt now selects voice-matched examples but the model can
487
+ link_tail prompt selects voice-matched examples but the model can
428
488
  still drift; on violation we fall back to the mechanical concat so
429
489
  the post still ships without impersonating the client (root cause of
430
490
  the 2026-05-27 Agora OODAO incident).
431
491
  """
432
- if not final_text:
492
+ if not bridge:
433
493
  return (False, "empty")
434
- if link_url not in final_text:
494
+ if link_url not in bridge:
435
495
  return (False, "no_url")
436
496
  # Trailing-URL check (nothing meaningful after URL, optional ./! is fine
437
497
  # to strip; but our prompt forbids trailing period — so just check no
438
498
  # alphanumeric content follows).
439
- tail = final_text.split(link_url, 1)[1].strip()
499
+ tail = bridge.split(link_url, 1)[1].strip()
440
500
  if tail and re.search(r"[A-Za-z0-9]", tail):
441
501
  return (False, f"content_after_url: {tail[:40]!r}")
442
- lower = final_text.lower()
502
+ lower = bridge.lower()
443
503
  for phrase in BANNED_PHRASES:
444
504
  if phrase in lower:
445
505
  return (False, f"banned_phrase: {phrase!r}")
446
506
  if voice_relationship == "third_party":
447
507
  for rx in THIRD_PARTY_VOICE_VIOLATIONS:
448
- m = rx.search(final_text)
508
+ m = rx.search(bridge)
449
509
  if m:
450
510
  return (False, f"third_party_voice_violation: {m.group(0)!r}")
451
- # Length sanity: model returning a 5-word stub is a fail.
452
- if len(final_text.split()) < 8:
453
- return (False, "too_short")
454
- # Upper-length backstop (twitter): X-weighted length must fit the cap. The
455
- # caller trims the body to fit BEFORE the gate, so this should only trip on
456
- # a degenerate trim; on trip we fall back to the (also budget-enforced)
457
- # mechanical concat.
458
- if limit is not None and x_weighted_len(final_text) > limit:
459
- return (False, f"too_long:{x_weighted_len(final_text)}>{limit}")
511
+ if len(bridge) > MAX_BRIDGE_CHARS:
512
+ return (False, f"bridge_too_long:{len(bridge)}>{MAX_BRIDGE_CHARS}")
460
513
  return (True, "")
461
514
 
462
515
 
@@ -530,14 +583,43 @@ def main() -> int:
530
583
  print(json.dumps(out), flush=True)
531
584
  return 0
532
585
 
533
- voice_relationship = args.voice_relationship or resolve_voice_relationship(args.project)
586
+ project_entry = _load_project_entry(args.project)
587
+ voice_relationship = args.voice_relationship or resolve_voice_relationship(project_entry)
588
+ learned_prefs_block = resolve_learned_preferences_block(project_entry)
534
589
  # Length cap is X-specific; reddit/linkedin pass None (no tail trim).
535
590
  limit = TWEET_LIMIT if args.platform == "twitter" else None
591
+
592
+ # Deterministic no-room guard: if the already-drafted reply alone leaves
593
+ # no meaningful space for a bridge sentence before the (flat-weighted)
594
+ # URL, skip the Claude call outright — a squeezed-in fragment is worse
595
+ # than no bridge — and fall back to the mechanical concat like any other
596
+ # failure path.
597
+ bridge_budget = None
598
+ if limit is not None:
599
+ bridge_budget = limit - URL_WEIGHT - 1 - x_weighted_len(reply_text)
600
+ if bridge_budget < MIN_BRIDGE_CHARS:
601
+ fb_text, fb_trim = enforce_budget(
602
+ mechanical_fallback(reply_text, link_url), link_url, limit)
603
+ out = {
604
+ "ok": True,
605
+ "text": fb_text,
606
+ "tail": link_url,
607
+ "model_call_ok": False,
608
+ "fallback_used": True,
609
+ "budget_trimmed": fb_trim,
610
+ "error": f"no_room_for_bridge:{bridge_budget}",
611
+ "elapsed_sec": 0.0,
612
+ }
613
+ print(json.dumps(out), flush=True)
614
+ return 0
615
+
536
616
  prompt = build_prompt(
537
617
  reply_text=reply_text, link_url=link_url,
538
618
  thread_text=(args.thread_text or "").strip()[:2000],
539
619
  project=args.project, platform=args.platform,
540
620
  voice_relationship=voice_relationship,
621
+ learned_prefs_block=learned_prefs_block,
622
+ bridge_budget=bridge_budget,
541
623
  )
542
624
 
543
625
  started = time.time()
@@ -562,16 +644,9 @@ def main() -> int:
562
644
  print(json.dumps(out), flush=True)
563
645
  return 0
564
646
 
565
- cleaned = clean_output(raw)
566
- # Trim the body to fit BEFORE the gate so a good model bridge is preserved
567
- # (we trim the body, never the link) instead of being thrown away for being
568
- # a few chars over. No-op on non-twitter (limit is None).
569
- budget_trimmed = False
570
- if limit is not None:
571
- cleaned, budget_trimmed = enforce_budget(cleaned, link_url, limit)
572
- passes, reason = passes_quality_gate(cleaned, link_url,
573
- voice_relationship=voice_relationship,
574
- limit=limit)
647
+ bridge = clean_output(raw)
648
+ passes, reason = passes_quality_gate(bridge, link_url,
649
+ voice_relationship=voice_relationship)
575
650
  if not passes:
576
651
  fb_text, fb_trim = enforce_budget(
577
652
  mechanical_fallback(reply_text, link_url), link_url,
@@ -590,23 +665,24 @@ def main() -> int:
590
665
  print(json.dumps(out), flush=True)
591
666
  return 0
592
667
 
593
- # Successful path: extract the bridge tail (everything after the original
594
- # reply body's prefix, OR the last sentence containing the URL).
595
- tail = ""
596
- # Heuristic: the bridge is the last sentence in cleaned. Split on ". " or
597
- # "! " or "? "; take the last chunk.
598
- chunks = re.split(r"(?<=[.!?])\s+", cleaned)
599
- for c in reversed(chunks):
600
- if link_url in c:
601
- tail = c.strip()
602
- break
603
- if not tail:
604
- tail = cleaned
668
+ # Successful path: append the model-written bridge to the UNMODIFIED
669
+ # reply_text. This is the deterministic append-only guarantee — the
670
+ # model's output was validated above as a standalone sentence and is
671
+ # never substituted for reply_text, only concatenated after it.
672
+ final_text = f"{reply_text.rstrip()} {bridge}".strip()
673
+ budget_trimmed = False
674
+ if limit is not None:
675
+ # Belt-and-suspenders: should be a no-op given the bridge_budget
676
+ # precheck above, unless the model ignored the length rule. Trims
677
+ # from the END of the pre-URL text (the model's own bridge, appended
678
+ # last), so a well-behaved reply_text is only touched in the
679
+ # degenerate case where reply_text alone already exceeds the limit.
680
+ final_text, budget_trimmed = enforce_budget(final_text, link_url, limit)
605
681
 
606
682
  out = {
607
683
  "ok": True,
608
- "text": cleaned,
609
- "tail": tail,
684
+ "text": final_text,
685
+ "tail": bridge,
610
686
  "model_call_ok": True,
611
687
  "fallback_used": False,
612
688
  "budget_trimmed": budget_trimmed,
@@ -0,0 +1,128 @@
1
+ #!/usr/bin/env python3
2
+ """Bracket a HOST-NATIVE tool call (create_scheduled_task, list_scheduled_tasks,
3
+ update_scheduled_task) with an explicit start/end marker.
4
+
5
+ WHY THIS EXISTS: those tools run entirely inside the Claude Desktop host
6
+ process, invisible to S4L's own tool-call telemetry (which only sees our own
7
+ MCP tools, e.g. queue_setup). During the Karol 2026-07-07 update-orphan
8
+ incident, create_scheduled_task and list_scheduled_tasks each silently hung
9
+ for 35-56 minutes with zero signal anywhere except manually reading gaps
10
+ between transcript message timestamps after the fact. This script is meant to
11
+ be run by the AGENT (via Bash), bracketing exactly those calls, per the
12
+ instructions baked into REARM_PROMPT / DIAGNOSE_PROMPT_TEMPLATE
13
+ (mcp/menubar/s4l_menubar.py) and the dashboard panel's copy (mcp/panel/panel.ts).
14
+
15
+ Usage:
16
+ mark_event.py start <tool_name> # call immediately BEFORE the host tool call
17
+ mark_event.py end <tool_name> # call immediately AFTER it returns
18
+
19
+ State: one small JSON file per tool_name under
20
+ ~/.social-autoposter-mcp/host-tool-calls/<tool_name>.json. 'start' writes it;
21
+ 'end' reads + removes it and computes the elapsed duration. A mismatched or
22
+ missing start on 'end' still reports (duration unknown) rather than silently
23
+ no-op'ing, so a broken bracket is itself visible in the log line.
24
+
25
+ Best-effort end to end: never raises, never blocks the caller, exits 0 always
26
+ (a failure here must not stop the actual scheduled-task registration).
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import json
32
+ import os
33
+ import sys
34
+ import time
35
+
36
+ # Only page/record when the call was slow enough to matter — routine
37
+ # sub-second calls shouldn't generate a Sentry event on every re-arm. 120s
38
+ # matches the "consecutive check" cadence used elsewhere in this codebase
39
+ # (e.g. autopilot_stall_watch.py's launchd interval).
40
+ SLOW_THRESHOLD_S = 120.0
41
+
42
+
43
+ def _state_dir() -> str:
44
+ return os.path.join(os.path.expanduser("~"), ".social-autoposter-mcp", "host-tool-calls")
45
+
46
+
47
+ def _state_path(tool_name: str) -> str:
48
+ safe = "".join(c if (c.isalnum() or c in "-_") else "_" for c in tool_name)
49
+ return os.path.join(_state_dir(), f"{safe}.json")
50
+
51
+
52
+ def _report(message: str, level: str, tags: dict, extra: dict | None = None) -> None:
53
+ try:
54
+ repo = os.environ.get("S4L_REPO_DIR")
55
+ if repo:
56
+ scripts_dir = os.path.join(repo, "scripts")
57
+ if scripts_dir not in sys.path:
58
+ sys.path.insert(0, scripts_dir)
59
+ else:
60
+ # No S4L_REPO_DIR in this chat turn's env — fall back to this
61
+ # script's own directory, which IS scripts/ (sentry_init lives
62
+ # right next to it).
63
+ here = os.path.dirname(os.path.abspath(__file__))
64
+ if here not in sys.path:
65
+ sys.path.insert(0, here)
66
+ import sentry_init
67
+
68
+ sentry_init.init()
69
+ sentry_init.capture_message(message, level=level, tags=tags, extra=extra)
70
+ sentry_init.flush()
71
+ except Exception:
72
+ pass
73
+
74
+
75
+ def cmd_start(tool_name: str) -> int:
76
+ try:
77
+ os.makedirs(_state_dir(), exist_ok=True)
78
+ tmp = f"{_state_path(tool_name)}.tmp.{os.getpid()}"
79
+ with open(tmp, "w") as f:
80
+ json.dump({"tool_name": tool_name, "started_at": time.time()}, f)
81
+ os.replace(tmp, _state_path(tool_name))
82
+ except Exception as e:
83
+ sys.stderr.write(f"[mark-event] start write failed for {tool_name}: {e}\n")
84
+ sys.stderr.write(f"[mark-event] host tool call started: {tool_name}\n")
85
+ return 0
86
+
87
+
88
+ def cmd_end(tool_name: str) -> int:
89
+ started_at = None
90
+ try:
91
+ with open(_state_path(tool_name)) as f:
92
+ started_at = (json.load(f) or {}).get("started_at")
93
+ except Exception:
94
+ pass
95
+ try:
96
+ os.remove(_state_path(tool_name))
97
+ except Exception:
98
+ pass
99
+
100
+ duration = (time.time() - started_at) if started_at else None
101
+ if duration is not None:
102
+ sys.stderr.write(f"[mark-event] host tool call finished: {tool_name} took {duration:.1f}s\n")
103
+ if duration > SLOW_THRESHOLD_S:
104
+ _report(
105
+ f"S4L host tool call was slow: {tool_name} took {duration:.1f}s",
106
+ level="warning",
107
+ tags={"component": "host_tool_timing", "tool_name": tool_name},
108
+ extra={"duration_seconds": round(duration, 1)},
109
+ )
110
+ else:
111
+ sys.stderr.write(f"[mark-event] host tool call finished: {tool_name} (no matching start)\n")
112
+ return 0
113
+
114
+
115
+ def main() -> int:
116
+ if len(sys.argv) != 3 or sys.argv[1] not in ("start", "end"):
117
+ sys.stderr.write("usage: mark_event.py start|end <tool_name>\n")
118
+ return 0 # never a hard failure for the calling agent
119
+ action, tool_name = sys.argv[1], sys.argv[2]
120
+ return cmd_start(tool_name) if action == "start" else cmd_end(tool_name)
121
+
122
+
123
+ if __name__ == "__main__":
124
+ try:
125
+ sys.exit(main())
126
+ except Exception as e:
127
+ sys.stderr.write(f"[mark-event] unexpected error: {e}\n")
128
+ sys.exit(0)
@@ -604,6 +604,7 @@ def build_snapshot(top_n: int) -> dict[str, Any]:
604
604
  "repo_dir": str(REPO_DIR),
605
605
  "claude_desktop_version": claude_desktop_version(),
606
606
  "reaper": reaper_status(),
607
+ "menubar": menubar_status(),
607
608
  "memory": parse_vm_stat(),
608
609
  "process_count": len(rows),
609
610
  "top_rss": [
@@ -696,7 +697,9 @@ def build_summary() -> dict[str, Any]:
696
697
  "app_version": _app_version(),
697
698
  "claude_desktop_version": claude_desktop_version(),
698
699
  "reaper": reaper_status(),
700
+ "menubar": menubar_status(),
699
701
  "twitter_cycle": twitter_cycle_status(),
702
+ "draft_publish": draft_publish_wrapper_status(),
700
703
  "process_count": len(rows),
701
704
  "mem": {
702
705
  "total_mb": total,
@@ -765,6 +768,39 @@ def claude_desktop_version() -> str | None:
765
768
  return None
766
769
 
767
770
 
771
+ def menubar_status() -> dict[str, Any] | None:
772
+ """Last status-item visibility probe written by s4l_menubar._icon_watch_tick(),
773
+ or None. A live menubar PROCESS says nothing about whether macOS is drawing
774
+ its icon (2026-07-07: a customer's menubar ran ~25 min icon-less after a
775
+ reboot and support had no remote signal). Carrying this on the heartbeat
776
+ makes "process up, icon hidden" diagnosable centrally. age_sec doubles as a
777
+ process-liveness hint: the menubar rewrites the file every ~60s, so a stale
778
+ file means the menubar itself is down."""
779
+ path = (
780
+ Path(os.environ.get("S4L_STATE_DIR", str(Path.home() / ".social-autoposter-mcp")))
781
+ / "menubar-state.json"
782
+ )
783
+ try:
784
+ if not path.exists():
785
+ return None
786
+ ds = json.loads(path.read_text())
787
+ age = None
788
+ try:
789
+ age = round(time.time() - path.stat().st_mtime, 1)
790
+ except OSError:
791
+ pass
792
+ return {
793
+ "age_sec": age, # >150s means the menubar process itself is likely dead
794
+ "pid": ds.get("pid"),
795
+ "uptime_sec": ds.get("uptime_sec"),
796
+ "statusitem_on_screen": ds.get("statusitem_on_screen"),
797
+ "statusitem_visible_pref": ds.get("statusitem_visible_pref"),
798
+ "hidden_for_sec": ds.get("hidden_for_sec"),
799
+ }
800
+ except Exception:
801
+ return None
802
+
803
+
768
804
  def reaper_status() -> dict[str, Any] | None:
769
805
  """Last cycle written by reap_stale_claude_sessions.py::write_status(), or None.
770
806
 
@@ -836,6 +872,32 @@ def twitter_cycle_status() -> dict[str, Any] | None:
836
872
  return None
837
873
 
838
874
 
875
+ def draft_publish_wrapper_status() -> dict[str, Any] | None:
876
+ """Tail of run-draft-and-publish.sh's launchd stderr log.
877
+
878
+ run-twitter-cycle.sh's OWN log (see twitter_cycle_status above) only shows
879
+ that a cycle drafted; it says nothing about whether those drafts actually
880
+ became review cards. That decision happens one level up, in the
881
+ [run-draft-and-publish]/[merge_review_queue] lines this wrapper prints to
882
+ its own stderr (draft-only on/off, posting-defer skips, merge success/
883
+ failure/count). Those lines land in a real file via the launchd plist's
884
+ StandardErrorPath, but nothing centrally tailed them, which blinded the
885
+ 2026-07-08 Karol investigation to whether merges were succeeding even
886
+ though the inner cycle log looked healthy. Best-effort."""
887
+ try:
888
+ p = REPO_DIR / "skill" / "logs" / "launchd-twitter-cycle-stderr.log"
889
+ if not p.exists():
890
+ return None
891
+ lines = [ln.strip() for ln in _tail_lines(p, 16) if ln.strip()]
892
+ return {
893
+ "log": p.name,
894
+ "age_sec": round(time.time() - p.stat().st_mtime, 1),
895
+ "last_lines": [ln[:200] for ln in lines[-8:]],
896
+ }
897
+ except Exception:
898
+ return None
899
+
900
+
839
901
  def _tail_lines(path: Path, n: int, approx_line_bytes: int = 4096) -> list[str]:
840
902
  """Return the last `n` lines of a possibly-large file without reading it all.
841
903
  Reads a bounded tail window (n * approx_line_bytes) from the end. Best-effort."""
@@ -154,7 +154,7 @@ def main():
154
154
  sys.exit(3)
155
155
 
156
156
  project_recents = recent_angles_by_project(ctx.get("project_angles"))
157
- candidates = build_candidates(config, project_recents)
157
+ candidates, _ = build_candidates(config, project_recents)
158
158
  recent_project_counts = ctx.get("recent_posts_by_project") or {}
159
159
 
160
160
  if args.show_all: