@m13v/s4l 1.7.1-rc.2 → 1.7.1-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 (45) hide show
  1. package/bin/cli.js +23 -0
  2. package/mcp/dist/index.js +332 -24
  3. package/mcp/dist/onboarding.js +3 -10
  4. package/mcp/dist/panel.html +42 -31
  5. package/mcp/dist/product-link.html +1 -1
  6. package/mcp/dist/runtime.js +101 -1
  7. package/mcp/dist/telemetry.js +80 -3
  8. package/mcp/dist/version.json +2 -2
  9. package/mcp/manifest.json +9 -1
  10. package/mcp/menubar/dashboard_server.py +12 -0
  11. package/mcp/menubar/s4l_card.py +472 -96
  12. package/mcp/menubar/s4l_menubar.py +269 -26
  13. package/mcp/menubar/s4l_state.py +169 -23
  14. package/mcp/package.json +1 -1
  15. package/mcp/shared/doctor.cjs +8 -0
  16. package/mcp/shared/onboarding-ledger.cjs +1 -0
  17. package/package.json +3 -1
  18. package/scripts/active_users.py +3 -6
  19. package/scripts/autopilot_stall_watch.py +200 -16
  20. package/scripts/claude_job.py +6 -0
  21. package/scripts/feedback_digest.py +34 -6
  22. package/scripts/get-latest-staging-mcpb.sh +50 -0
  23. package/scripts/harness_overlay.py +45 -0
  24. package/scripts/identity.py +26 -0
  25. package/scripts/learned_preferences.py +241 -32
  26. package/scripts/link_tail.py +167 -91
  27. package/scripts/mark_event.py +128 -0
  28. package/scripts/memory_snapshot.py +62 -0
  29. package/scripts/pick_twitter_thread_target.py +1 -1
  30. package/scripts/release-mcpb.sh +48 -0
  31. package/scripts/reset-test-machine.sh +85 -3
  32. package/scripts/s4l_mode.py +7 -5
  33. package/scripts/scheduled_tasks_snapshot.py +107 -0
  34. package/scripts/send_gmail_report.py +76 -0
  35. package/scripts/sentry_digest.py +81 -189
  36. package/scripts/sentry_init.py +43 -15
  37. package/scripts/setup_twitter_auth.py +76 -38
  38. package/scripts/snapshot.py +20 -0
  39. package/scripts/twitter_post_plan.py +39 -13
  40. package/skill/dm-outreach-twitter.sh +19 -0
  41. package/skill/engage-twitter.sh +27 -0
  42. package/skill/run-draft-and-publish.sh +13 -12
  43. package/skill/run-linkedin.sh +9 -1
  44. package/skill/run-twitter-cycle.sh +199 -27
  45. package/skill/sentry-digest.sh +125 -12
