@m13v/s4l 1.7.5-rc.2 → 1.7.5-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 (48) hide show
  1. package/mcp/dist/index.js +571 -79
  2. package/mcp/dist/panel.html +2 -3
  3. package/mcp/dist/redditAuth.js +115 -0
  4. package/mcp/dist/runtime.js +83 -2
  5. package/mcp/dist/setup.js +142 -5
  6. package/mcp/dist/telemetry.js +13 -3
  7. package/mcp/dist/version.json +2 -2
  8. package/mcp/manifest.json +1 -1
  9. package/mcp/menubar/s4l_card.py +32 -6
  10. package/mcp/menubar/s4l_menubar.py +42 -111
  11. package/mcp/menubar/s4l_state.py +54 -187
  12. package/mcp/package.json +1 -1
  13. package/mcp/shared/onboarding-ledger.cjs +63 -4
  14. package/package.json +1 -1
  15. package/scripts/autopilot_stall_watch.py +3 -6
  16. package/scripts/browser_mutex.py +316 -0
  17. package/scripts/claude_job.py +21 -0
  18. package/scripts/config.py +118 -0
  19. package/scripts/counterparty_history.py +44 -6
  20. package/scripts/engage_github.py +7 -3
  21. package/scripts/engage_reddit.py +9 -3
  22. package/scripts/engagement_signal_feedback.py +256 -0
  23. package/scripts/learned_preferences.py +37 -14
  24. package/scripts/memory_snapshot.py +45 -0
  25. package/scripts/merge_review_queue.py +67 -3
  26. package/scripts/post_reddit.py +289 -24
  27. package/scripts/reddit_browser.py +62 -63
  28. package/scripts/reddit_browser_fetch.py +32 -0
  29. package/scripts/release-mcpb.sh +17 -0
  30. package/scripts/reset-test-machine.sh +13 -7
  31. package/scripts/scan_twitter_thread_followups.py +7 -1
  32. package/scripts/schedule_state.py +1 -1
  33. package/scripts/setup_reddit_auth.py +925 -0
  34. package/scripts/snapshot.py +96 -4
  35. package/scripts/stats.py +37 -13
  36. package/scripts/test_no_silent_fallbacks.py +281 -0
  37. package/scripts/trigger_promo_lane_test.sh +91 -0
  38. package/scripts/twitter_browser.py +183 -342
  39. package/scripts/twitter_scan.py +75 -10
  40. package/scripts/watchdog_hung_runs.py +15 -9
  41. package/skill/lib/harness-common.sh +344 -0
  42. package/skill/lib/linkedin-backend.sh +61 -161
  43. package/skill/lib/reddit-backend.sh +39 -125
  44. package/skill/lib/twitter-backend.sh +51 -273
  45. package/skill/lock.sh +57 -0
  46. package/skill/run-reddit-search-launchd.sh +4 -1
  47. package/skill/run-reddit-search.sh +51 -2
  48. package/skill/run-twitter-cycle.sh +44 -21
@@ -48,7 +48,11 @@ except Exception:
48
48
  # both mutate it; without this a concurrent interleave would drop an approval.
49
49
  _approved_lock = threading.Lock()
50
50
 
