@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.
- package/mcp/dist/index.js +571 -79
- package/mcp/dist/panel.html +2 -3
- package/mcp/dist/redditAuth.js +115 -0
- package/mcp/dist/runtime.js +83 -2
- package/mcp/dist/setup.js +142 -5
- package/mcp/dist/telemetry.js +13 -3
- package/mcp/dist/version.json +2 -2
- package/mcp/manifest.json +1 -1
- package/mcp/menubar/s4l_card.py +32 -6
- package/mcp/menubar/s4l_menubar.py +42 -111
- package/mcp/menubar/s4l_state.py +54 -187
- package/mcp/package.json +1 -1
- package/mcp/shared/onboarding-ledger.cjs +63 -4
- package/package.json +1 -1
- package/scripts/autopilot_stall_watch.py +3 -6
- package/scripts/browser_mutex.py +316 -0
- package/scripts/claude_job.py +21 -0
- package/scripts/config.py +118 -0
- package/scripts/counterparty_history.py +44 -6
- package/scripts/engage_github.py +7 -3
- package/scripts/engage_reddit.py +9 -3
- package/scripts/engagement_signal_feedback.py +256 -0
- package/scripts/learned_preferences.py +37 -14
- package/scripts/memory_snapshot.py +45 -0
- package/scripts/merge_review_queue.py +67 -3
- package/scripts/post_reddit.py +289 -24
- package/scripts/reddit_browser.py +62 -63
- package/scripts/reddit_browser_fetch.py +32 -0
- package/scripts/release-mcpb.sh +17 -0
- package/scripts/reset-test-machine.sh +13 -7
- package/scripts/scan_twitter_thread_followups.py +7 -1
- package/scripts/schedule_state.py +1 -1
- package/scripts/setup_reddit_auth.py +925 -0
- package/scripts/snapshot.py +96 -4
- package/scripts/stats.py +37 -13
- package/scripts/test_no_silent_fallbacks.py +281 -0
- package/scripts/trigger_promo_lane_test.sh +91 -0
- package/scripts/twitter_browser.py +183 -342
- package/scripts/twitter_scan.py +75 -10
- package/scripts/watchdog_hung_runs.py +15 -9
- package/skill/lib/harness-common.sh +344 -0
- package/skill/lib/linkedin-backend.sh +61 -161
- package/skill/lib/reddit-backend.sh +39 -125
- package/skill/lib/twitter-backend.sh +51 -273
- package/skill/lock.sh +57 -0
- package/skill/run-reddit-search-launchd.sh +4 -1
- package/skill/run-reddit-search.sh +51 -2
- package/skill/run-twitter-cycle.sh +44 -21
package/mcp/menubar/s4l_state.py
CHANGED
|
@@ -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)}
|
|
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
|
-
|
|
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":
|
|
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
|
|
1032
|
-
|
|
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.
|
|
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
|
-
|
|
287
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
@@ -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
|
|
285
|
-
|
|
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 = (
|