@@ -2,18 +2,28 @@
2
2
  """Feedback digest: distill human card decisions into learned_preferences.
3
3
 
4
4
  The scheduled half of the review-events feedback loop (see
5
- scripts/learned_preferences.py for the full loop). Per run:
5
+ scripts/learned_preferences.py for the full loop). learned_preferences is a
6
+ SINGLE install-wide block (2026-07-08), not one per project: events are
7
+ still fetched and prompted per project (that's where the candidates/cards
8
+ came from), but every project's digest writes into the same shared block, on
9
+ the theory that what a reviewer is actually correcting (voice, quality bar,
10
+ author-quality signals) is a fact about them, not about one product's
11
+ audience. main() runs learned_preferences.migrate_to_global() once per tick
12
+ (idempotent) to fold any pre-existing per-project blocks into the shared one.
13
+
14
+ Per run:
6
15
 
7
16
  1. GET /api/v1/review-events?counts=true — which (project, platform) pairs
8
17
  have unprocessed events. The API scopes to this installation, so a
9
18
  customer box only ever digests its own user's decisions.
10
19
  2. For each project that exists in the local config.json: fetch the
11
- unprocessed events, build a conservative digest prompt (current block +
12
- events + approval counter-evidence), run Claude headless via
20
+ unprocessed events, build a conservative digest prompt (current shared
21
+ block + events + approval counter-evidence), run Claude headless via
13
22
  run_claude.sh (script_tag feedback-digest, cost-tracked like every other
14
23
  pipeline Claude call).
15
24
  3. Apply the returned mutation plan through
16
- learned_preferences.apply_mutations() (whitelist, flock, backup, atomic).
25
+ learned_preferences.apply_mutations() (whitelist, flock, backup, atomic)
26
+ into the shared block.
17
27
  4. PATCH the events processed (processed_batch=digest-<ts>) so they are
18
28
  never digested twice. Events are marked processed even when the plan is
19
29
  "no changes" — a considered no-op is a completed digestion, not a retry.
@@ -180,9 +190,11 @@ def build_prompt(project: dict, events: list[dict], overall_events: list[dict] |
180
190
  f"{notes}"
181
191
  )
182
192
 
183
- return f"""You maintain the learned_preferences block for the project "{project.get('name')}" in a social-posting pipeline. The block distills the user's own approve/reject decisions on draft cards into short standing preferences that steer future thread selection and drafting. It is SOFT guidance read by the drafting model, not a filter.
193
+ return f"""You maintain the SINGLE, install-wide learned_preferences block for this social-posting pipeline (shared across every configured project, not just "{project.get('name')}"). It distills the user's own approve/reject decisions on draft cards into short standing preferences that steer future thread selection and drafting across ALL projects. It is SOFT guidance read by the drafting model, not a filter.
194
+
195
+ The events below all happened to come from project "{project.get('name')}"'s cards, but since the block is shared, only propose an entry that generalizes to a human reviewer's standing taste or quality bar (voice, tone, structural habits, author-quality signals, what counts as a good vs bad reply) — NOT something that is true only because of this one product's specific audience, niche, or content angle (e.g. "prefers accounts studying for nursing boards" belongs to one product's ICP, not to every project this reviewer runs). When the evidence is really project-specific, prefer NO change over forcing it into the shared block.
184
196
 
185
- CURRENT learned_preferences:
197
+ CURRENT learned_preferences (shared by every project):
186
198
  {json.dumps({k: block[k] for k in ("audience_avoid", "audience_prefer", "thread_avoid", "draft_style_notes")}, indent=2)}
187
199
 
188
200
  CURRENT voice.never: {json.dumps(voice_never)}
@@ -203,6 +215,7 @@ Propose changes to the block. RULES, in priority order:
203
215
  5. bad_draft events feed draft_style_notes (or, ONLY for a clearly recurring phrasing complaint, voice_never_add / guardrails_do_not_add; use those sparingly, they touch curated fields).
204
216
  6. Each entry: one sentence, under 200 characters, plain language, no em dashes, no hashtags, understandable a month from now without these events.
205
217
  7. Respect the cap: at most {lp.MAX_ENTRIES_PER_LIST} entries per list. If a list is full, fold the new signal into an existing entry via remove+add.
218
+ 8. Scope check (this block is shared across every project): before proposing an entry, ask whether it would still make sense read from a totally different product's cards. If it only makes sense for "{project.get('name')}" specifically, don't add it.
206
219
 
207
220
  OUTPUT: a single JSON object, nothing else. Schema:
208
221
  {{"changes": {{"audience_avoid": {{"add": [], "remove": []}}, "audience_prefer": {{"add": [], "remove": []}}, "thread_avoid": {{"add": [], "remove": []}}, "draft_style_notes": {{"add": [], "remove": []}}}}, "voice_never_add": [], "guardrails_do_not_add": [], "block_authors": [{{"handle": "somehandle", "reason": "one short sentence citing the evidence"}}], "rationale": "one short sentence"}}
@@ -463,6 +476,21 @@ def main() -> int:
463
476
  log("another digest run holds the lock; exiting")
464
477
  return 0
465
478
 
479
+ # One-time, idempotent move from per-project learned_preferences to the
480
+ # single learned_preferences_global block (2026-07-08). Runs on every
481
+ # digest tick everywhere (this operator's box AND every customer install,
482
+ # since this file ships in the same release); a config already migrated
483
+ # (no project carries the old key) is a fast no-op. Skipped in --dry-run
484
+ # since that mode promises to change nothing.
485
+ if not args.dry_run:
486
+ try:
487
+ mig = lp.migrate_to_global()
488
+ if mig.get("migrated"):
489
+ log(f"learned_preferences migrated to global: projects={mig.get('projects_merged')} "
490
+ f"excluded={len(mig.get('excluded') or [])} dropped_at_cap={len(mig.get('dropped_at_cap') or [])}")
491
+ except Exception as e:
492
+ log(f"learned_preferences migration error (non-fatal): {e}")
493
+
466
494
  cfg = load_config()
467
495
  by_name = {p.get("name"): p for p in (cfg.get("projects") or [])}
468
496
 
@@ -0,0 +1,50 @@
1
+ #!/bin/bash
2
+ # Print (or download) the permanent download link for the latest S4L staging
3
+ # release (.mcpb).
4
+ #
5
+ # Usage:
6
+ # bash scripts/get-latest-staging-mcpb.sh [--download] [--channel]
7
+ #
8
+ # Options:
9
+ # --download Download the .mcpb to the current directory (instead of
10
+ # just printing the URL)
11
+ # --channel After downloading, opt this machine into the staging
12
+ # channel (delegates to scripts/s4l_channel.py, the single
13
+ # source of truth for the channel marker)
14
+ #
15
+ # The link below is a FIXED URL, not a live GitHub API lookup: every
16
+ # `bash scripts/release-mcpb.sh --staging` run mirrors its .mcpb onto a
17
+ # separate, non-version-tagged release ("staging-latest") so this exact URL
18
+ # always serves whatever's newest — see scripts/release-mcpb.sh step 7b. That
19
+ # means this script needs zero network calls to resolve anything; it only
20
+ # hits the network when actually downloading.
21
+
22
+ set -euo pipefail
23
+
24
+ STAGING_MCPB_URL="https://github.com/m13v/s4l/releases/download/staging-latest/social-autoposter.mcpb"
25
+
26
+ DO_DOWNLOAD=0
27
+ DO_CHANNEL=0
28
+
29
+ while [[ $# -gt 0 ]]; do
30
+ case "$1" in
31
+ --download) DO_DOWNLOAD=1; shift ;;
32
+ --channel) DO_CHANNEL=1; shift ;;
33
+ *) echo "Unknown option: $1" >&2; exit 1 ;;
34
+ esac
35
+ done
36
+
37
+ if [ "$DO_DOWNLOAD" -eq 0 ]; then
38
+ echo "$STAGING_MCPB_URL"
39
+ exit 0
40
+ fi
41
+
42
+ FILENAME="social-autoposter-staging.mcpb"
43
+ echo "Downloading $FILENAME from $STAGING_MCPB_URL" >&2
44
+ curl -fL "$STAGING_MCPB_URL" -o "$FILENAME"
45
+ echo "$FILENAME"
46
+
47
+ if [ "$DO_CHANNEL" -eq 1 ]; then
48
+ REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
49
+ python3 "$REPO_ROOT/scripts/s4l_channel.py" set staging >&2
50
+ fi
@@ -43,6 +43,7 @@ import os
43
43
  import re
44
44
  import signal
45
45
  import sys
46
+ import threading
46
47
  import time
47
48
  from pathlib import Path
48
49
 
@@ -306,6 +307,21 @@ class Harness:
306
307
  ctx.add_init_script(script)
307
308
  except Exception:
308
309
  pass
310
+ # The overlay shares this CDP session with the real twitter-harness
311
+ # automation. When x.com throws up a native JS dialog, both clients'
312
+ # Playwright drivers race to auto-dismiss it (the default behavior
313
+ # when no listener is registered); the loser gets a
314
+ # "Page.handleJavaScriptDialog: No dialog is showing" protocol error
315
+ # that is an UNCAUGHT rejection in the Node driver, killing that
316
+ # whole driver process. A no-op listener opts this connection out of
317
+ # the race entirely (dialog handling stays the automation's job);
318
+ # once the driver's default-dismiss is suppressed here, evaluate()
319
+ # calls on the affected page resume normally as soon as the other
320
+ # side clears the dialog.
321
+ try:
322
+ ctx.on("dialog", lambda dialog: None)
323
+ except Exception:
324
+ pass
309
325
 
310
326
  def paint(self, title: str, reassure: str, status: str) -> int:
311
327
  """Paint/refresh the overlay on every live page. Returns pages touched."""
@@ -473,11 +489,40 @@ def cmd_watch(interval: float = 2.0) -> int:
473
489
  # Treat SIGTERM (launchd unload, `kill`) like Ctrl-C so the overlay is
474
490
  # cleared on the way out instead of lingering until the next navigation.
475
491
  signal.signal(signal.SIGTERM, lambda *_: (_ for _ in ()).throw(KeyboardInterrupt()))
492
+
493
+ # Hard watchdog: a wedged CDP call (e.g. a page whose JS is paused on a
494
+ # native dialog) can block the loop body indefinitely without ever raising,
495
+ # so the normal try/except reconnect logic below never gets a chance to
496
+ # run. That let a past incident spin a full core for ~3.5 hours with the
497
+ # overlay silently stuck instead of failing loudly. A background thread
498
+ # that self-terminates the process if the main loop hasn't ticked in too
499
+ # long is the only thing that reliably fires even when the main thread is
500
+ # stuck inside a blocking Playwright/CDP call; os._exit() skips Python-level
501
+ # cleanup on purpose since a stuck process can't be trusted to run it
502
+ # either. launchd's KeepAlive relaunches a clean instance immediately.
503
+ WATCHDOG_MAX_STALL_SEC = 60.0
504
+ _last_tick = {"t": time.time()}
505
+
506
+ def _watchdog() -> None:
507
+ while True:
508
+ time.sleep(10)
509
+ stalled = time.time() - _last_tick["t"]
510
+ if stalled > WATCHDOG_MAX_STALL_SEC:
511
+ print(
512
+ f"[watchdog] loop stalled {stalled:.0f}s (>{WATCHDOG_MAX_STALL_SEC:.0f}s); "
513
+ "self-terminating for a clean launchd restart",
514
+ file=sys.stderr,
515
+ )
516
+ os._exit(1)
517
+
518
+ threading.Thread(target=_watchdog, daemon=True).start()
519
+
476
520
  last_status = None
477
521
  h: Harness | None = None
478
522
  registered = False
479
523
  try:
480
524
  while True:
525
+ _last_tick["t"] = time.time()
481
526
  # Never let status computation (log globbing/stat, all racing against
482
527
  # live log rotation) kill the watcher; fall back to a neutral status.
483
528
  try:
@@ -328,6 +328,32 @@ def get_identity_header(refresh: bool = False) -> str:
328
328
  return base64.b64encode(raw).decode("ascii")
329
329
 
330
330
 
331
+ # Our own infra: operator Mac, mk0r E2B sandbox fleet, MacStadium remote QA boxes.
332
+ # Single source of truth for "is this install actually a customer", consumed by
333
+ # active_users.py (customer-roster dedupe) and autopilot_stall_watch.py (stall-page
334
+ # severity), so the two lists can't drift apart. "71522" was the MacStadium box
335
+ # retired by the 2026-07-06 hardware swap (ticket #10904); "71732" is its
336
+ # replacement, both listed since the old box's hostname could still appear in
337
+ # historical data.
338
+ INTERNAL_EMAILS = {"i@m13v.com", "agent@mk0r.com", "matt@mediar.ai"}
339
+ INTERNAL_HOSTNAME_SUBSTR = ("e2b.local", "71522", "71732")
340
+ INTERNAL_HARDWARE_UUIDS = {"07CB793D-6E32-5EF8-82E2-7CDEABD47FBC"}
341
+
342
+
343
+ def is_internal_install(ident: dict | None = None) -> bool:
344
+ """True when this install is our own infra (staging/QA/dev), not a real
345
+ customer. Local-only (no DB), so it works on shipped .mcpb installs too."""
346
+ ident = ident if ident is not None else get_identity()
347
+ if (ident.get("git_email") or "").strip().lower() in INTERNAL_EMAILS:
348
+ return True
349
+ hostname = ident.get("hostname") or ""
350
+ if any(sub in hostname for sub in INTERNAL_HOSTNAME_SUBSTR):
351
+ return True
352
+ if (ident.get("hardware_uuid") or "") in INTERNAL_HARDWARE_UUIDS:
353
+ return True
354
+ return False
355
+
356
+
331
357
  def main():
332
358
  cmd = sys.argv[1] if len(sys.argv) > 1 else "show"
333
359
  if cmd == "show":
@@ -1,39 +1,54 @@
1
1
  #!/usr/bin/env python3
2
- """Per-project learned_preferences: the agent-owned config block distilled from
3
- human card decisions (review_events).
2
+ """learned_preferences: the agent-owned config block distilled from human
3
+ card decisions (review_events).
4
+
5
+ **Single install-wide block (2026-07-08).** Earlier this lived once PER
6
+ PROJECT. In practice every entry ever learned was a fact about the human
7
+ reviewer (their voice, their quality bar, their reviewing habits), never
8
+ about one product's audience specifically, so a multi-project install (every
9
+ install gets at least a persona project plus, usually, a product project)
10
+ just relearned the same reviewer-level facts independently in each project.
11
+ Consolidated into ONE top-level `learned_preferences_global` block so a
12
+ correction made anywhere reaches every project's drafting/judging prompt.
13
+ migrate_to_global() performs the one-time move from the old per-project
14
+ shape and runs automatically (idempotent) from feedback_digest.py.
4
15
 
5
16
  The feedback loop:
6
17
  1. The menubar review card ships every approve/reject (with reason chips,
7
18
  free-text note, link-click interactions, dwell) to /api/v1/review-events.
8
19
  2. scripts/feedback_digest.py (scheduled) claims unprocessed events per
9
20
  project and asks Claude for a conservative mutation plan.
10
- 3. apply_mutations() here writes that plan into config.json under the
11
- project's `learned_preferences` block, whitelist-enforced, with flock +
21
+ 3. apply_mutations() here writes that plan into config.json's single
22
+ `learned_preferences_global` block, whitelist-enforced, with flock +
12
23
  backup + atomic replace.
13
24
  4. Enforcement is SOFT (prompt-level, never a deterministic filter): the
14
25
  twitter prep prompt embeds every project entry verbatim via
15
- ALL_PROJECTS_JSON, so the block (with its self-describing _instruction)
16
- reaches the judging/drafting model automatically. prompt_block() renders
17
- the same content for prompts that want an explicit section.
26
+ ALL_PROJECTS_JSON (each project dict is stamped with the same global
27
+ block at prompt-build time), so the block (with its self-describing
28
+ _instruction) reaches the judging/drafting model automatically.
29
+ prompt_block() renders the same content for callers that want an
30
+ explicit section instead of the raw embed.
18
31
 
19
- Block shape (inside a config.json project entry):
32
+ Block shape (config.json top-level key `learned_preferences_global`):
20
33
 
21
- "learned_preferences": {
34
+ "learned_preferences_global": {
22
35
  "_instruction": "<how the drafting model should apply this block>",
23
36
  "enabled": true,
24
37
  "audience_avoid": ["crypto/web3-native authors ..."],
25
38
  "audience_prefer": [],
26
39
  "thread_avoid": ["engagement-bait question threads ..."],
27
40
  "draft_style_notes": [],
41
+ "edit_examples": [{"original", "final", "ts"}],
28
42
  "updated_at": "...",
29
43
  "history": [{"ts", "change", "rationale", "source_events": [ids]}]
30
44
  }
31
45
 
32
- WHITELIST: this module writes ONLY learned_preferences plus (append-only)
33
- voice.never and content_guardrails.do_not. Nothing else in a project entry is
34
- touchable through this path; unknown keys in a mutation plan are dropped and
35
- counted, never applied. Facts (content_angle, links, identity fields) are
36
- deliberately unreachable so a bad digest can never poison grounding.
46
+ WHITELIST: this module writes ONLY learned_preferences_global plus
47
+ (append-only, still per-project) voice.never and content_guardrails.do_not.
48
+ Nothing else in a project entry is touchable through this path; unknown keys
49
+ in a mutation plan are dropped and counted, never applied. Facts
50
+ (content_angle, links, identity fields) are deliberately unreachable so a
51
+ bad digest can never poison grounding.
37
52
  """
