@m13v/s4l 1.7.4-rc.4 → 1.7.4

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 (86) hide show
  1. package/README.md +134 -101
  2. package/bin/cli.js +23 -0
  3. package/mcp/dist/index.js +24 -5
  4. package/mcp/dist/runtime.js +110 -0
  5. package/mcp/dist/version.js +163 -54
  6. package/mcp/dist/version.json +2 -2
  7. package/mcp/manifest.json +1 -1
  8. package/mcp/menubar/s4l_card.py +55 -19
  9. package/mcp/menubar/s4l_menubar.py +179 -56
  10. package/mcp/menubar/s4l_state.py +41 -1
  11. package/mcp/package.json +1 -1
  12. package/package.json +4 -2
  13. package/scripts/active_experiments.py +30 -6
  14. package/scripts/autopilot_stall_watch.py +185 -63
  15. package/scripts/cdp_ready_check.py +94 -0
  16. package/scripts/engage_twitter_helper.py +83 -17
  17. package/scripts/engagement_styles.py +221 -26
  18. package/scripts/enrich_reply_parents.py +68 -22
  19. package/scripts/feedback_digest.py +24 -1
  20. package/scripts/generate_daily_human_style.py +31 -7
  21. package/scripts/invent_styles.py +103 -26
  22. package/scripts/linkedin_cadence.py +234 -0
  23. package/scripts/linkedin_killswitch.py +43 -0
  24. package/scripts/memory_snapshot.py +57 -0
  25. package/scripts/patches/browser-harness-loopback-cdp-proxy.patch +118 -0
  26. package/scripts/recent_self_posts.py +6 -0
  27. package/scripts/reddit_browser.py +57 -3
  28. package/scripts/s4l_box_update.sh +20 -1
  29. package/scripts/s4l_mode.py +3 -4
  30. package/scripts/scan_x_profile.py +25 -7
  31. package/scripts/schedule_state.py +23 -0
  32. package/scripts/scheduled_task_selfheal.py +213 -16
  33. package/scripts/setup_twitter_auth.py +6 -1
  34. package/scripts/snapshot.py +179 -61
  35. package/scripts/stall_guard.py +70 -0
  36. package/scripts/test_scheduled_task_selfheal.py +121 -0
  37. package/scripts/top_performers.py +34 -0
  38. package/scripts/twitter_browser.py +146 -8
  39. package/scripts/twitter_post_plan.py +10 -1
  40. package/scripts/twitter_scan.py +26 -0
  41. package/scripts/watchdog_hung_runs.py +117 -3
  42. package/skill/amplitude-24h-signups.sh +1 -1
  43. package/skill/audit-dm-staleness.sh +1 -1
  44. package/skill/audit-linkedin.sh +1 -1
  45. package/skill/audit-reddit-resurrect.sh +1 -1
  46. package/skill/audit.sh +5 -5
  47. package/skill/check-web-chats.sh +1 -1
  48. package/skill/dm-outreach-linkedin.sh +4 -4
  49. package/skill/dm-outreach-reddit.sh +1 -1
  50. package/skill/dm-outreach-twitter.sh +1 -1
  51. package/skill/engage-dm-replies.sh +16 -3
  52. package/skill/engage-linkedin.sh +2 -2
  53. package/skill/engage-moltbook.sh +1 -1
  54. package/skill/engage-twitter.sh +4 -4
  55. package/skill/github-engage.sh +1 -1
  56. package/skill/lib/browser-launch.sh +54 -0
  57. package/skill/lib/linkedin-backend.sh +22 -15
  58. package/skill/lib/reddit-backend.sh +17 -11
  59. package/skill/lib/twitter-backend.sh +137 -16
  60. package/skill/link-edit-github.sh +1 -1
  61. package/skill/link-edit-moltbook.sh +1 -1
  62. package/skill/link-edit-reddit.sh +1 -1
  63. package/skill/linkedin-cadence.sh +20 -0
  64. package/skill/linkedin-presence.sh +1 -1
  65. package/skill/lock.sh +30 -6
  66. package/skill/precompute-stats.sh +1 -1
  67. package/skill/refresh-instagram-tokens.sh +1 -1
  68. package/skill/refresh-twitter-following.sh +1 -1
  69. package/skill/run-generate-daily-style.sh +1 -1
  70. package/skill/run-instagram-daily.sh +1 -1
  71. package/skill/run-instagram-render.sh +1 -1
  72. package/skill/run-linkedin-launchd.sh +1 -1
  73. package/skill/run-linkedin-unipile.sh +1 -1
  74. package/skill/run-linkedin.sh +1 -1
  75. package/skill/run-reddit-search.sh +2 -2
  76. package/skill/run-reddit-threads.sh +1 -1
  77. package/skill/run-twitter-cycle.sh +190 -55
  78. package/skill/scan-instagram-replies.sh +1 -1
  79. package/skill/sentry-digest.sh +1 -1
  80. package/skill/stats-instagram.sh +1 -1
  81. package/skill/stats-linkedin.sh +2 -2
  82. package/skill/stats.sh +1 -1
  83. package/skill/sweep-link-clicks.sh +1 -1
  84. package/scripts/_lock_preempt_test.py +0 -60
  85. package/scripts/saps_activity.py +0 -18
  86. package/scripts/saps_mode.py +0 -18
