@m13v/s4l 1.7.1-rc.21 → 1.7.1-rc.23

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.
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "1.7.1-rc.21",
3
- "installedAt": "2026-07-08T19:14:19.569Z"
2
+ "version": "1.7.1-rc.23",
3
+ "installedAt": "2026-07-08T20:24:54.769Z"
4
4
  }
package/mcp/manifest.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "dxt_version": "0.1",
3
3
  "name": "social-autoposter",
4
4
  "display_name": "S4L",
5
- "version": "1.7.1-rc.21",
5
+ "version": "1.7.1-rc.23",
6
6
  "description": "Draft, review, approve, and autopilot X/Twitter posts.",
7
7
  "long_description": "## **⚠️ The disclaimer above is generic Claude boilerplate.** Anthropic shows the same warning on every plugin regardless of what it does; any plugin has the same level of access as any app you download from the internet.\n\nS4L is an open source product developed by Mediar.ai Incorporated, a VC-backed San Francisco-based startup.\n\nTo get started:\n\n1\\. Copy this prompt: **Set me up on S4L plugin end to end**\n\n2\\. Quit with CMD+Q, reopen Claude, paste into a new chat.\n\nWhat happens next:\n\n* About every 5 minutes S4L scans X for posts that match your topics and drafts replies in your voice.\n* Drafts show up as review cards, usually the first within a few minutes. Nothing is posted automatically; you approve each one.\n* Posting autopilot stays off until you explicitly turn it on.",