38
53
  from __future__ import annotations
39
54
 
@@ -41,6 +56,7 @@ import datetime
41
56
  import fcntl
42
57
  import json
43
58
  import os
59
+ import re
44
60
  import shutil
45
61
  import sys
46
62
  from pathlib import Path
@@ -137,14 +153,40 @@ def normalized(block) -> dict:
137
153
  return out
138
154
 
139
155
 
140
- def get_block(project_cfg) -> dict:
141
- return normalized((project_cfg or {}).get("learned_preferences"))
156
+ def get_global_block(cfg: dict | None = None, cfg_path: str | None = None) -> dict:
157
+ """Load + normalize the single install-wide learned_preferences block.
158
+
159
+ If `cfg` (an already-parsed config.json dict) isn't supplied, reads
160
+ config.json fresh from disk — the same "re-read on every call" pattern
161
+ already used elsewhere (e.g. link_tail.py's project loader), so callers
162
+ that only ever had a single project's dict slice (not the whole config)
163
+ can still reach the global block with no signature change on their end.
164
+ """
165
+ if cfg is None:
166
+ try:
167
+ cfg = json.loads(Path(cfg_path or config_path()).read_text())
168
+ except Exception:
169
+ cfg = {}
170
+ return normalized(cfg.get("learned_preferences_global"))
171
+
172
+
173
+ def get_block(project_cfg=None) -> dict:
174
+ """Back-compat shim (2026-07-08): learned_preferences is now a SINGLE
175
+ install-wide block, not one per project — see module docstring.
176
+ `project_cfg` is accepted but ignored; kept so every existing caller
177
+ (post_reddit.py, engage_reddit.py, post_github.py, engage_github.py,
178
+ link_tail.py — 4 of those 5 files are chflags-uchg locked) needs zero
179
+ changes. Callers that already have the full parsed config in scope
180
+ should call get_global_block(cfg) directly instead (apply_mutations and
181
+ record_edit_examples below do)."""
182
+ return get_global_block()
142
183
 