@@ -94,6 +94,67 @@ export function releaseChannel() {
94
94
  }
95
95
  return "stable";
96
96
  }
97
+ const SHARED_TTL_S = 600; // banner latency ceiling: a new release surfaces within 10 min
98
+ const PROBE_LOCK_STALE_MS = 30_000;
99
+ function sharedCachePath() {
100
+ return path.join(STATE_DIR, "latest-release.json");
101
+ }
102
+ function readSharedCache(channel) {
103
+ try {
104
+ const d = JSON.parse(fs.readFileSync(sharedCachePath(), "utf-8"));
105
+ if (!d || d.channel !== channel || typeof d.at !== "number")
106
+ return null;
107
+ return {
108
+ at: d.at,
109
+ channel,
110
+ version: typeof d.version === "string" ? d.version : null,
111
+ tag: typeof d.tag === "string" ? d.tag : null,
112
+ etag: typeof d.etag === "string" ? d.etag : null,
113
+ };
114
+ }
115
+ catch {
116
+ return null;
117
+ }
118
+ }
119
+ function writeSharedCache(c) {
120
+ try {
121
+ fs.mkdirSync(STATE_DIR, { recursive: true });
122
+ const tmp = `${sharedCachePath()}.tmp.${process.pid}`;
123
+ fs.writeFileSync(tmp, JSON.stringify(c));
124
+ fs.renameSync(tmp, sharedCachePath());
125
+ }
126
+ catch {
127
+ /* best effort; worst case another process re-probes */
128
+ }
129
+ }
130
+ // Single-flight guard so N concurrent processes with an expired shared cache
131
+ // don't all probe at once. acquired=false ONLY when another probe holds a
132
+ // FRESH lock; any other failure probes anyway rather than going blind.
133
+ function tryProbeLock() {
134
+ const lock = path.join(STATE_DIR, "latest-release.lock");
135
+ const create = () => fs.closeSync(fs.openSync(lock, "wx"));
136
+ try {
137
+ fs.mkdirSync(STATE_DIR, { recursive: true });
138
+ create();
139
+ return { acquired: true, lock };
140
+ }
141
+ catch (e) {
142
+ if (e.code === "EEXIST") {
143
+ try {
144
+ if (Date.now() - fs.statSync(lock).mtimeMs > PROBE_LOCK_STALE_MS) {
145
+ fs.unlinkSync(lock); // stale: holder died mid-probe
146
+ create();
147
+ return { acquired: true, lock };
148
+ }
149
+ }
150
+ catch {
151
+ /* raced with the holder */
152
+ }
153
+ return { acquired: false, lock: null };
154
+ }
155
+ return { acquired: true, lock: null };
156
+ }
157
+ }
97
158
  function parseSemverish(v) {
98
159
  return /^\d+\.\d+\.\d+/.test(v) ? v : null;
99
160
  }
