@m13v/s4l 1.6.202 → 1.6.203-rc.10

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 CHANGED
@@ -408,8 +408,8 @@ function toolActivityLabel(name, args) {
408
408
  const fallback = TOOL_ACTIVITY[name];
409
409
  if (!fallback)
410
410
  return null;
411
- const override = typeof args?.__saps_activity_label === "string"
412
- ? args.__saps_activity_label.replace(/\s+/g, " ").trim().slice(0, 80)
411
+ const override = typeof args?.__s4l_activity_label === "string"
412
+ ? args.__s4l_activity_label.replace(/\s+/g, " ").trim().slice(0, 80)
413
413
  : "";
414
414
  return override || fallback;
415
415
  }
@@ -1247,7 +1247,7 @@ async function seedSearchQueriesForProject(project, rawQueries) {
1247
1247
  // ---- engagement_mode: choose personal-brand vs product (setup-time) --------
1248
1248
  // Part of onboarding: AFTER X connect + profile_scan, BEFORE product config, the
1249
1249
  // agent asks the user which mode they want and calls this. It persists the mode
1250
- // (scripts/saps_mode.py, the single source of truth the cycle reads) and
1250
+ // (scripts/s4l_mode.py, the single source of truth the cycle reads) and
1251
1251
  // provisions the persona project (grounded in the profile scan), then the agent
1252
1252
  // continues to product setup — a product is always configured regardless of mode.
1253
1253
  tool("engagement_mode", {
@@ -1301,7 +1301,7 @@ tool("engagement_mode", {
1301
1301
  .optional()
1302
1302
  .describe("The RAW voice-memo transcript from the onboarding dictation interview, VERBATIM (do NOT " +
1303
1303
  "paraphrase or summarize). Persisted to the persona_corpus.txt sidecar (never config.json), " +
1304
- "capped ~8000 chars. This is the grounding pool the drafter quotes real specifics from " +
1304
+ "capped ~100000 chars. This is the grounding pool the drafter quotes real specifics from " +
1305
1305
  "(actual projects, numbers, opinions, phrasing), so keep it dense and first-hand."),
1306
1306
  voice: z
1307
1307
  .any()
@@ -1323,7 +1323,7 @@ tool("engagement_mode", {
1323
1323
  }, async (args) => {
1324
1324
  const action = args.action || "get";
1325
1325
  const readFlags = async () => {
1326
- const cur = await runPython("scripts/saps_mode.py", ["flags"], { timeoutMs: 15_000 });
1326
+ const cur = await runPython("scripts/s4l_mode.py", ["flags"], { timeoutMs: 15_000 });
1327
1327
  try {
1328
1328
  const f = JSON.parse((cur.stdout || "").trim());
1329
1329
  return { personal_brand: !!f.personal_brand, promotion: !!f.promotion };
@@ -1339,11 +1339,11 @@ tool("engagement_mode", {
1339
1339
  return jsonContent({ flags, mode, persona: persona ? persona.name : null });
1340
1340
  }
1341
1341
  // Lightweight flip of ONE lane (the dashboard/menu-bar quick toggle): just
1342
- // rewrite mode.json via saps_mode.py — NO persona provisioning. Mirrors the
1342
+ // rewrite mode.json via s4l_mode.py — NO persona provisioning. Mirrors the
1343
1343
  // menu bar's pure-local _toggle_lane so flipping from either surface is cheap.
1344
1344
  if (action === "toggle") {
1345
1345
  const lane = args.lane === "promotion" ? "promotion" : "personal_brand";
1346
- const res = await runPython("scripts/saps_mode.py", ["toggle", lane], { timeoutMs: 15_000 });
1346
+ const res = await runPython("scripts/s4l_mode.py", ["toggle", lane], { timeoutMs: 15_000 });
1347
1347
  if (res.code !== 0) {
1348
1348
  const tail = (res.stderr || res.stdout).trim().split("\n").slice(-1)[0] || "unknown error";
1349
1349
  return textContent(`Could not switch lane: ${tail}`);
@@ -1373,12 +1373,18 @@ tool("engagement_mode", {
1373
1373
  }
1374
1374
  const mode = personalBrand ? "personal_brand" : "promotion";
1375
1375
  recordOnboardingAttempt("mode_chosen", { personal_brand: personalBrand, promotion });
1376
- const setRes = await runPython("scripts/saps_mode.py", ["set-flags", personalBrand ? "1" : "0", promotion ? "1" : "0"], { timeoutMs: 15_000 });
1376
+ const setRes = await runPython("scripts/s4l_mode.py", ["set-flags", personalBrand ? "1" : "0", promotion ? "1" : "0"], { timeoutMs: 15_000 });
1377
1377
  if (setRes.code !== 0) {
1378
1378
  const tail = (setRes.stderr || setRes.stdout).trim().split("\n").slice(-1)[0] || "unknown error";
1379
1379
  blockOnboardingMilestone("mode_chosen", "mode_set_failed", tail, { personal_brand: personalBrand, promotion });
1380
1380
  return textContent(`Couldn't save the engagement lanes: ${tail}`);
1381
1381
  }
1382
+ // NOTE (2026-07-06): the draft-only flag (mode.json {"draft_only": false} —
1383
+ // promotion cycles POST autonomously instead of drafting cards) is
1384
+ // DELIBERATELY NOT exposed on this tool or any user surface. It is an
1385
+ // operator-only switch, set via `scripts/s4l_mode.py draft-only on|off`.
1386
+ // run-draft-and-publish.sh reads it per cycle. Do not add a tool param,
1387
+ // menubar toggle, or onboarding step for it.
1382
1388
  // Provision the persona (grounded from the scan when supplied) regardless of
1383
1389
  // mode, so the toggle always has a real persona to flip to.
1384
1390
  let personaName;
@@ -1794,70 +1800,52 @@ tool("project_config", {
1794
1800
  }
1795
1801
  // Status / discovery mode: no project name supplied, or explicitly asked.
1796
1802
  if (args.status === true || !args.name) {
1797
- const projects = listManagedProjectStatus();
1798
- const rtReady = runtimeReady();
1799
- // On a bare .mcpb install the runtime step also materializes the pipeline
1800
- // source that xStatus shells into. Status must still work before that first
1801
- // install, otherwise the agent cannot discover that installation is the
1802
- // next milestone. Avoid probing Python until the owned runtime is ready.
1803
- const x = rtReady
1804
- ? await xStatus().catch(() => ({ connected: false, state: "status_unavailable" }))
1805
- : { connected: false, state: "runtime_not_ready" };
1806
- await ensureDoctorPhase(x.connected ? "full" : "pre_connect");
1807
- const ver = await versionStatus();
1808
- const configured = projects.some((p) => p.ready);
1809
- if (rtReady)
1810
- completeOnboardingMilestone("runtime_ready");
1811
- if (x.connected) {
1812
- completeOnboardingMilestone("x_connected", { state: x.state || "connected" });
1813
- }
1814
- if (configured) {
1815
- completeOnboardingMilestone("project_ready", {
1816
- missing_count: 0,
1817
- });
1818
- }
1819
- // mode_chosen completes when the user explicitly picked a mode (mode.json
1820
- // exists) OR this is a legacy install already past setup (a ready product),
1821
- // so adding this step never regresses an already-onboarded box.
1822
- if (modeChosen() || configured) {
1823
- completeOnboardingMilestone("mode_chosen", {
1824
- source: modeChosen() ? "chosen" : "backfilled_legacy",
1825
- });
1826
- }
1803
+ // ONE compute path: buildSnapshot -> scripts/snapshot.py, the same source
1804
+ // the menu bar and browser dashboard read. This branch used to recompute
1805
+ // projects/X/version inline (listManagedProjectStatus + xStatus), which
1806
+ // excluded the persona project and carried no setup_complete — so the
1807
+ // panel's refresh() disagreed with the dashboard tool and the menu bar on
1808
+ // persona-only setups. Do not reintroduce a parallel compute here.
1809
+ const snap = await buildSnapshot();
1810
+ const projects = Array.isArray(snap.projects) ? snap.projects : [];
1811
+ const rtReady = !!snap.runtime_ready;
1812
+ const xConnected = !!snap.x_connected;
1813
+ const configured = (snap.projects_ready || 0) > 0;
1827
1814
  return jsonContent({
1828
1815
  configured,
1829
1816
  projects,
1830
1817
  runtime_ready: rtReady,
1831
- x_connected: x.connected,
1832
- x_state: x.state,
1833
- x_handle: x.handle ?? null,
1834
- mcp_version: ver.installed,
1835
- latest_version: ver.latest,
1836
- update_available: ver.update_available,
1837
- mode: currentMode(),
1838
- flags: currentFlags(),
1839
- update_hint: ver.update_available
1840
- ? `A newer version (${ver.latest}) is available — you're on ${ver.installed}. ` +
1818
+ x_connected: xConnected,
1819
+ x_state: snap.x_state,
1820
+ x_handle: snap.x_handle ?? null,
1821
+ setup_complete: !!snap.setup_complete,
1822
+ mcp_version: snap.version,
1823
+ latest_version: snap.latest_version,
1824
+ update_available: !!snap.update_available,
1825
+ mode: snap.mode,
1826
+ flags: snap.flags,
1827
+ update_hint: snap.update_available
1828
+ ? `A newer version (${snap.latest_version}) is available — you're on ${snap.version}. ` +
1841
1829
  `Tell the user and offer to run the \`runtime\` tool with action:'update' ` +
1842
1830
  `(or \`npx social-autoposter@latest update\`).`
1843
1831
  : undefined,
1844
1832
  required_fields: REQUIRED_FIELDS,
1845
1833
  recommended_fields: RECOMMENDED_FIELDS,
1846
1834
  config_path: configPath(),
1847
- ready_for_verification: rtReady && configured && x.connected,
1848
- onboarding: onboardingSnapshot(),
1835
+ ready_for_verification: !!snap.setup_complete,
1836
+ onboarding: snap.onboarding,
1849
1837
  next_step: !rtReady
1850
1838
  ? "Runtime is not ready yet. It provisions automatically on boot — poll runtime action:'status' until ready (only call runtime action:'install' to retry if status shows the boot provision failed or stalled), then continue setup automatically."
1851
1839
  : projects.length === 0
1852
1840
  ? "No projects yet. Discover the product from conversation context and the connected X profile; research its website, infer a conservative complete project, and call project_config. Ask only if no product can be identified." +
1853
- (x.connected ? "" : " X is not connected yet either — detect_x_sources, warn about keychain prompts, then run connect_x with confirm:true without a separate permission turn.")
1841
+ (xConnected ? "" : " X is not connected yet either — detect_x_sources, warn about keychain prompts, then run connect_x with confirm:true without a separate permission turn.")
1854
1842
  : projects.every((p) => p.ready)
1855
- ? (x.connected
1843
+ ? (xConnected
1856
1844
  ? "All configured projects are ready and X is connected. SCHEDULE THE AUTOPILOT: (1) call queue_setup and create each returned task with create_scheduled_task (prompt verbatim; 'already exists' is fine); (2) the autopilot then runs on its own (launchd kicker + queue worker). Call the `dashboard` tool to confirm the schedule is firing (schedule_state 'ok') — that is the terminal step; do NOT wait for or verify a draft card. Do NOT pause to ask the user to review drafts."
1857
1845
  : "All configured projects are ready, but X is NOT connected — posting needs a logged-in " +
1858
1846
  "x.com session. Detect sources and run project_config action:'connect_x', confirm:true; do not ask whether to proceed.")
1859
1847
  : "Some projects are missing required fields (see each project's missing_required). Derive them from config, context, profile_scan, and website research, then call project_config again. Ask only if a required field is genuinely unknowable." +
1860
- (x.connected ? "" : " X is also not connected yet; detect sources and run connect_x with confirm:true."),
1848
+ (xConnected ? "" : " X is also not connected yet; detect sources and run connect_x with confirm:true."),
1861
1849
  });
1862
1850
  }
1863
1851
  // Apply mode (incremental): merge whatever fields were supplied onto the
@@ -2964,22 +2952,38 @@ function ensureCoworkMcpRegistered() {
2964
2952
  console.error(`[cowork-mcp] ensureCoworkMcpRegistered error: ${e?.message || e}`);
2965
2953
  }
2966
2954
  }
2967
- // ---- launchd kicker: run the REAL pipeline in DRAFT_ONLY + queue mode --------
2955
+ // ---- launchd kicker: run the REAL pipeline in queue mode ---------------------
2968
2956
  // Reinstates com.m13v.social-twitter-cycle as the customer-box kicker. It runs
2969
- // run-twitter-cycle.sh straight through (scan -> score -> draft -> link-gen) but
2970
- // STOPS before posting (DRAFT_ONLY=1), writing the plan to the review-queue the
2971
- // approval cards read. Its `claude -p` steps route through the job queue
2972
- // (S4L_CLAUDE_PROVIDER=queue) for the scheduled-task workers to service.
2973
- // link_tail is skipped for now (TWITTER_TAIL_LINK_RATE=0); the short link is
2974
- // still baked by twitter_gen_links.py (pure Python).
2975
- const QUEUE_KICKER_INTERVAL_SECS = 300; // a fresh draft cycle every 5 min
2957
+ // run-twitter-cycle.sh straight through (scan -> score -> draft -> link-gen); its
2958
+ // `claude -p` steps route through the job queue (S4L_CLAUDE_PROVIDER=queue) for
2959
+ // the scheduled-task workers to service. The per-cycle DRAFT_ONLY value is NOT
2960
+ // baked here anymore: run-draft-and-publish.sh decides it from mode.json
2961
+ // (draft-only flag, 2026-07-06) — while draft-only is ON (default) cycles stop
2962
+ // before posting and merge into review cards; with draft-only OFF (operator
2963
+ // opt-out) promotion cycles post autonomously behind the virality bar. The DRAFT_ONLY=1 below is only the safe baseline an OLD wrapper (which
2964
+ // never overrides it) inherits.
2965
+ // 60s tick (2026-07-06): parity with the retired claude -p twitter-cycle plist.
2966
+ // The preflight slot gate (max-1) makes this safe — a tick that fires while a
2967
+ // cycle is still running skips in milliseconds — so the effective cadence is
2968
+ // back-to-back ~10-min cycles with ≤1 min idle, not one cycle per minute.
2969
+ const QUEUE_KICKER_INTERVAL_SECS = 60;
2976
2970
  function kickerEnv() {
2977
2971
  return {
2978
2972
  DRAFT_ONLY: "1",
2979
2973
  S4L_CLAUDE_PROVIDER: "queue",
2980
2974
  S4L_STATE_DIR: sapsStateDir(),
2981
- TWITTER_TAIL_LINK_RATE: "0",
2982
2975
  TWITTER_PAGE_GEN_RATE: "0",
2976
+ // Thread-media context for the drafter (2026-07-06): parity with the
2977
+ // retired CLI twitter-cycle plist. Without it the prep step drafts blind to
2978
+ // images in the candidate threads.
2979
+ S4L_TWITTER_CAPTURE_MEDIA: "1",
2980
+ // NOTE: TWITTER_TAIL_LINK_RATE is deliberately NOT set here (2026-07-06).
2981
+ // The persona lane exports =0 per cycle via s4l_mode.py env (link-free
2982
+ // organic replies); promotion cycles keep the script's own default so
2983
+ // draft-only-OFF posts carry the project link per the A/B gate, and card posts
2984
+ // still force =1.0 inside post_drafts.
2985
+ // Virality bar percentile is NOT set here: it is hardcoded to 0.90 in
2986
+ // skill/run-twitter-cycle.sh (single source of truth, no env dependency).
2983
2987
  };
2984
2988
  }
2985
2989
  async function ensureQueueKickerInstalled() {
@@ -3428,6 +3432,29 @@ async function buildSnapshot() {
3428
3432
  completeOnboardingMilestone("project_ready", { missing_count: 0 });
3429
3433
  if (snap.schedule_state === "ok")
3430
3434
  completeOnboardingMilestone("tasks_scheduled");
3435
+ // mode_chosen completes when the user explicitly picked a mode (mode.json
3436
+ // exists) OR this is a legacy install already past setup (a ready project),
3437
+ // so adding this step never regresses an already-onboarded box. (Moved here
3438
+ // from project_config's status branch so ALL milestone truth lives in one
3439
+ // place next to the snapshot.)
3440
+ if (modeChosen() || (snap.projects_ready || 0) > 0) {
3441
+ completeOnboardingMilestone("mode_chosen", {
3442
+ source: modeChosen() ? "chosen" : "backfilled_legacy",
3443
+ });
3444
+ }
3445
+ // profile_scanned has no file signal of its own, but a provisioned persona
3446
+ // project can only come from the scan+dictation flow — treat its presence as
3447
+ // the completed scan so old installs can't stick at "profile pending".
3448
+ if ((Array.isArray(snap.projects) ? snap.projects : []).some((p) => p?.persona)) {
3449
+ completeOnboardingMilestone("profile_scanned", { backfill: true });
3450
+ }
3451
+ // topics_seeded has no cheap live signal (it's a DB fact, not a file fact), so
3452
+ // installs that finished setup before the persona-path completion landed stay
3453
+ // stuck at 7/8 forever. Heal by re-running the idempotent seed for the ready
3454
+ // project(s) and completing the milestone on success. Fire-and-forget: the
3455
+ // ledger flip shows on the next snapshot.
3456
+ if ((snap.projects_ready || 0) > 0)
3457
+ void healTopicsSeeded(snap);
3431
3458
  // Persist this snapshot so the menu bar can answer "set up?" the SAME way when
3432
3459
  // the loopback server is unreachable (Claude Desktop closed or mid-restart)
3433
3460
  // instead of falling back to a divergent local rule. Refreshed on every
@@ -3436,6 +3463,37 @@ async function buildSnapshot() {
3436
3463
  persistStatusSummary(snap);
3437
3464
  return snap;
3438
3465
  }
3466
+ // One attempt per process: a failed seed (offline DB) retries on the next MCP
3467
+ // launch, not on every dashboard poll.
3468
+ let topicsSeedHealAttempted = false;
3469
+ async function healTopicsSeeded(snap) {
3470
+ if (topicsSeedHealAttempted)
3471
+ return;
3472
+ topicsSeedHealAttempted = true;
3473
+ try {
3474
+ const ledger = onboardingLedger();
3475
+ if (ledger?.milestones?.topics_seeded?.status === "complete")
3476
+ return;
3477
+ const ready = (Array.isArray(snap.projects) ? snap.projects : []).filter((p) => p && p.ready && typeof p.name === "string");
3478
+ for (const p of ready) {
3479
+ const seed = await runPython("scripts/seed_search_topics.py", ["--project", p.name], {
3480
+ timeoutMs: 60_000,
3481
+ });
3482
+ if (seed.code === 0) {
3483
+ const m = /planned=(\d+)\s+inserted=(\d+)\s+updated=(\d+)/.exec(seed.stdout);
3484
+ completeOnboardingMilestone("topics_seeded", {
3485
+ project: p.name,
3486
+ topic_count: m ? Number(m[1]) : 0,
3487
+ backfill: true,
3488
+ });
3489
+ return;
3490
+ }
3491
+ }
3492
+ }
3493
+ catch (e) {
3494
+ console.error("[snapshot] topics_seeded heal failed:", e?.message || e);
3495
+ }
3496
+ }
3439
3497
  // ---- dashboard localhost fallback -----------------------------------------
3440
3498
  // When the connected host doesn't support MCP Apps UI (Claude Code / Cowork
3441
3499
  // today), serve the SAME dist/panel.html from a loopback HTTP server. The page
@@ -3580,7 +3638,7 @@ function sapsStateDir() {
3580
3638
  }
3581
3639
  // Has the user explicitly chosen an engagement mode? mode.json is written by the
3582
3640
  // engagement_mode tool (setup) and the menu-bar toggle. Used to complete the
3583
- // mode_chosen onboarding milestone. (Source of truth: scripts/saps_mode.py.)
3641
+ // mode_chosen onboarding milestone. (Source of truth: scripts/s4l_mode.py.)
3584
3642
  function modeChosen() {
3585
3643
  try {
3586
3644
  return fs.existsSync(path.join(sapsStateDir(), "mode.json"));
@@ -3590,8 +3648,8 @@ function modeChosen() {
3590
3648
  }
3591
3649
  }
3592
3650
  // The current engagement lane flags, surfaced in the snapshot so the dashboard
3593
- // AND menu bar read them from ONE place (mode.json, the same file saps_mode.py
3594
- // writes). Mirrors saps_mode.py get_flags(): explicit flag keys win; else map a
3651
+ // AND menu bar read them from ONE place (mode.json, the same file s4l_mode.py
3652
+ // writes). Mirrors s4l_mode.py get_flags(): explicit flag keys win; else map a
3595
3653
  // legacy {"mode": ...} string; else default personal ON / promotion OFF.
3596
3654
  function currentFlags() {
3597
3655
  try {