143
184
 
144
- def prompt_block(project_cfg) -> str:
185
+ def prompt_block(project_cfg=None) -> str:
145
186
  """Explicit prompt section for callers that don't embed the raw project
146
187
  JSON (mirrors the engagement_styles STYLES_BLOCK pattern). Empty string
147
- when the block is disabled or has no entries."""
188
+ when the block is disabled or has no entries. `project_cfg` is accepted
189
+ but ignored (see get_block())."""
148
190
  b = get_block(project_cfg)
149
191
  if not b["enabled"]:
150
192
  return ""
@@ -190,12 +232,16 @@ def _validate_add_list(raw, cap=MAX_ENTRIES_PER_LIST):
190
232
 
191
233
  def record_edit_examples(project_name: str, pairs, cfg_path: str | None = None) -> dict:
192
234
  """Deterministically record (original, final) card-edit pairs as few-shot
193
- examples in the project's learned_preferences.edit_examples (newest first,
194
- capped at MAX_EDIT_EXAMPLES). This is CODE-owned, not part of the digest
195
- LLM's mutation plan: an edit the user made IS the ground truth, no judgment
196
- call needed to keep it (mirrors the s4l-email inbox repo, which injects
197
- the last 5 human-edited drafts into every draft prompt). Same flock +
198
- backup + atomic-replace mechanics as apply_mutations().
235
+ examples in the single learned_preferences_global.edit_examples (newest
236
+ first, capped at MAX_EDIT_EXAMPLES). This is CODE-owned, not part of the
237
+ digest LLM's mutation plan: an edit the user made IS the ground truth, no
238
+ judgment call needed to keep it (mirrors the s4l-email inbox repo, which
239
+ injects the last 5 human-edited drafts into every draft prompt). Same
240
+ flock + backup + atomic-replace mechanics as apply_mutations().
241
+
242
+ `project_name` is validated against config.json (catches a caller typo)
243
+ but no longer selects a per-project block to write into — every project's
244
+ edits feed the same global few-shot pool (2026-07-08).
199
245
 