51
- # Mirrors shared/onboarding-ledger.cjs MILESTONES (same order).
51
+ # Mirrors shared/onboarding-ledger.cjs MILESTONES (same order). The ledger's
52
+ # OPTIONAL milestones (reddit_connected / reddit_verified) are deliberately NOT
53
+ # listed here, same as x_verified: this list drives the offline completeness
54
+ # check and the step list, and optional platform milestones must never make an
55
+ # X-only box read as "Setting up".
52
56
  MILESTONES = [
53
57
  "environment_checked",
54
58
  "runtime_ready",
@@ -60,7 +64,8 @@ MILESTONES = [
60
64
  "tasks_scheduled",
61
65
  ]
62
66
 
63
- # Mirrors panel.ts MILESTONE_LABELS.
67
+ # Mirrors panel.ts MILESTONE_LABELS. Includes labels for optional milestones so
68
+ # server-snapshot rows (which include them once touched) render nicely.
64
69
  MILESTONE_LABELS = {
65
70
  "environment_checked": "Environment checked",
66
71
  "runtime_ready": "Runtime ready",
@@ -70,6 +75,8 @@ MILESTONE_LABELS = {
70
75
  "project_ready": "Project ready",
71
76
  "topics_seeded": "Topics seeded",
72
77
  "tasks_scheduled": "Tasks scheduled",
78
+ "reddit_connected": "Reddit connected",
79
+ "reddit_verified": "Reddit verified",
73
80
  }
74
81
 
75
82
  # Mirrors index.ts TWITTER_AUTOPILOT_LABEL.
@@ -190,14 +197,49 @@ def read_onboarding():
190
197
  return "complete"
191
198
  return st
192
199
 
200
+ # Any-of platform completion (mirror of onboarding-ledger.cjs
201
+ # milestoneSatisfied, 2026-07-15): a reddit-only install must read complete.
202
+ # x_connected also counts when reddit_connected is complete, and
203
+ # profile_scanned (an X-account scan) is required only while X is the
204
+ # connected platform. reddit_* ids are read straight from the raw ledger
205
+ # dict; they are deliberately not in MILESTONES (optional, never gating).
206
+ def _satisfied(mid):
207
+ if _status(mid) == "complete":
208
+ return True
209
+ reddit_done = (ms.get("reddit_connected") or {}).get("status") == "complete"
210
+ if mid == "x_connected":
211
+ return reddit_done
212
+ if mid == "profile_scanned":
213
+ return _status("x_connected") != "complete" and reddit_done
214
+ return False
215
+
216
+ # Omit pristine-pending rows the other platform satisfies (reddit-only
217
+ # install: x_connected / profile_scanned), and append touched optional
218
+ # reddit rows, mirroring the server snapshot's row filtering.
193
219
  milestones = [
194
- {"id": mid, **(ms.get(mid) or {}), "status": _status(mid)} for mid in MILESTONES
220
+ {"id": mid, **(ms.get(mid) or {}), "status": _status(mid)}
221
+ for mid in MILESTONES
222
+ if not (_status(mid) in (None, "pending") and _satisfied(mid))
195
223
  ]
196
- complete = all(_status(mid) == "complete" for mid in MILESTONES)
224
+ for mid in ("reddit_connected", "reddit_verified"):
225
+ st = (ms.get(mid) or {}).get("status")
226
+ if st and st != "pending":
227
+ milestones.append({"id": mid, **(ms.get(mid) or {}), "optional": True})
228
+ complete = all(_satisfied(mid) for mid in MILESTONES)
229
+ # Blocker suppression, mirror of the server snapshot: a blocker on an
230
+ # optional reddit milestone or on a milestone the other platform satisfies
231
+ # must not flag attention.
232
+ blocker = d.get("current_blocker")
233
+ if blocker:
234
+ bmid = blocker.get("milestone")
235
+ if bmid in ("reddit_connected", "reddit_verified"):
236
+ blocker = None
237
+ elif bmid in MILESTONES and _satisfied(bmid):
238
+ blocker = None
197
239
  return {
198
240
  "complete": complete,
199
241
  "milestones": milestones,
200
- "current_blocker": d.get("current_blocker"),
242
+ "current_blocker": blocker,
201
243
  }
202
244
 
203
245
 
@@ -209,15 +251,6 @@ def runtime_ready() -> bool:
209
251
  return bool(py and os.path.exists(py))
210
252
 
211
253
 
212
- def _launchctl_list() -> str:
213
- try:
214
- return subprocess.run(
215
- ["launchctl", "list"], capture_output=True, text=True, timeout=10
216
- ).stdout
217
- except Exception:
218
- return ""
219
-
220
-
221
254
  def autopilot_loaded() -> bool:
222
255
  # Autopilot is now the Claude Desktop scheduled task, not the legacy launchd job.
223
256
  cfg = os.environ.get("CLAUDE_CONFIG_DIR") or os.path.join(str(Path.home()), ".claude")
@@ -456,23 +489,6 @@ def snapshot():
456
489
  return out
457
490
 
458
491
 
459
- def stats_7d():
460
- """7-day post stats; loopback only (the DB read needs the owned runtime)."""
461
- res = loopback_tool("get_stats", {"days": 7})
462
- if not isinstance(res, dict):
463
- return None
464
- projects = res.get("projects")
465
- proj = projects[0] if isinstance(projects, list) and projects else None
466
- p = (proj or {}).get("posts")
467
- if not p:
468
- return None
469
- return {
470
- "posts": p.get("total", 0),
471
- "views": p.get("views_period_total", p.get("views", 0)),
472
- "replies": p.get("comments_period_total", p.get("comments", 0)),
473
- }
474
-
475
-
476
492
  # set_autopilot() (the launchd toggle) was removed: the autopilot is now the Claude
477
493
  # Desktop scheduled task `social-autoposter-autopilot`, managed in the Scheduled tab,
478
494
  # so the menu bar no longer toggles a launchd job (it mirrors the dashboard instead).
@@ -498,51 +514,6 @@ def accessibility_trusted() -> bool:
498
514
  return False
499
515
 
500
516
 
501
- def request_accessibility() -> bool:
502
- """Pop the system Accessibility prompt for THIS process (registers it in the
503
- list so the user can toggle it on) and open the Settings pane. Returns the
504
- current trust state. Safe to call when already trusted (no prompt shown)."""
505
- trusted = False
506
- try:
507
- import ctypes
508
- import ctypes.util
509
-
510
- cf = ctypes.cdll.LoadLibrary(ctypes.util.find_library("CoreFoundation"))
511
- ap = ctypes.cdll.LoadLibrary(ctypes.util.find_library("ApplicationServices"))
512
- prompt_key = ctypes.c_void_p.in_dll(ap, "kAXTrustedCheckOptionPrompt")
513
- cf_true = ctypes.c_void_p.in_dll(cf, "kCFBooleanTrue")
514
- cf.CFDictionaryCreate.restype = ctypes.c_void_p
515
- cf.CFDictionaryCreate.argtypes = [
516
- ctypes.c_void_p,
517
- ctypes.POINTER(ctypes.c_void_p),
518
- ctypes.POINTER(ctypes.c_void_p),
519
- ctypes.c_long,
520
- ctypes.c_void_p,
521
- ctypes.c_void_p,
522
- ]
523
- keys = (ctypes.c_void_p * 1)(prompt_key)
524
- vals = (ctypes.c_void_p * 1)(cf_true)
525
- d = cf.CFDictionaryCreate(None, keys, vals, 1, None, None)
526
- ap.AXIsProcessTrustedWithOptions.restype = ctypes.c_bool
527
- ap.AXIsProcessTrustedWithOptions.argtypes = [ctypes.c_void_p]
528
- trusted = bool(ap.AXIsProcessTrustedWithOptions(d))
529
- except Exception:
530
- pass
531
- try:
532
- subprocess.run(
533
- [
534
- "open",
535
- "x-apple.systempreferences:com.apple.preference.security"
536
- "?Privacy_Accessibility",
537
- ],
538
- capture_output=True,
539
- timeout=10,
540
- )
541
- except Exception:
542
- pass
543
- return trusted
544
-
545
-
546
517
  # ---- draft review (pop-up cards) ------------------------------------------
547
518
  # draft_cycle writes review-request.json when a fresh batch is ready; we read
548
519
  # the linked plan file (the /tmp/twitter_cycle_plan_<batch>.json the pipeline
@@ -1028,8 +999,9 @@ def review_drafts(plan, batch="review-queue"):
1028
999
  must never be re-presented for review (approved ones proceed to post).
1029
1000
 
1030
1001
  Also excludes cards with ANY durable decision (approved, edited, rejected, or a
1031
- decided-but-failed post) via review_settled_ns(). approve/reject/edit are now
1032
- IDENTICAL: each writes a durable local record the INSTANT the user clicks, so a
1002
+ decided-but-failed post) via _ledger_items_for_plan() below. approve/reject/edit
1003
+ each write a durable local record via store_stamp_decision() the INSTANT the
1004
+ user clicks (see s4l_menubar.py::_on_card_decision), so a
1033
1005
  decided card never re-presents even if the loopback (Claude Desktop) is down
1034
1006
  when the decision's plan-flag write is attempted. The main plan's
1035
1007
  `approved`/`terminal`/`posted` flags are only stamped once the loopback write
@@ -1055,6 +1027,10 @@ def review_drafts(plan, batch="review-queue"):
1055
1027
  out.append(
1056
1028
  {
1057
1029
  "n": i + 1, # 1-based, matches post_drafts numbering
1030
+ # Platform tag (reddit cards, 2026-07-14): absent/None means
1031
+ # twitter (every plan written before the field shipped). The
1032
+ # card renders the platform mark and profile link from this.
1033
+ "platform": c.get("platform"),
1058
1034
  "thread_author": c.get("thread_author"),
1059
1035
  "thread_text": c.get("thread_text"),
1060
1036
  "reply_text": c.get("reply_text") or "",
@@ -1183,13 +1159,6 @@ def read_flags():
1183
1159
  return dict(_DEFAULT_FLAGS)
1184
1160
 
1185
1161
 
1186
- def read_mode():
1187
- """Derived legacy single-mode string (personal_brand wins when on). Kept so
1188
- older menu-bar callers that expect one value keep working."""
1189
- f = read_flags()
1190
- return MODE_PERSONAL_BRAND if f.get("personal_brand") else MODE_PROMOTION
1191
-
1192
-
1193
1162
  def write_flags(personal_brand, promotion):
1194
1163
  """Persist both lane flags atomically (plus the derived legacy `mode`).
1195
1164
 
@@ -1363,16 +1332,6 @@ def write_posting_mode(mode):
1363
1332
  return stored
1364
1333
 
1365
1334
 
1366
- def write_mode(mode):
1367
- """Legacy single-mode setter: named lane ON, the other OFF (compat)."""
1368
- if mode not in _VALID_MODES:
1369
- return read_flags()
1370
- return write_flags(
1371
- personal_brand=(mode == MODE_PERSONAL_BRAND),
1372
- promotion=(mode == MODE_PROMOTION),
1373
- )
1374
-
1375
-
1376
1335
  def toggle_lane(lane):
1377
1336
  """Flip ONE lane (personal_brand|promotion) and return the new flags."""
1378
1337
  if lane not in _VALID_MODES:
@@ -1382,75 +1341,6 @@ def toggle_lane(lane):
1382
1341
  return write_flags(f["personal_brand"], f["promotion"])
1383
1342
 
1384
1343
 
1385
- def toggle_mode():
1386
- """Legacy whole-mode flip (mutually exclusive). Kept for old callers."""
1387
- new = (
1388
- MODE_PROMOTION
1389
- if read_mode() == MODE_PERSONAL_BRAND
1390
- else MODE_PERSONAL_BRAND
1391
- )
1392
- return write_mode(new)
1393
-
1394
-
1395
- def review_reject_add(batch, n, candidate_id=None):
1396
- """Record a REJECT the INSTANT the user clicks, mirroring approved_queue_add.
1397
- Reject and approve are now IDENTICAL: both write a durable local record before
1398
- any loopback call, so review_drafts() suppresses the card even if the loopback
1399
- (Claude Desktop) is down when the reject's plan `terminal` write is attempted.
1400
- Dedups on (batch, n) within the CURRENT plan generation (a stale item's n
1401
- refers to a dead plan and must not absorb this decision); a reject is FINAL
1402
- and overrides any earlier status."""
1403
- with _approved_lock:
1404
- gen = _plan_generation(batch)
1405
- d = read_approved_queue()
1406
- for it in d["items"]:
1407
- if it.get("batch") == batch and it.get("n") == n and not _stale_for_plan(it, gen):
1408
- if it.get("status") != "rejected":
1409
- it.update(status="rejected", error=None, ts=time_iso())
1410
- _write_approved_queue(d)
1411
- return
1412
- d["items"].append({
1413
- "batch": batch, "n": n, "candidate_id": candidate_id,
1414
- "text": "", "edited": False,
1415
- "drop_link": False, "candidate_url": "", "status": "rejected",
1416
- "error": None, "ts": time_iso(),
1417
- })
1418
- _write_approved_queue(d)
1419
-
1420
-
1421
- def approved_queue_add(batch, n, text="", edited=False, candidate_url="", drop_link=False,
1422
- candidate_id=None):
1423
- """Record an approval the INSTANT the user clicks, before any posting. Dedups
1424
- on (batch, n) within the CURRENT plan generation: re-approving a card that's
1425
- still queued/posting/posted is a no-op; a previously FAILED card is reset to
1426
- queued so it retries. A stale item (previous plan generation, same n) must
1427
- not absorb the approval or the fresh card would silently never post.
1428
-
1429
- candidate_id is the durable identity of the decision (twitter_candidates
1430
- row); n is only meaningful within one plan generation.
1431
-
1432
- drop_link carries the user's "I deleted the link while editing" intent so a
1433
- restart-resumed post honors it too (else the poster re-appends the link)."""
1434
- with _approved_lock:
1435
- gen = _plan_generation(batch)
1436
- d = read_approved_queue()
1437
- for it in d["items"]:
1438
- if it.get("batch") == batch and it.get("n") == n and not _stale_for_plan(it, gen):
1439
- if it.get("status") == "failed":
1440
- it.update(status="queued", text=text, edited=bool(edited),
1441
- drop_link=bool(drop_link), error=None, ts=time_iso())
1442
- _write_approved_queue(d)
1443
- return
1444
- d["items"].append({
1445
- "batch": batch, "n": n, "candidate_id": candidate_id,
1446
- "text": text, "edited": bool(edited),
1447
- "drop_link": bool(drop_link),
1448
- "candidate_url": candidate_url, "status": "queued",
1449
- "error": None, "ts": time_iso(),
1450
- })
1451
- _write_approved_queue(d)
1452
-
1453
-
1454
1344
  def approved_queue_set_status(batch, n, status, error=None):
1455
1345
  with _approved_lock:
1456
1346
  d = read_approved_queue()
@@ -1470,29 +1360,6 @@ def approved_queue_pending():
1470
1360
  if it.get("status") in ("queued", "posting")]
1471
1361
 
1472
1362
 
1473
- def approved_queue_active_ns(batch):
1474
- """Plan indices the user has already approved for this batch — review_drafts()
1475
- excludes these so an approved card is never re-shown. Covers queued/posting
1476
- (in flight) AND posted: relying on the plan's posted flag alone leaves a window
1477
- (and breaks if the plan is regenerated), so the durable queue excludes posted
1478
- cards independently. `failed` is intentionally NOT excluded, so a failed post
1479
- falls back to manual review rather than silently vanishing."""
1480
- gen = _plan_generation(batch)
1481
- return {it.get("n") for it in read_approved_queue()["items"]
1482
- if it.get("batch") == batch and it.get("status") in ("queued", "posting", "posted")
1483
- and not _stale_for_plan(it, gen)}
1484
-
1485
-
1486
- def review_settled_ns(batch):
1487
- """Plan indices with ANY durable user DECISION for this batch — review_drafts()
1488
- excludes these so approve, edit, and reject behave IDENTICALLY: a decided card
1489
- never re-presents for review. Covers queued/posting/posted (approved, in flight
1490
- or landed), `rejected`, AND `failed` (a decided-but-failed post is surfaced via
1491
- the failure notification + dashboard, NOT by re-showing it as a fresh review
1492
- card — that re-show was the "I already decided these came back" bug)."""
1493
- return {it.get("n") for it in _ledger_items_for_plan(batch, _plan_generation(batch))}
1494
-
1495
-
1496
1363
  def post_drafts(batch_id, post=None, edits=None, reject=None, clear_link=None, timeout=900, activity_label=None):
1497
1364
  """Post / reject drafts via the loopback tool. `post` = 1-based numbers to post
1498
1365
  as-is; `edits` = [{n, text}] to rewrite then post; `reject` = numbers to mark
package/mcp/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m13v/s4l-mcp",
3
- "version": "1.7.5-rc.2",
3
+ "version": "1.7.5-rc.21",
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",
@@ -16,8 +16,38 @@ const MILESTONES = [
16
16
  "project_ready",
17
17
  "topics_seeded",
18
18
  "tasks_scheduled",
19
+ // Optional platform add-ons (see OPTIONAL_MILESTONES below). Listed here so
20
+ // assertMilestone/appendEvent accept them, but they never gate `complete`.
21
+ "reddit_connected",
22
+ "reddit_verified",
19
23
  ];
20
24
 
25
+ // Milestones that are offered but NOT required for onboarding completion.
26
+ // Reddit is an optional platform: most installs never connect it, and adding
27
+ // these ids must never regress an already-complete (or fresh X-only) box back
28
+ // to "Setting up". publicSnapshot() therefore (a) excludes these from the
29
+ // `complete` computation and (b) omits them from the milestones array while
30
+ // they are still pristine "pending" (so progress bars and step lists are
31
+ // unchanged for installs that never touch reddit); the same
32
+ // never-regress-legacy-ledgers principle as the mode_chosen backfill.
33
+ const OPTIONAL_MILESTONES = ["reddit_connected", "reddit_verified"];
34
+
35
+ // Any-of platform completion (2026-07-15, user requirement): onboarding is
36
+ // complete with AT LEAST ONE platform connected. X stays the default first
37
+ // suggestion, but a Reddit-only install must read complete. A required
38
+ // X milestone therefore also counts as satisfied when its Reddit counterpart
39
+ // is complete; profile_scanned (an X-account scan) is required only while X is
40
+ // the connected platform. Same never-regress principle as the mode_chosen
41
+ // backfill: X-only and fresh ledgers evaluate exactly as before.
42
+ function milestoneSatisfied(ledger, id) {
43
+ const done = (m) => ledger.milestones[m] && ledger.milestones[m].status === "complete";
44
+ if (done(id)) return true;
45
+ if (id === "x_connected") return done("reddit_connected");
46
+ if (id === "x_verified") return done("reddit_verified");
47
+ if (id === "profile_scanned") return !done("x_connected") && done("reddit_connected");
48
+ return false;
49
+ }
50
+
21
51
  function stateDir(opts = {}) {
22
52
  return (
23
53
  opts.stateDir ||
@@ -283,14 +313,42 @@ function publicSnapshot(opts = {}) {
283
313
  schema_version: ledger.schema_version,
284
314
  started_at: ledger.started_at,
285
315
  updated_at: ledger.updated_at,
286
- complete: MILESTONES.every(
287
- (id) => ledger.milestones[id].status === "complete"
316
+ // Optional milestones (reddit) never gate completion, and required X
317
+ // milestones count as satisfied via their Reddit counterpart (any-of
318
+ // platform completion; see milestoneSatisfied above).
319
+ complete: MILESTONES.filter((id) => !OPTIONAL_MILESTONES.includes(id)).every(
320
+ (id) => milestoneSatisfied(ledger, id)
288
321
  ),
289
- milestones: MILESTONES.map((id) => ({
322
+ // Row filtering, two rules with one goal (the step list shows only steps
323
+ // that apply to THIS install):
324
+ // - optional milestones appear only once touched (attempted/completed/
325
+ // blocked); a pristine pending optional row would read as an
326
+ // unfinished setup step on installs that never opted in.
327
+ // - a required milestone that is still pristine-pending but satisfied
328
+ // via the other platform (reddit-only install: x_connected,
329
+ // x_verified, profile_scanned) is omitted as not-applicable. A
330
+ // blocked/in_progress row stays visible: the user actually tried it.
331
+ milestones: MILESTONES.filter((id) => {
332
+ const status = ledger.milestones[id].status;
333
+ if (OPTIONAL_MILESTONES.includes(id)) return status !== "pending";
334
+ if (status === "pending" && milestoneSatisfied(ledger, id)) return false;
335
+ return true;
336
+ }).map((id) => ({
290
337
  id,
291
338
  ...ledger.milestones[id],
339
+ ...(OPTIONAL_MILESTONES.includes(id) ? { optional: true } : {}),
292
340
  })),
293
- current_blocker: ledger.current_blocker,
341
+ // Blocker suppression: a blocker on an OPTIONAL milestone (a failed
342
+ // reddit connect attempt on an X box) or on a milestone satisfied via the
343
+ // other platform (a failed X attempt on a reddit-only box) must not flag
344
+ // the setup as needing attention. The raw blocker stays in the ledger for
345
+ // history; only the reported snapshot hides it.
346
+ current_blocker:
347
+ ledger.current_blocker &&
348
+ (OPTIONAL_MILESTONES.includes(ledger.current_blocker.milestone) ||
349
+ milestoneSatisfied(ledger, ledger.current_blocker.milestone))
350
+ ? null
351
+ : ledger.current_blocker,
294
352
  doctor: ledger.doctor.latest
295
353
  ? {
296
354
  phase: ledger.doctor.latest.phase,
@@ -311,6 +369,7 @@ function publicSnapshot(opts = {}) {
311
369
 
312
370
  module.exports = {
313
371
  MILESTONES,
372
+ OPTIONAL_MILESTONES,
314
373
  blockMilestone,
315
374
  completeMilestone,
316
375
  ledgerPath,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m13v/s4l",
3
- "version": "1.7.5-rc.2",
3
+ "version": "1.7.5-rc.21",
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",
@@ -281,8 +281,8 @@ def _oldest_running_age() -> float | None:
281
281
  """Seconds since the oldest CLAIMED-but-unfinished job was written, or None if
282
282
  nothing is in flight. A worker claims by moving a job pending/ -> running/ and
283
283
  only removes it on result, so a job lingering in running/ far past any real
284
- drafting turn means the worker claimed it and then wedged mid-run (dead/never-
285
- spawned claude -p child). This is the ONLY signal for that case: pending-age is
284
+ drafting turn means the worker claimed it and then wedged mid-run.
285
+ This is the ONLY signal for that case: pending-age is
286
286
  silent (the job left pending/) and the producer's drain latch hasn't fired yet
287
287
  (it's still inside its own timeout). running/ is flat (see claude_job.py)."""
288
288
  run_root = os.path.join(_queue_root(), "running")
@@ -521,10 +521,7 @@ def main() -> int:
521
521
  sentry = _sentry()
522
522
  sentry.init()
523
523
  if wedged_inflight:
524
- cause = (
525
- "a worker claimed a draft job and then died mid-run (claude -p child "
526
- "never came up / crashed)"
527
- )
524
+ cause = "a worker claimed a draft job and then died mid-run"
528
525
  stall_shape = "inflight_wedged"
529
526
  elif batches_stuck:
530
527
  cause = (