8
8
  "author": {
@@ -102,6 +102,7 @@ from AppKit import (
102
102
  NSEventModifierFlagShift,
103
103
  NSEventModifierFlagDeviceIndependentFlagsMask,
104
104
  NSViewWidthSizable,
105
+ NSColorSpace,
105
106
  )
106
107
 
107
108
  # Styling extras that may be missing on older AppKit; every consumer degrades
@@ -499,6 +500,22 @@ def _fill_color():
499
500
  return NSColor.labelColor().colorWithAlphaComponent_(0.06)
500
501
 
501
502
 
503
+ def _solid(color):
504
+ """Bake a dynamic/semantic NSColor (textBackgroundColor, controlAccentColor,
505
+ etc.) down to concrete sRGB components. The card sits inside an
506
+ NSVisualEffectView (see _frosted); dynamic system colors drawn in that
507
+ vibrant context render partially see-through against whatever is behind
508
+ the window instead of the flat opaque/solid color they look like in a
509
+ normal window (2026-07-08 feedback: the selection ring and its "opaque"
510
+ backing both still blended into a dark desktop behind the card). Once
511
+ converted to a plain sRGB color it is no longer a vibrancy-aware dynamic
512
+ color, so CALayer draws it as flat, fully opaque pixels."""
513
+ try:
514
+ return color.colorUsingColorSpace_(NSColorSpace.sRGBColorSpace()) or color
515
+ except Exception:
516
+ return color
517
+
518
+
502
519
  def _round_rect(view, *, border=True):
503
520
  """Rounded-rect skin: 8px corners, optional hairline border. Returns True
504
521
  on success so callers can restore their square-bezel fallback when the
@@ -619,7 +636,7 @@ class _ReviewController(NSObject):
619
636
  # Two-draft cards (2026-07-08 redesign, no-recommendation pass same
620
637
  # day): both drafts render at once as separate editable boxes;
621
638
  # `_selected_draft` (0=a, 1=b) is "whichever box the reviewer is
622
- # currently in", driven by focus (see textDidBeginEditing_ below),
639
+ # currently in", driven by caret movement (see textViewDidChangeSelection_ below),
623
640
  # not a button. None = not yet chosen this card, _render() defaults
624
641
  # it to slot 0 (Draft A) — the model never picks a favorite, so
625
642
  # there's no recommendation to default to instead. Reset to None on
@@ -1076,9 +1093,9 @@ class _ReviewController(NSObject):
1076
1093
  # day): show BOTH drafts at once, stacked, each independently
1077
1094
  # editable, rather than a toggle that swaps one field's content. No
1078
1095
  # buttons: selection is "whichever box the reviewer is currently in",
1079
- # shown via that box's own border (an accent outline vs a plain
1080
- # hairline, mirroring a standard focus ring) and updated live by
1081
- # textDidBeginEditing_ below. The model never picks a favorite
1096
+ # shown via a dedicated outline view wrapping that box (an accent
1097
+ # outline vs a plain hairline, mirroring a standard focus ring) and
1098
+ # updated live by textViewDidChangeSelection_ below. The model never picks a favorite
1082
1099
  # (removed 2026-07-08 per user: no ask-the-model-to-recommend), so
1083
1100
  # Draft A (slot 0) is simply the fixed default until the reviewer
1084
1101
  # clicks into Draft B. Absent/short (reused stale draft, legacy plan)
@@ -1089,7 +1106,7 @@ class _ReviewController(NSObject):
1089
1106
  if dual:
1090
1107
  self._selected_draft = sel_idx
1091
1108
 
1092
- edit_top = H - 172 - 6
1109
+ edit_top = H - 172 - 3
1093
1110
  link = d.get("link_url")
1094
1111
 
1095
1112
  # Tail link baked at draft time is normally already in each draft's
@@ -1102,10 +1119,15 @@ class _ReviewController(NSObject):
1102
1119
 
1103
1120
  self._draft_textviews = {}
1104
1121
  self._draft_scrolls = {}
1122
+ self._draft_outlines = {}
1105
1123
  if dual:
1106
1124
  avail_h = edit_top - M
1107
1125
  gap = 5
1108
- label_h = 15
1126
+ # Reserved only for foreign-language cards, which show a muted
1127
+ # "EN:" line above each box; English drafts (the common case)
1128
+ # have nothing to put there, so reserving it unconditionally used
1129
+ # to leave a dead 15px gap above every box for no reason.
1130
+ label_h = 15 if is_foreign else 0
1109
1131
  unit = (avail_h - gap) / 2.0
1110
1132
  box_h = unit - label_h
1111
1133
  for slot in (0, 1):
@@ -1126,14 +1148,42 @@ class _ReviewController(NSObject):
1126
1148
  truncates=True,
1127
1149
  )
1128
1150
  )
1151
+ # The selection ring can't be drawn on the scroll view's own
1152
+ # layer: NSScrollView's opaque clip/document view fully paints
1153
+ # over its parent layer's border, so setBorderWidth/Color on
1154
+ # scroll.layer() is accepted (no error) but never visibly
1155
+ # renders, at any width or color (verified empirically). A
1156
+ # separate outline view, sized to the box and holding the
1157
+ # scroll view inset a few px inside it, keeps the ring
1158
+ # unobstructed since nothing opaque reaches its edge.
1159
+ # The inset gap between this wrapper's edge (where the ring is
1160
+ # drawn) and the scroll view's own background otherwise sits
1161
+ # directly on the translucent frosted panel behind it, so a
1162
+ # plain hairline there read as barely visible (2026-07-08
1163
+ # feedback: "outline not obvious, background is mostly
1164
+ # transparent"). _update_draft_borders (called once the
1165
+ # content view is installed, below) backs the whole wrapper a
1166
+ # solid color so box + ring read as one opaque card; no need
1167
+ # to pre-seed it here since that call always follows.
1168
+ outline_frame = NSMakeRect(M, box_y, W - 2 * M, box_h)
1169
+ outline = NSView.alloc().initWithFrame_(outline_frame)
1170
+ _round_rect(outline)
1171
+ inset = 4
1129
1172
  scroll, tv = _editable_scroll(
1130
- NSMakeRect(M, box_y, W - 2 * M, box_h), _compose(draft.get("text"))
1173
+ NSMakeRect(
1174
+ inset,
1175
+ inset,
1176
+ outline_frame.size.width - 2 * inset,
1177
+ outline_frame.size.height - 2 * inset,
1178
+ ),
1179
+ _compose(draft.get("text")),
1131
1180
  )
1132
1181
  tv.setDelegate_(self)
1133
- content.addSubview_(scroll)
1182
+ outline.addSubview_(scroll)
1183
+ content.addSubview_(outline)
1134
1184
  self._draft_scrolls[slot] = scroll
1185
+ self._draft_outlines[slot] = outline
1135
1186
  self._draft_textviews[slot] = tv
1136
- self._update_draft_borders()
1137
1187
  tv = self._draft_textviews[sel_idx]
1138
1188
  else:
1139
1189
  reply = d.get("reply_text") or ""
@@ -1161,6 +1211,13 @@ class _ReviewController(NSObject):
1161
1211
  self._textview = tv
1162
1212
 
1163
1213
  self._panel.setContentView_(_frosted(content))
1214
+ if dual:
1215
+ # Layer border properties set before a view is attached to its
1216
+ # eventual window get silently dropped when AppKit backs the view
1217
+ # for real on attach, so the accent outline must be (re)applied
1218
+ # only after setContentView_ installs the view tree, not during
1219
+ # construction above.
1220
+ self._update_draft_borders()
1164
1221
  # Counter lives in the native title bar, not inside the content, with
1165
1222
  # the product name so a stray card is identifiable at a glance.
1166
1223
  self._panel.setTitle_(
@@ -1342,14 +1399,18 @@ class _ReviewController(NSObject):
1342
1399
  pass
1343
1400
  return True
1344
1401
 
1345
- # NSTextView/NSText delegate: fires when a text view becomes the one
1346
- # being edited, i.e. right when it gains focus (click or tab), before any
1347
- # keystroke. Two-draft cards use this as the ONLY selection mechanism:
1348
- # whichever draft box the reviewer is in IS the selected one. Only the
1349
- # two draft boxes set self as delegate for this notification (the
1350
- # read-only thread quote never fires it), so no candidate_id lookup is
1351
- # needed, just a slot match against self._draft_textviews.
1352
- def textDidBeginEditing_(self, notification):
1402
+ # NSTextView delegate: fires the instant the caret/selection moves inside
1403
+ # a text view, which includes a plain click-to-place-cursor with no
1404
+ # keystroke (unlike NSTextDidBeginEditingNotification/textDidBeginEditing_,
1405
+ # which only fires once an actual edit starts and does NOT fire from
1406
+ # clicking into a box to merely place the cursor, verified empirically —
1407
+ # that gap was the "clicking the other draft doesn't select it" bug).
1408
+ # Two-draft cards use this as the ONLY selection mechanism: whichever
1409
+ # draft box the reviewer is in IS the selected one. Only the two draft
1410
+ # boxes set self as delegate for this notification (the read-only thread
1411
+ # quote never fires it), so no candidate_id lookup is needed, just a slot
1412
+ # match against self._draft_textviews.
1413
+ def textViewDidChangeSelection_(self, notification):
1353
1414
  try:
1354
1415
  tv = notification.object()
1355
1416
  except Exception:
@@ -1363,23 +1424,42 @@ class _ReviewController(NSObject):
1363
1424
 
1364
1425
  @objc.python_method
1365
1426
  def _update_draft_borders(self):
1366
- """Redraw the two draft boxes' borders in place (no re-render, so an
1367
- in-progress edit/caret in either box is never disturbed): the
1368
- selected box gets a thicker system-accent outline (the platform's own
1369
- selection color, respects the user's macOS accent choice and
1370
- light/dark mode), the other a plain hairline, matching the existing
1371
- thread-quote/editable-field border look."""
1372
- for slot, scroll in (self._draft_scrolls or {}).items():
1427
+ """Redraw the two draft boxes' selection ring in place (no re-render,
1428
+ so an in-progress edit/caret in either box is never disturbed): the
1429
+ selected box's outline view gets a thick, solid black outline
1430
+ (deliberately NOT the user's system accent color — on a Graphite
1431
+ accent it renders as plain gray and is indistinguishable from chrome;
1432
+ 2026-07-08 feedback wanted "a stronger color", then specifically
1433
+ black), the other a plain hairline. Every color here is baked via
1434
+ _solid() first: drawn as-is, dynamic system colors render partially
1435
+ see-through against whatever is behind the card's frosted/vibrant
1436
+ panel, which was why an earlier pass still looked washed out over a
1437
+ dark desktop. Applied to the dedicated outline wrapper, not the
1438
+ scroll view itself; see the comment at its construction in _render
1439
+ for why."""
1440
+ selected_color = NSColor.blackColor()
1441
+ for slot, outline in (self._draft_outlines or {}).items():
1373
1442
  try:
1374
- layer = scroll.layer()
1443
+ layer = outline.layer()
1375
1444
  if layer is None:
1376
1445
  continue
1377
1446
  if slot == self._selected_draft:
1378
- layer.setBorderWidth_(2.0)
1379
- layer.setBorderColor_(NSColor.controlAccentColor().CGColor())
1447
+ layer.setBorderWidth_(3.0)
1448
+ layer.setBorderColor_(selected_color.CGColor())
1449
+ # A ring alone read as barely-there against the frosted
1450
+ # panel. The margin between this wrapper's edge and the
1451
+ # inset scroll view is otherwise plain background, so
1452
+ # tinting it toward black turns that margin into a visible
1453
+ # halo, not just a hairline — obvious at a glance, not
1454
+ # just on close inspection.
1455
+ tint = _solid(
1456
+ NSColor.textBackgroundColor()
1457
+ ).blendedColorWithFraction_ofColor_(0.30, selected_color)
1458
+ layer.setBackgroundColor_((tint or selected_color).CGColor())
1380
1459
  else:
1381
1460
  layer.setBorderWidth_(1.0)
1382
- layer.setBorderColor_(NSColor.separatorColor().CGColor())
1461
+ layer.setBorderColor_(_solid(NSColor.separatorColor()).CGColor())
1462
+ layer.setBackgroundColor_(_solid(NSColor.textBackgroundColor()).CGColor())
1383
1463
  except Exception:
1384
1464
  pass
1385
1465
 
@@ -2338,14 +2338,26 @@ class S4LMenuBar(rumps.App):
2338
2338
  # has been "drafting" past DRAFT_STUCK_SECONDS the worker keeps getting
2339
2339
  # killed mid-run (or never claims) and nothing is draining — flip to ⚠
2340
2340
  # instead of leaving the reassuring "drafting (8m)" spinner up. Skip when a
2341
- # more specific cause (rate limit) already owns the reason. Gated on
2342
- # schedule_state == "ok" (like the rate-limit check above): when the
2343
- # schedule is missing/disabled (e.g. orphaned by an account switch), the
2344
- # producer ALSO sits "drafting" forever, and without this gate draft_stuck
2345
- # shadowed the missing branch in _build_menu the user saw "worker keeps
2346
- # getting killed" with NO Re-arm button instead of "Draft tasks aren't
2347
- # scheduled on this account" + the one-click fix (Karol, 2026-07-06).
2348
- if setup_complete and schedule_state == "ok" and self._stall_reason_info[0] != "rate_limited":
2341
+ # more specific cause (rate limit) already owns the reason.
2342
+ #
2343
+ # Gated on schedule_state in ("ok", "stalled"), NOT missing/disabled: when
2344
+ # the schedule is missing/disabled (e.g. orphaned by an account switch),
2345
+ # the producer ALSO sits "drafting" forever, and without excluding those two
2346
+ # draft_stuck shadowed the missing/disabled branch in _build_menu the user
2347
+ # saw "worker keeps getting killed" with NO Re-arm button instead of "Draft
2348
+ # tasks aren't scheduled on this account" + the one-click fix (Karol,
2349
+ # 2026-07-06). "stalled" is deliberately included (2026-07-08): that branch's
2350
+ # only offered fix is "Restart Claude Desktop", but a job that has sat this
2351
+ # long — claimed-and-hung, OR never claimed at all (see the ⧖ prefix check
2352
+ # below) — means a restart was either already tried and didn't help, or
2353
+ # isn't the right first move; Diagnose is the more useful single action once
2354
+ # we have DRAFT_STUCK_SECONDS of direct evidence from the queue itself,
2355
+ # which is a more reliable signal than the host's lastRunAt staleness.
2356
+ if (
2357
+ setup_complete
2358
+ and schedule_state in ("ok", "stalled")
2359
+ and self._stall_reason_info[0] != "rate_limited"
2360
+ ):
2349
2361
  _act = st.read_activity()
2350
2362
  if (
2351
2363
  _act
@@ -2398,6 +2410,19 @@ class S4LMenuBar(rumps.App):
2398
2410
  "Drafts can’t run — this Claude account hit its rate limit. "
2399
2411
  + (self._stall_reason_info[1] or "Wait for the limit to reset or switch account."),
2400
2412
  )
2413
+ elif self._stall_reason_info[0] == "draft_stuck":
2414
+ # Previously unhandled here: fell through to the final `else`
2415
+ # below and told the user to click "Set up draft schedule" — a
2416
+ # button that doesn't exist in the draft_stuck menu (only
2417
+ # Diagnose does). Fixed 2026-07-08 so the notification always
2418
+ # matches the one button the menu actually shows.
2419
+ _unclaimed = "⧖" in (self._stall_reason_info[1] or "")
2420
+ self._notify(
2421
+ "S4L drafts not completing",
2422
+ ("No worker is claiming draft jobs. " if _unclaimed else
2423
+ "A worker claimed a draft job and never finished it. ")
2424
+ + "Open the S4L menu → “Diagnose & fix in Claude…”.",
2425
+ )
2401
2426
  elif schedule_state == "disabled":
2402
2427
  self._notify(
2403
2428
  "S4L draft tasks disabled",
@@ -2937,21 +2962,37 @@ class S4LMenuBar(rumps.App):
2937
2962
  # When the schedule IS firing (ok), attention is False and nothing shows here
2938
2963
  # — a firing autopilot reads as healthy even if no draft has drained yet.
2939
2964
  if attention:
2965
+ # Exactly ONE clickable action per ⚠ scenario, always — never stack a
2966
+ # specific fix next to the universal "Diagnose & fix" escape hatch.
2967
+ # Each branch below picks whichever single action is most relevant:
2968
+ # a known mechanical fix when one exists, or Diagnose when it doesn't
2969
+ # (2026-07-08, replacing the old "specific fix + Diagnose" pattern that
2970
+ # showed 2-3 buttons at once and confused which one to click).
2940
2971
  if self._stall_reason_info[0] == "rate_limited":
2941
2972
  # Routines fire but every run dies on a Claude rate limit (429).
2942
- # Re-arm can't fix this, so don't offer it just say what's wrong.
2973
+ # Re-arm/restart can't fix this Diagnose is the one relevant action.
2943
2974
  items.append(self._label("⚠ Claude rate-limited — drafts can’t run"))
2944
2975
  items.append(self._label(
2945
2976
  " " + (self._stall_reason_info[1] or "wait for reset or switch account")
2946
2977
  ))
2978
+ items.append(rumps.MenuItem("Diagnose & fix in Claude…", callback=self._diagnose_fix))
2947
2979
  elif self._stall_reason_info[0] == "draft_stuck":
2948
- # Routines fire and the producer keeps narrating "drafting" but the
2949
- # worker keeps getting killed mid-run / never returns a result. Don't
2950
- # offer Re-arm (routines are fine); state the real problem.
2951
- items.append(self._label("⚠ Draft not completing worker keeps getting killed"))
2980
+ # Routines fire and the producer keeps narrating "drafting" but
2981
+ # nothing is finishing. Two different root causes share this one
2982
+ # reason code, and the label already tells us which: a '⧖' prefix
2983
+ # (see _label_elapsed_secs) means the job has sat in the pending
2984
+ # queue this whole time with no worker ever claiming it; no '⧖'
2985
+ # means something DID claim it and then hung or died mid-run.
2986
+ # Re-arm/restart can't fix either case — Diagnose is the one action.
2987
+ _unclaimed = "⧖" in (self._stall_reason_info[1] or "")
2988
+ items.append(self._label(
2989
+ "⚠ No worker is claiming draft jobs" if _unclaimed
2990
+ else "⚠ Draft not completing — worker keeps getting killed"
2991
+ ))
2952
2992
  items.append(self._label(
2953
2993
  " " + (self._stall_reason_info[1] or "drafting") + " — no result yet"
2954
2994
  ))
2995
+ items.append(rumps.MenuItem("Diagnose & fix in Claude…", callback=self._diagnose_fix))
2955
2996
  elif schedule_state == "disabled":
2956
2997
  items.append(self._label("⚠ Draft tasks are scheduled but disabled"))
2957
2998
  items.append(rumps.MenuItem("Set up draft schedule for this account", callback=self._rearm))
@@ -2959,19 +3000,15 @@ class S4LMenuBar(rumps.App):
2959
3000
  # Task registered + enabled but the host stopped launching it: the
2960
3001
  # Claude Desktop warm-session wedge (finished worker sessions never
2961
3002
  # exit; the overlap guard skips every fire) or an account-switch
2962
- # orphan. A full Claude restart fixes the wedge and is harmless
2963
- # otherwise, so it's the PRIMARY action; re-arm stays as fallback
2964
- # for the orphan case (Karol, 2026-07-06).
3003
+ # orphan. _restart_claude_fix_work already runs the registry
3004
+ # self-heal (ensure-worker/orphan repair) while Claude is down,
3005
+ # before relaunching so it covers both known causes in one click,
3006
+ # making it the one relevant action here (Karol, 2026-07-06).
2965
3007
  items.append(self._label("⚠ Drafts stopped — Claude’s scheduler is stuck"))
2966
3008
  items.append(rumps.MenuItem("Restart Claude Desktop to fix", callback=self._restart_claude_fix))
2967
- items.append(rumps.MenuItem("Set up draft schedule for this account", callback=self._rearm))
2968
3009
  else:
2969
3010
  items.append(self._label("⚠ Draft tasks aren’t scheduled on this account"))
2970
3011
  items.append(rumps.MenuItem("Set up draft schedule for this account", callback=self._rearm))
2971
- # Universal escape hatch for EVERY persistent ⚠ (the draft_stuck and
2972
- # rate_limited branches previously dead-ended with labels only): hand
2973
- # Claude a diagnose-and-heal prompt that also reports back to us.
2974
- items.append(rumps.MenuItem("Diagnose & fix in Claude…", callback=self._diagnose_fix))
2975
3012
  items.append(rumps.separator)
2976
3013
 
2977
3014
  if not runtime_ready:
package/mcp/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m13v/s4l-mcp",
3
- "version": "1.7.1-rc.21",
3
+ "version": "1.7.1-rc.23",
4
4
  "private": true,
5
5
  "description": "Desktop MCP client for social-autoposter (X/Twitter rail): manual draft/review/approve loop, autopilot control, and stats. Thin wrapper over the existing pipeline scripts.",
6
6
  "license": "MIT",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m13v/s4l",
3
- "version": "1.7.1-rc.21",
3
+ "version": "1.7.1-rc.23",
4
4
  "description": "Automated social posting pipeline for Reddit, X/Twitter, LinkedIn, and Moltbook. Install as a Claude Code agent skill.",
5
5
  "bin": {
6
6
  "social-autoposter": "bin/cli.js",
@@ -155,6 +155,7 @@
155
155
  "node": ">=16"
156
156
  },
157
157
  "dependencies": {
158
+ "@sentry/node": "^10.64.0",
158
159
  "firebase-admin": "^13.8.0",
159
160
  "pg": "^8.20.0",
160
161
  "ws": "^8.0.0"
@@ -262,22 +262,48 @@ def main() -> int:
262
262
  plan_obj = {"candidates": merged}
263
263
  if plan_created_at:
264
264
  plan_obj["created_at"] = plan_created_at
265
- _atomic_write(dst, plan_obj)
266
- ensure_store_symlink()
267
-
268
- # Refresh the review-request marker the menu bar polls (count = pending, not posted).
269
- pending = len([c for c in merged if not c.get("posted")])
270
- project = ns.project or batch.get("project") or (new_cands[0].get("matched_project") if new_cands else None)
271
- _atomic_write(
272
- review_request_path(),
273
- {
274
- "batch_id": REVIEW_QUEUE_ID,
275
- "project": project,
276
- "count": pending,
277
- "plan_path": dst,
278
- "created_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
279
- },
280
- )
265
+
266
+ # This is the actual delivery: if anything below throws, the cycle's drafts
267
+ # were computed but never reached the store the menu bar reads, and the
268
+ # wrapper (run-draft-and-publish.sh) captures this process's whole stdout+
269
+ # stderr with `|| true`, so a crash here previously vanished into a local
270
+ # log nothing central reads the exact blind spot that cost the 2026-07-08
271
+ # Karol investigation its root cause. Report it like any other handled
272
+ # pipeline failure (see twitter_post_plan.py's post-failure capture).
273
+ try:
274
+ _atomic_write(dst, plan_obj)
275
+ ensure_store_symlink()
276
+
277
+ # Refresh the review-request marker the menu bar polls (count = pending, not posted).
278
+ pending = len([c for c in merged if not c.get("posted")])
279
+ project = ns.project or batch.get("project") or (new_cands[0].get("matched_project") if new_cands else None)
280
+ _atomic_write(
281
+ review_request_path(),
282
+ {
283
+ "batch_id": REVIEW_QUEUE_ID,
284
+ "project": project,
285
+ "count": pending,
286
+ "plan_path": dst,
287
+ "created_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
288
+ },
289
+ )
290
+ except Exception as e:
291
+ print(f"[merge_review_queue] delivery failed (drafts NOT merged into cards): {e}", file=sys.stderr)
292
+ try:
293
+ import sentry_init
294
+
295
+ sentry_init.init()
296
+ sentry_init.capture_message(
297
+ f"merge_review_queue delivery failed: {e}",
298
+ level="error",
299
+ tags={"component": "merge_review_queue", "added": str(added)},
300
+ extra={"plan_src": src},
301
+ )
302
+ sentry_init.flush(2.0)
303
+ except Exception:
304
+ pass
305
+ return 1
306
+
281
307
  print(
282
308
  f"[merge_review_queue] merged {added} new draft(s) into {REVIEW_QUEUE_ID} "
283
309
  f"({pending} pending total) from {os.path.basename(src)}",
@@ -56,6 +56,14 @@ SECRET_PATTERNS = [
56
56
  # Absolute home path leak. Placeholder forms (/Users/<you>, /Users/USERNAME) pass.
57
57
  HOME_PATH_RE = re.compile(r"/Users/(?!<|USER|USERNAME|you\b|me\b|name\b)[a-z0-9._-]{2,}", re.I)
58
58
 
59
+
60
+ def _home_path_exempt(path: str) -> bool:
61
+ """launchd .plist files legitimately require absolute paths — launchd does not
62
+ expand ~ or $HOME in ProgramArguments/StandardOutPath/etc. The repo already
63
+ tracks 70+ of them, so exempt this file class from the absolute-home-path rule.
64
+ Secrets, PII, and image checks still apply."""
65
+ return path.startswith("launchd/") and path.endswith(".plist")
66
+
59
67
  IMAGE_EXT = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".heic", ".bmp", ".tiff"}
60
68
 
61
69
 
@@ -146,7 +154,7 @@ def scan(paths: list[str], staged: bool) -> tuple[list[str], list[str]]:
146
154
  if deny_re and deny_re.search(text):
147
155
  m = deny_re.search(text)
148
156
  hard.append(f"{path}:{lineno}: PII denylist match ('{m.group(0)}')")
149
- if HOME_PATH_RE.search(text):
157
+ if HOME_PATH_RE.search(text) and not _home_path_exempt(path):
150
158
  m = HOME_PATH_RE.search(text)
151
159
  (hard if staged else soft).append(
152
160
  f"{path}:{lineno}: absolute home path ('{m.group(0)}...')"