200
246
  pairs: iterable of {"original": str, "final": str} (ts optional).
201
247
  Returns {ok, recorded: int}.
@@ -230,7 +276,7 @@ def record_edit_examples(project_name: str, pairs, cfg_path: str | None = None)
230
276
  if proj is None:
231
277
  return {"ok": False, "error": "project not in config", "recorded": 0}
232
278
 
233
- block = get_block(proj)
279
+ block = get_global_block(cfg)
234
280
  existing = block["edit_examples"]
235
281
  # Dedup on the final text (a retried digest re-records the same batch).
236
282
  seen = {e["final"] for e in existing}
@@ -245,9 +291,10 @@ def record_edit_examples(project_name: str, pairs, cfg_path: str | None = None)
245
291
  "change": f"edit_examples recorded: {len(fresh)}",
246
292
  "rationale": "user rewrote the draft on the review card",
247
293
  "source_events": [],
294
+ "project": project_name,
248
295
  }
249
296
  ])[-MAX_HISTORY:]
250
- proj["learned_preferences"] = block
297
+ cfg["learned_preferences_global"] = block
251
298
 
252
299
  stamp = _now_iso().replace(":", "-").replace(".", "-")
253
300
  try:
@@ -263,7 +310,8 @@ def record_edit_examples(project_name: str, pairs, cfg_path: str | None = None)
263
310
 