@@ -128,41 +189,45 @@ async function latestFromGithubRedirect() {
128
189
  return null;
129
190
  }
130
191
  }
131
- // In-process conditional-request state: long-lived processes send If-None-Match
132
- // on every probe and get free 304s; each new release costs a single 200.
133
- const apiState = { etag: null, latest: null };
134
- async function latestFromGithubApi() {
192
+ // Conditional-request state lives in the SHARED cache file (latest-release.json)
193
+ // so the ETag survives process boundaries: short-lived MCP respawns used to pay
194
+ // a full 200 per process; now every probe sends If-None-Match and gets a free
195
+ // 304 between releases.
196
+ async function curlConditional(url, etag) {
197
+ const args = ["-sS", "-m", "10", "-H", "Accept: application/vnd.github+json"];
198
+ if (etag)
199
+ args.push("-H", `If-None-Match: ${etag}`);
200
+ args.push("-w", "\n__CURL_STATUS__:%{http_code}\n__CURL_ETAG__:%header{etag}", url);
201
+ const res = await run("curl", args, { timeoutMs: 12000, noTee: true });
202
+ let status = 0;
203
+ let newEtag = null;
204
+ const body = [];
205
+ for (const line of (res.stdout || "").split("\n")) {
206
+ if (line.startsWith("__CURL_STATUS__:"))
207
+ status = parseInt(line.slice(16).trim(), 10) || 0;
208
+ else if (line.startsWith("__CURL_ETAG__:"))
209
+ newEtag = line.slice(14).trim() || null;
210
+ else
211
+ body.push(line);
212
+ }
213
+ return { status, etag: newEtag, body: body.join("\n") };
214
+ }
215
+ // Stable probe (releases/latest). On 304 serves the caller-supplied cached
216
+ // version with the same etag; If-None-Match is only sent when there IS a
217
+ // cached value to serve.
218
+ async function latestFromGithubApi(etag = null, cached = null) {
135
219
  try {
136
- const args = ["-sS", "-m", "10", "-H", "Accept: application/vnd.github+json"];
137
- if (apiState.etag)
138
- args.push("-H", `If-None-Match: ${apiState.etag}`);
139
- args.push("-w", "\n__CURL_STATUS__:%{http_code}\n__CURL_ETAG__:%header{etag}", RELEASES_LATEST_API);
140
- const res = await run("curl", args, { timeoutMs: 12000, noTee: true });
141
- let status = 0;
142
- let etag = null;
143
- const body = [];
144
- for (const line of (res.stdout || "").split("\n")) {
145
- if (line.startsWith("__CURL_STATUS__:"))
146
- status = parseInt(line.slice(16).trim(), 10) || 0;
147
- else if (line.startsWith("__CURL_ETAG__:"))
148
- etag = line.slice(14).trim() || null;
149
- else
150
- body.push(line);
151
- }
152
- if (status === 304)
153
- return apiState.latest;
154
- if (status !== 200)
155
- return null;
156
- const tag = JSON.parse(body.join("\n")).tag_name;
220
+ const r = await curlConditional(RELEASES_LATEST_API, cached ? etag : null);
221
+ if (r.status === 304)
222
+ return { version: cached, etag };
223
+ if (r.status !== 200)
224
+ return { version: null, etag: null };
225
+ const tag = JSON.parse(r.body).tag_name;
157
226
  const v = typeof tag === "string" ? parseSemverish(tag.replace(/^v/, "").trim()) : null;
158
- if (v) {
159
- apiState.etag = etag;
160
- apiState.latest = v;
161
- }
162
- return v;
227
+ return v ? { version: v, etag: r.etag } : { version: null, etag: null };
163
228
  }
164
229
  catch {
165
- return null;
230
+ return { version: null, etag: null };
166
231
  }
167
232
  }
168
233
  async function latestFromNpm() {
@@ -177,18 +242,24 @@ async function latestFromNpm() {
177
242
  }
178
243
  // Staging channel: newest release OVERALL (prereleases included) from the
179
244
  // releases LIST, since releases/latest excludes prereleases. Drafts are skipped;
180
- // "newest" is by the rc-aware verKey. Returns {version, tag} or null.
181
- async function latestFromGithubListStaging() {
245
+ // "newest" is by the rc-aware verKey. On 304 serves the caller-supplied cached
246
+ // {version, tag} with the same etag. Returns {version, tag, etag} or null.
247
+ async function latestFromGithubListStaging(etag = null, cached = null) {
182
248
  try {
183
- const res = await run("curl", ["-sS", "-m", "10", "-H", "Accept: application/vnd.github+json", RELEASES_LIST_API], { timeoutMs: 12000, noTee: true });
184
- const rels = JSON.parse(res.stdout || "[]");
249
+ const haveCached = !!(cached && cached.version && cached.tag);
250
+ const r = await curlConditional(RELEASES_LIST_API, haveCached ? etag : null);
251
+ if (r.status === 304 && haveCached)
252
+ return { version: cached.version, tag: cached.tag, etag };
253
+ if (r.status !== 200)
254
+ return null;
255
+ const rels = JSON.parse(r.body || "[]");
185
256
  if (!Array.isArray(rels))
186
257
  return null;
187
258
  let best = null;
188
- for (const r of rels) {
189
- if (!r || typeof r !== "object" || r.draft)
259
+ for (const rel of rels) {
260
+ if (!rel || typeof rel !== "object" || rel.draft)
190
261
  continue;
191
- const tag = r.tag_name;
262
+ const tag = rel.tag_name;
192
263
  if (typeof tag !== "string")
193
264
  continue;
194
265
  const v = parseSemverish(tag.replace(/^v/, "").trim());
@@ -198,7 +269,7 @@ async function latestFromGithubListStaging() {
198
269
  if (best == null || cmpKey(key, best.key) > 0)
199
270
  best = { version: v, tag, key };
200
271
  }
201
- return best ? { version: best.version, tag: best.tag } : null;
272
+ return best ? { version: best.version, tag: best.tag, etag: r.etag } : null;
202
273
  }
203
274
  catch {
204
275
  return null;
@@ -221,28 +292,66 @@ async function resolveLatest() {
221
292
  const now = Date.now();
222
293
  if (cache && cache.channel === channel && now - cache.at < TTL_MS)
223
294
  return { version: cache.latest, tag: cache.tag, channel };
295
+ // Level 2: shared cross-process cache file (see SharedCache above).
296
+ const shared = readSharedCache(channel);
297
+ if (shared && now / 1000 - shared.at < SHARED_TTL_S) {
298
+ cache = { at: now, latest: shared.version, tag: shared.tag, channel };
299
+ return { version: shared.version, tag: shared.tag, channel };
300
+ }
301
+ const { acquired, lock } = tryProbeLock();
302
+ if (!acquired) {
303
+ // Another process is probing right now; serve the stale shared value (or
304
+ // null) instead of doubling the request. The short in-process TTL means we
305
+ // pick up its fresh result within a minute.
306
+ const v = shared?.version ?? null;
307
+ const t = shared?.tag ?? null;
308
+ cache = { at: now, latest: v, tag: t, channel };
309
+ return { version: v, tag: t, channel };
310
+ }
224
311
  let version = null;
225
312
  let tag = null;
226
- if (channel === "staging") {
227
- const s = await latestFromGithubListStaging();
228
- if (s) {
229
- version = s.version;
230
- tag = s.tag;
313
+ let etag = null;
314
+ try {
315
+ if (channel === "staging") {
316
+ const s = await latestFromGithubListStaging(shared?.etag ?? null, shared ? { version: shared.version, tag: shared.tag } : null);
317
+ if (s) {
318
+ version = s.version;
319
+ tag = s.tag;
320
+ etag = s.etag;
321
+ }
322
+ else {
323
+ // Degrade to the stable probes so a staging box tracks at least stable
324
+ // rather than going blind when the list endpoint fails. etag stays
325
+ // null: a releases/latest etag must never be replayed against the
326
+ // LIST endpoint on the next staging probe.
327
+ version =
328
+ (await latestFromGithubApi()).version ?? (await latestFromGithubRedirect());
329
+ tag = version ? `v${version}` : null;
330
+ }
231
331
  }
232
332
  else {
233
- // Degrade to the stable probes so a staging box tracks at least stable
234
- // rather than going blind when the list endpoint fails.
235
- version = (await latestFromGithubApi()) ?? (await latestFromGithubRedirect());
333
+ const r = await latestFromGithubApi(shared?.etag ?? null, shared?.version ?? null);
334
+ version = r.version;
335
+ etag = r.etag;
336
+ if (version == null) {
337
+ version = await latestFromGithubRedirect();
338
+ etag = null;
339
+ }
340
+ if (version == null)
341
+ version = await latestFromNpm();
236
342
  tag = version ? `v${version}` : null;
237
343
  }
344
+ writeSharedCache({ at: now / 1000, channel, version, tag, etag });
238
345
  }
239
- else {
240
- version = await latestFromGithubApi();
241
- if (version == null)
242
- version = await latestFromGithubRedirect();
243
- if (version == null)
244
- version = await latestFromNpm();
245
- tag = version ? `v${version}` : null;
346
+ finally {
347
+ if (lock) {
348
+ try {
349
+ fs.unlinkSync(lock);
350
+ }
351
+ catch {
352
+ /* already gone */
353
+ }
354
+ }
246
355
  }
247
356
  cache = { at: now, latest: version, tag, channel };
248
357
  return { version, tag, channel };
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "1.7.4-rc.4",
3
- "installedAt": "2026-07-11T00:05:38.650Z"
2
+ "version": "1.7.4",
3
+ "installedAt": "2026-07-14T01:43:29.220Z"
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.4-rc.4",
5
+ "version": "1.7.4",
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": {
@@ -46,10 +46,11 @@ menu bar registers at boot via `set_feedback_handler` (it ships
46
46
  decision='feedback' review events).
47
47
 
48
48
  Rejecting is a two-step flow: the Reject button swaps the card body for a
49
- reason picker (three one-tap categories plus Other, with an optional free-text
50
- note). The category feeds the feedback-digest loop that distills human
51
- rejections into each project's learned_preferences config block; "Skip" keeps
52
- the old zero-friction reject (no reason recorded). Link clicks (author profile,
49
+ reason picker (three one-tap categories, all optional, plus an optional
50
+ free-text note). The category feeds the feedback-digest loop that distills
51
+ human rejections into each project's learned_preferences config block; the
52
+ picker's red Reject button commits with no category (the note, if typed, is
53
+ still sent). Link clicks (author profile,
53
54
  thread ↗) and per-card dwell time ride along on the decision so the digest can
54
55
  infer intent (e.g. profile-checked-then-rejected = author-quality signal) even
55
56
  when the reason is skipped.
@@ -185,13 +186,15 @@ M = 16
185
186
  NS_BEZEL_BORDER = 2 # NSBezelBorder
186
187
 
187
188
  # Reject-reason categories, in display order. Tags are 1-based button tags on
188
- # the reason picker; values must match the review_events.reject_category CHECK
189
- # constraint server-side (wrong_author | off_topic | bad_draft | other).
189
+ # the reason picker; values must be a subset of the review_events.reject_category
190
+ # CHECK constraint server-side (wrong_author | off_topic | bad_draft | other).
191
+ # "other" is still valid server-side but no longer offered as a button: picking
192
+ # a category is optional (the red Reject button commits without one) and the
193
+ # free-text note covers everything a generic Other chip used to.
190
194
  REJECT_REASONS = (
191
195
  ("wrong_author", "Wrong author / audience"),
192
196
  ("off_topic", "Off-topic thread"),
193
- ("bad_draft", "Draft doesn't sound right"),
194
- ("other", "Other"),
197
+ ("bad_draft", "Feels like AI writing"),
195
198
  )
196
199
 
197
200
  # Client-side cap on tracked interactions per card (server clips at 50 too).
@@ -431,7 +434,21 @@ def _details_lines(d):
431
434
  d = d or {}
432
435
  lines = []
433
436
  style = (d.get("engagement_style") or "").strip()
434
- if style:
437
+ # Two-draft cards (2026-07-11): name each slot's style so the reviewer
438
+ # can tell WHICH style produced the draft they are picking (Draft B is
439
+ # the exploration slot; its source arm renders separately below via the
440
+ # generic experiments lines). Single-draft cards keep the old one-liner.
441
+ _dual = d.get("drafts")
442
+ if isinstance(_dual, list) and len(_dual) == 2:
443
+ _slot_labels = {"a": "Draft A", "b": "Draft B"}
444
+ for _draft in _dual:
445
+ _s = (_draft.get("style") or "").strip()
446
+ if _s:
447
+ _label = _slot_labels.get(
448
+ (_draft.get("variant") or "").strip().lower(), "Draft"
449
+ )
450
+ lines.append(f"{_label} style: {_s}")
451
+ elif style:
435
452
  lines.append(f"Style: {style}")
436
453
  desc = (d.get("style_description") or "").strip()
437
454
  if desc:
@@ -741,6 +758,12 @@ class _ReviewController(NSObject):
741
758
  # timestamp of any hover still in progress (flushed on decision).
742
759
  self._draft_hover_ms = {0: 0, 1: 0}
743
760
  self._draft_hover_open = {}
761
+ # Slots the caret has actually been in this card (2026-07-10 follow-up):
762
+ # lets the decision distinguish "clicked into B, then came BACK to A"
763
+ # (an explicit head-to-head choice of A, per user) from "never touched
764
+ # B at all". Only the UNCHOSEN slot's membership matters at decision
765
+ # time; the selected slot is trivially visited.
766
+ self._draft_visited = set()
744
767
  # Attention anchors for the unattended-review watchdog: the stack counts
745
768
  # as "touched" on present, on any tracked interaction, and on any
746
769
  # decision. No touch past the watchdog threshold = the user is not
@@ -1070,6 +1093,7 @@ class _ReviewController(NSObject):
1070
1093
  self._selected_draft = None
1071
1094
  self._draft_hover_ms = {0: 0, 1: 0}
1072
1095
  self._draft_hover_open = {}
1096
+ self._draft_visited = set()
1073
1097
  self._reason_field = None
1074
1098
  content = NSView.alloc().initWithFrame_(NSMakeRect(0, 0, W, H))
1075
1099
 
@@ -1688,11 +1712,18 @@ class _ReviewController(NSObject):
1688
1712
  except Exception:
1689
1713
  return
1690
1714
  for slot, cand_tv in (self._draft_textviews or {}).items():
1691
- if cand_tv is tv and slot != self._selected_draft:
1715
+ if cand_tv is not tv:
1716
+ continue
1717
+ # Visited even when it's already the selected slot: membership of
1718
+ # the eventually-UNCHOSEN slot is what _record reads, and that
1719
+ # slot only ever gets the caret via a deliberate user click (the
1720
+ # auto-focus seat in _render targets the selected slot only).
1721
+ self._draft_visited.add(slot)
1722
+ if slot != self._selected_draft:
1692
1723
  self._selected_draft = slot
1693
1724
  self._textview = cand_tv
1694
1725
  self._update_draft_borders()
1695
- break
1726
+ break
1696
1727
 
1697
1728
  @objc.python_method
1698
1729
  def _update_draft_borders(self):
@@ -1779,6 +1810,10 @@ class _ReviewController(NSObject):
1779
1810
  "unchosen_style": other.get("style") or None,
1780
1811
  "hover_a_ms": int(self._draft_hover_ms.get(0, 0)),
1781
1812
  "hover_b_ms": int(self._draft_hover_ms.get(1, 0)),
1813
+ # True = the caret was in the unchosen box at some point, i.e.
1814
+ # they tried the other draft and came back: an explicit choice
1815
+ # even when the winner is the preselected default.
1816
+ "visited_other": bool((1 - sel_idx) in self._draft_visited),
1782
1817
  }
1783
1818
  else:
1784
1819
  orig = (d.get("reply_text") or "").strip()
@@ -1958,10 +1993,11 @@ class _ReviewController(NSObject):
1958
1993
  content = NSView.alloc().initWithFrame_(NSMakeRect(0, 0, W, H))
1959
1994
 
1960
1995
  # Buttons at the TOP, above the title, mirroring the card's action
1961
- # row so the cursor doesn't have to travel between steps: "Reject,
1962
- # no reason" sits right-aligned exactly where Reject was (one-click
1963
- # double-tap keeps the old zero-friction reject), Back sits at the
1964
- # left where Approve was.
1996
+ # row so the cursor doesn't have to travel between steps: "Reject"
1997
+ # sits exactly where the card's Reject was (one-click double-tap
1998
+ # keeps the old zero-friction reject) and commits with no category,
1999
+ # since picking one is optional; Back sits at the left where Approve
2000
+ # was.
1965
2001
  back = NSButton.alloc().initWithFrame_(NSMakeRect(M, H - 42, 90, 30))
1966
2002
  back.setTitle_("Back")
1967
2003
  back.setBezelStyle_(NSBezelStyleRounded)
@@ -1969,8 +2005,8 @@ class _ReviewController(NSObject):
1969
2005
  back.setAction_("rejectBack:")
1970
2006
  content.addSubview_(back)
1971
2007
 
1972
- skip = NSButton.alloc().initWithFrame_(NSMakeRect(W - M - 150, H - 42, 150, 30))
1973
- skip.setTitle_("Reject, no reason")
2008
+ skip = NSButton.alloc().initWithFrame_(NSMakeRect(W - M - 66, H - 42, 66, 30))
2009
+ skip.setTitle_("Reject")
1974
2010
  skip.setBezelStyle_(NSBezelStyleRounded)
1975
2011
  try:
1976
2012
  skip.setHasDestructiveAction_(True) # red title on macOS 12+
@@ -1981,7 +2017,7 @@ class _ReviewController(NSObject):
1981
2017
  content.addSubview_(skip)
1982
2018
 
1983
2019
  content.addSubview_(
1984
- _label(NSMakeRect(M, H - 72, W - 2 * M, 20), "Why reject this draft?", size=13, bold=True)
2020
+ _label(NSMakeRect(M, H - 72, W - 2 * M, 20), "Why reject this draft? (optional)", size=13, bold=True)
1985
2021
  )
1986
2022
  y = H - 106
1987
2023
  for i, (_, title) in enumerate(REJECT_REASONS):
@@ -2029,7 +2065,7 @@ class _ReviewController(NSObject):
2029
2065
  note.setEditable_(True)
2030
2066
  note.setFont_(NSFont.systemFontOfSize_(12))
2031
2067
  try:
2032
- note.setPlaceholderString_("Optional note (sent with whichever reason you pick)")
2068
+ note.setPlaceholderString_("Optional note (sent with or without a reason)")
2033
2069
  note.cell().setWraps_(True)
2034
2070
  except Exception:
2035
2071
  pass