264
311
 
265
312
  def apply_mutations(project_name: str, plan: dict, source_event_ids=None, cfg_path: str | None = None) -> dict:
266
- """Apply a digest mutation plan to config.json. Returns a summary dict:
313
+ """Apply a digest mutation plan to the single learned_preferences_global
314
+ block. Returns a summary dict:
267
315
  {ok, applied: [change strings], dropped: [reasons], project_found: bool}.
268
316
 
269
317
  plan shape (all optional):
@@ -271,6 +319,13 @@ def apply_mutations(project_name: str, plan: dict, source_event_ids=None, cfg_pa
271
319
  "voice_never_add": [...], "guardrails_do_not_add": [...],
272
320
  "rationale": "..."}
273
321
 
322
+ `project_name` is validated against config.json (catches a caller typo)
323
+ and recorded in the history entry's rationale, but no longer selects a
324
+ per-project block: every project's digest writes into the same global
325
+ block now (2026-07-08). `voice_never_add`/`guardrails_do_not_add` stay
326
+ per-project (proj['voice']/proj['content_guardrails']) since those are a
327
+ separate, narrower whitelist unrelated to this consolidation.
328
+
274
329
  flock on <config>.lock serializes against other writers (setup tools, a
275
330
  concurrent digest); backup + atomic replace mirrors mcp/src/setup.ts
276
331
  applySetup(). Unknown keys are DROPPED, never applied: the whitelist is
@@ -295,7 +350,7 @@ def apply_mutations(project_name: str, plan: dict, source_event_ids=None, cfg_pa
295
350
  if proj is None:
296
351
  return {"ok": False, "error": "project not in config", "applied": [], "dropped": [], "project_found": False}
297
352
 
298
- block = get_block(proj)
353
+ block = get_global_block(cfg)
299
354
 
300
355
  for key, ops in changes.items():
301
356
  if key not in MANAGED_LISTS:
@@ -345,9 +400,10 @@ def apply_mutations(project_name: str, plan: dict, source_event_ids=None, cfg_pa
345
400
  "change": "; ".join(applied)[:1000],
346
401
  "rationale": rationale,
347
402
  "source_events": list(source_event_ids or [])[:100],
403
+ "project": project_name,
348
404
  }
349
405
  ])[-MAX_HISTORY:]
350
- proj["learned_preferences"] = block
406
+ cfg["learned_preferences_global"] = block
351
407
 
352
408
  # Backup + atomic replace (same shape as setup.ts applySetup).
353
409
  stamp = _now_iso().replace(":", "-").replace(".", "-")
@@ -363,19 +419,172 @@ def apply_mutations(project_name: str, plan: dict, source_event_ids=None, cfg_pa
363
419
  return {"ok": True, "applied": applied, "dropped": dropped, "project_found": True}
364
420
 
365
421
 
422
+ # --- Per-project -> global migration (2026-07-08) -------------------------
423
+ # Two categories of existing per-project draft_style_notes are EXCLUDED
424
+ # (dropped, not carried forward) rather than merged into the new global
425
+ # block, per explicit user instruction: notes that tell the drafter to
426
+ # suppress/strip the trailing product link, and notes about avoiding a
427
+ # trailing period/punctuation. The link-suppression notes directly
428
+ # contradict the tail-link bridge feature (scripts/link_tail.py), whose
429
+ # entire job is appending that link — when fed into the bridge prompt as a
430
+ # MANDATORY learned preference they produced degraded output (e.g. a
431
+ # duplicated discourse-marker artifact traced 2026-07-08). Everything else
432
+ # (audience_avoid/prefer, thread_avoid, other draft_style_notes,
433
+ # edit_examples, and each project's own history[]) is carried forward
434
+ # verbatim — nothing else is dropped, and every excluded/dropped entry is
435
+ # still recorded in the merged history for audit purposes.
436
+ _LINK_SUPPRESSION_RE = re.compile(
437
+ r"do not (append|add)\b.{0,60}\b(link|url)\b"
438
+ r"|\b(link|url)\b.{0,60}\bdo not (append|add)\b"
439
+ r"|leave\b.{0,40}\bout\b.{0,40}\b(link|url|product url)\b"
440
+ r"|\b(link|url)\b.{0,60}\b(strip|remove)\b"
441
+ r"|\bstrip(s|ped)?\b.{0,60}\b(link|url)\b"
442
+ r"|let the copy stand alone"
443
+ r"|end (right after|on) the (sentence|product name)",
444
+ re.IGNORECASE,
445
+ )
446
+ _PUNCTUATION_SUPPRESSION_RE = re.compile(
447
+ r"\b(trailing|no|without a?|avoid|remove|do not (end|use))\b.{0,40}\b(period|dot|full stop|punctuation)\b",
448
+ re.IGNORECASE,
449
+ )
450
+
451
+
452
+ def _excluded_note_reason(text: str) -> str | None:
453
+ """Reason string if this draft_style_notes entry should be dropped
454
+ during migration (not carried into the global block), else None."""
455
+ if _LINK_SUPPRESSION_RE.search(text):
456
+ return "contradicts the tail-link bridge feature (link/url suppression note)"
457
+ if _PUNCTUATION_SUPPRESSION_RE.search(text):
458
+ return "trailing punctuation suppression note"
459
+ return None
460
+
461
+
462
+ def migrate_to_global(cfg_path: str | None = None) -> dict:
463
+ """One-time, idempotent consolidation of every project's
464
+ learned_preferences into the single learned_preferences_global block,
465
+ then deletes the per-project key. Safe to call on every process start
466
+ (feedback_digest.py does): a config where no project carries a
467
+ `learned_preferences` key is already migrated and this is a cheap no-op.
468
+
469
+ Returns {ok, migrated: bool, projects_merged: [...], excluded: [...],
470
+ dropped_at_cap: [...]}.
471
+ """
472
+ cfg_path = cfg_path or config_path()
473
+ lock_path = cfg_path + ".lock"
474
+ Path(lock_path).parent.mkdir(parents=True, exist_ok=True)
475
+ with open(lock_path, "w") as lock_f:
476
+ fcntl.flock(lock_f, fcntl.LOCK_EX)
477
+ try:
478
+ cfg = json.loads(Path(cfg_path).read_text())
479
+ except Exception as e:
480
+ return {"ok": False, "error": f"config unreadable: {e}"}
481
+
482
+ projects = cfg.get("projects") or []
483
+ if not any(isinstance(p.get("learned_preferences"), dict) for p in projects):
484
+ return {"ok": True, "migrated": False, "projects_merged": [], "excluded": [], "dropped_at_cap": []}
485
+
486
+ global_block = normalized(cfg.get("learned_preferences_global"))
487
+ merged_history = list(global_block["history"])
488
+ excluded = []
489
+ dropped_at_cap = []
490
+ projects_merged = []
491
+
492
+ for proj in projects:
493
+ raw = proj.pop("learned_preferences", None)
494
+ if not isinstance(raw, dict):
495
+ continue
496
+ pname = proj.get("name", "?")
497
+ block = normalized(raw)
498
+ touched = False
499
+ for key in MANAGED_LISTS:
500
+ for v in block[key]:
501
+ if key == "draft_style_notes":
502
+ reason = _excluded_note_reason(v)
503
+ if reason:
504
+ excluded.append({"project": pname, "key": key, "value": v, "reason": reason})
505
+ continue
506
+ if v in global_block[key]:
507
+ continue
508
+ if len(global_block[key]) >= MAX_ENTRIES_PER_LIST:
509
+ dropped_at_cap.append({"project": pname, "key": key, "value": v})
510
+ continue
511
+ global_block[key].append(v)
512
+ touched = True
513
+ existing_finals = {e["final"] for e in global_block["edit_examples"]}
514
+ for e in block["edit_examples"]:
515
+ if e["final"] not in existing_finals:
516
+ global_block["edit_examples"].append(e)
517
+ existing_finals.add(e["final"])
518
+ touched = True
519
+ for h in block["history"]:
520
+ merged_history.append({**h, "project": pname})
521
+ touched = True
522
+ if touched:
523
+ projects_merged.append(pname)
524
+
525
+ # Keep only the newest MAX_EDIT_EXAMPLES, most-recent ts first.
526
+ global_block["edit_examples"].sort(key=lambda e: e.get("ts") or "", reverse=True)
527
+ global_block["edit_examples"] = global_block["edit_examples"][:MAX_EDIT_EXAMPLES]
528
+
529
+ summary = f"migrated {len(projects_merged)} project block(s) into learned_preferences_global"
530
+ if excluded:
531
+ summary += f"; excluded {len(excluded)} note(s) (link/punctuation suppression)"
532
+ if dropped_at_cap:
533
+ summary += f"; {len(dropped_at_cap)} entrie(s) dropped at the {MAX_ENTRIES_PER_LIST}-per-list cap"
534
+ merged_history.append({
535
+ "ts": _now_iso(),
536
+ "change": summary,
537
+ "rationale": "learned_preferences is now a single install-wide block, not per-project "
538
+ "(2026-07-08); every reviewer-level fact ever learned applied to the "
539
+ "reviewer, not to one product's audience, so per-project storage just "
540
+ "caused the same corrections to be relearned independently in every project.",
541
+ "source_events": [],
542
+ "excluded": excluded,
543
+ "dropped_at_cap": dropped_at_cap,
544
+ })
545
+ global_block["history"] = merged_history[-MAX_HISTORY:]
546
+ global_block["updated_at"] = _now_iso()
547
+ cfg["learned_preferences_global"] = global_block
548
+
549
+ stamp = _now_iso().replace(":", "-").replace(".", "-")
550
+ try:
551
+ if os.path.exists(cfg_path):
552
+ shutil.copyfile(cfg_path, f"{cfg_path}.bak-{stamp}")
553
+ except Exception:
554
+ pass
555
+ tmp = cfg_path + ".tmp"
556
+ Path(tmp).write_text(json.dumps(cfg, indent=2) + "\n")
557
+ os.replace(tmp, cfg_path)
558
+
559
+ return {
560
+ "ok": True, "migrated": True, "projects_merged": projects_merged,
561
+ "excluded": excluded, "dropped_at_cap": dropped_at_cap,
562
+ }
563
+
564
+
366
565
  if __name__ == "__main__":
367
566
  import argparse
368
567
 
369
568
  ap = argparse.ArgumentParser(description="Inspect/render learned_preferences")
370
- ap.add_argument("command", choices=["show", "block"], help="show = raw JSON, block = prompt block")
371
- ap.add_argument("project")
569
+ ap.add_argument("command", choices=["show", "block", "migrate"],
570
+ help="show = raw JSON, block = prompt block, migrate = run migrate_to_global()")
571
+ ap.add_argument("project", nargs="?", help="unused for 'migrate'; kept for show/block back-compat")
372
572
  args = ap.parse_args()
573
+
574
+ if args.command == "migrate":
575
+ print(json.dumps(migrate_to_global(), indent=2))
576
+ sys.exit(0)
577
+
578
+ if not args.project:
579
+ print("show/block require a project name (used only to validate against config.json; "
580
+ "the rendered block is now global)", file=sys.stderr)
581
+ sys.exit(1)
373
582
  cfg = json.loads(Path(config_path()).read_text())
374
583
  proj = next((p for p in (cfg.get("projects") or []) if p.get("name") == args.project), None)
375
584
  if proj is None:
376
585
  print(f"project {args.project!r} not found", file=sys.stderr)
377
586
  sys.exit(1)
378
587
  if args.command == "show":
379
- print(json.dumps(get_block(proj), indent=2))
588
+ print(json.dumps(get_global_block(cfg), indent=2))
380
589
  else:
381
590
  print(prompt_block(proj))