delimit-cli 4.5.13 → 4.6.1

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 (53) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/README.md +9 -8
  3. package/bin/delimit-cli.js +179 -4
  4. package/bin/delimit-setup.js +46 -6
  5. package/gateway/ai/_compile_status.py +154 -0
  6. package/gateway/ai/agent_dispatch.py +41 -0
  7. package/gateway/ai/backends/git_health.py +175 -0
  8. package/gateway/ai/backends/tools_infra.py +163 -10
  9. package/gateway/ai/cli_contract.py +185 -0
  10. package/gateway/ai/daemon.py +10 -0
  11. package/gateway/ai/daily_digest.py +1 -2
  12. package/gateway/ai/delimit_daemon.py +67 -0
  13. package/gateway/ai/dispatch_gate.py +399 -0
  14. package/gateway/ai/governance.py +181 -0
  15. package/gateway/ai/heartbeat.py +290 -0
  16. package/gateway/ai/hot_reload.py +1 -2
  17. package/gateway/ai/led193_daemon/executor.py +9 -0
  18. package/gateway/ai/ledger_manager.py +90 -4
  19. package/gateway/ai/ledger_proof.py +127 -0
  20. package/gateway/ai/license.py +132 -47
  21. package/gateway/ai/license_core.cpython-310-x86_64-linux-gnu.so +0 -0
  22. package/gateway/ai/license_core.pyi +1 -1
  23. package/gateway/ai/notify.py +39 -0
  24. package/gateway/ai/outreach_loop_daemon.py +349 -0
  25. package/gateway/ai/outreach_substantive.py +1437 -0
  26. package/gateway/ai/pro_tools.yaml +167 -0
  27. package/gateway/ai/reaper.py +70 -0
  28. package/gateway/ai/reddit_scanner.py +17 -6
  29. package/gateway/ai/sensing/schema.py +1 -1
  30. package/gateway/ai/sensing/signal_store.py +0 -1
  31. package/gateway/ai/server.py +5490 -1602
  32. package/gateway/ai/social_capability/fit_floor.py +114 -12
  33. package/gateway/ai/social_queue.py +166 -10
  34. package/gateway/ai/tdqs_lint.py +611 -0
  35. package/gateway/ai/tenant_auth.py +329 -0
  36. package/gateway/ai/tenant_data.py +339 -0
  37. package/gateway/ai/tenant_paths.py +150 -0
  38. package/gateway/ai/usage_allowlist.py +198 -0
  39. package/gateway/ai/workers/base.py +2 -2
  40. package/gateway/ai/workers/executor.py +32 -3
  41. package/gateway/ai/workers/outreach_drafter.py +0 -1
  42. package/gateway/ai/workers/pr_drafter.py +0 -1
  43. package/gateway/ai/x_ranker.py +12 -2
  44. package/gateway/core/json_schema_diff.py +25 -1
  45. package/lib/auth-signin.js +136 -0
  46. package/lib/auth-signout.js +169 -0
  47. package/lib/delimit-template.js +11 -0
  48. package/lib/migration-2092-banner.js +213 -0
  49. package/package.json +5 -2
  50. package/server.json +4 -4
  51. package/scripts/build-license-core.sh +0 -85
  52. package/scripts/security-check.sh +0 -66
  53. package/scripts/test-license-core-so.sh +0 -107
@@ -45,10 +45,19 @@ from typing import Any, Dict, Iterable, Optional, Set, Tuple
45
45
 
46
46
  logger = logging.getLogger(__name__)
47
47
 
48
- # Default cooldown window for topic coverage. Founder directive (2026-05-05):
49
- # "if we've already drafted on a similar topic in the last 7 days, abstain on
50
- # the next one to avoid spam-pattern detection."
51
- DEFAULT_COOLDOWN_DAYS = 7
48
+ # Default cooldown window for topic coverage.
49
+ # Original founder directive (2026-05-05): 7 days to avoid spam-pattern detection.
50
+ # Revised 2026-05-12 (LED-1356, panel-unanimous): 7 days × cross-platform global
51
+ # scope was producing 57% abstention rate (115/200 recent abstentions =
52
+ # topic_cooldown) and effectively blocked every Delimit-relevant topic for a
53
+ # full week after one draft. Reduced to 48h AND scoped per-platform/per-subreddit
54
+ # so a Reddit/r/mcp draft no longer cools an HN draft on MCP, and a Reddit/r/mcp
55
+ # draft no longer cools a Reddit/r/programming draft on MCP. Different audiences,
56
+ # different scans.
57
+ DEFAULT_COOLDOWN_HOURS = 48
58
+ # Backwards-compat alias for any callers that still pass `cooldown_days=` —
59
+ # converted to hours internally. Don't add new callers using this name.
60
+ DEFAULT_COOLDOWN_DAYS = DEFAULT_COOLDOWN_HOURS / 24
52
61
 
53
62
  # Default high-engagement opportunity-cost threshold. Threads above this score
54
63
  # pass the fit floor even without keyword match, but with ``human_only=True``
@@ -179,18 +188,63 @@ def _parse_iso(value: Optional[str]) -> Optional[datetime]:
179
188
  return dt
180
189
 
181
190
 
191
+ def _log_entry_scope_key(entry: dict) -> str:
192
+ """LED-1356 platform-scoped cooldown key. Reddit cooldowns are per-subreddit
193
+ (different subreddits = different audiences); HN/devto/X are global within
194
+ the platform. Cross-platform doesn't cool.
195
+
196
+ Returns:
197
+ - "reddit:r/<name>" for Reddit entries with a subreddit field
198
+ - "reddit" for Reddit entries without (legacy fallback)
199
+ - The platform name itself for hn / devto / x / twitter
200
+ - "unknown" when the entry doesn't carry a platform
201
+ """
202
+ platform = (entry.get("platform") or "").lower()
203
+ if not platform:
204
+ return "unknown"
205
+ if platform == "reddit":
206
+ sub = (entry.get("subreddit") or "").lower()
207
+ if sub:
208
+ sub = sub.lstrip("r/")
209
+ return f"reddit:r/{sub}"
210
+ return "reddit"
211
+ # twitter and x are aliases for the same audience
212
+ if platform == "twitter":
213
+ return "x"
214
+ return platform
215
+
216
+
182
217
  def _recent_topic_fingerprints(
183
- cooldown_days: int = DEFAULT_COOLDOWN_DAYS,
218
+ cooldown_hours: float = DEFAULT_COOLDOWN_HOURS,
184
219
  log_path: Optional[Path] = None,
185
- ) -> Set[str]:
186
- """Return the union of topic fingerprints found in ``social_log.jsonl``
187
- within the cooldown window. Tolerant of malformed lines.
220
+ cooldown_days: Optional[float] = None,
221
+ ) -> Dict[str, Set[str]]:
222
+ """Return platform-scoped topic fingerprints from ``social_log.jsonl``
223
+ within the cooldown window.
224
+
225
+ LED-1356 changes (2026-05-12, panel-unanimous):
226
+ - Window default 7d → 48h.
227
+ - Return shape Set[str] → Dict[scope_key, Set[str]] so callers can ask
228
+ "what topics has THIS audience seen recently" instead of the prior
229
+ "global union of every topic across every platform."
230
+ - `cooldown_days` is preserved as backwards-compat kwarg; converted to
231
+ hours internally.
232
+
233
+ Callers should look up the relevant set via the dict and pass it to
234
+ ``evaluate_fit(..., recent_topics=...)``. A target's scope is computed
235
+ via :func:`_log_entry_scope_key` on the target dict.
236
+
237
+ Returns dict mapping scope_key → set of canonical signal fingerprints
238
+ drafted on for that scope within the window.
188
239
  """
189
240
  p = log_path or _social_log_path()
190
241
  if not p.exists():
191
- return set()
192
- cutoff = datetime.now(timezone.utc) - timedelta(days=cooldown_days)
193
- seen: Set[str] = set()
242
+ return {}
243
+ # Backwards compatibility: if cooldown_days passed, convert to hours.
244
+ if cooldown_days is not None:
245
+ cooldown_hours = float(cooldown_days) * 24.0
246
+ cutoff = datetime.now(timezone.utc) - timedelta(hours=cooldown_hours)
247
+ seen: Dict[str, Set[str]] = {}
194
248
  try:
195
249
  with open(p, "r", encoding="utf-8") as fh:
196
250
  for line in fh:
@@ -204,6 +258,7 @@ def _recent_topic_fingerprints(
204
258
  ts = _parse_iso(entry.get("ts"))
205
259
  if ts is None or ts < cutoff:
206
260
  continue
261
+ scope_key = _log_entry_scope_key(entry)
207
262
  # Use the post body + thread title (when present) as the
208
263
  # topic surface. Reddit entries log the thread title
209
264
  # separately; X entries don't have one but the post body
@@ -214,12 +269,55 @@ def _recent_topic_fingerprints(
214
269
  entry.get("thread_title") or "",
215
270
  ]
216
271
  )
217
- seen.update(_extract_topic_fingerprint(blob))
272
+ fps = _extract_topic_fingerprint(blob)
273
+ if not fps:
274
+ continue
275
+ seen.setdefault(scope_key, set()).update(fps)
218
276
  except OSError as exc:
219
277
  logger.warning("fit_floor: failed to read %s: %s", p, exc)
220
278
  return seen
221
279
 
222
280
 
281
+ def topics_for_scope(
282
+ recent_topics_dict: Dict[str, Set[str]],
283
+ scope_key: str,
284
+ ) -> Set[str]:
285
+ """Convenience helper: look up the topic set for a given scope.
286
+
287
+ Returns empty set if the scope has no recent activity in the window.
288
+ Callers should pass the result as ``evaluate_fit(..., recent_topics=...)``.
289
+ """
290
+ return recent_topics_dict.get(scope_key, set())
291
+
292
+
293
+ def target_scope_key(target: dict) -> str:
294
+ """Compute the scope key for a target dict (for cooldown lookup).
295
+
296
+ Mirror of :func:`_log_entry_scope_key` for the inbound target side.
297
+ Targets may carry `platform` plus (for Reddit) `subreddit` extracted
298
+ from the thread URL.
299
+ """
300
+ platform = (target.get("platform") or "").lower()
301
+ if not platform:
302
+ return "unknown"
303
+ if platform == "reddit":
304
+ sub = (target.get("subreddit") or "").lower()
305
+ if not sub:
306
+ # Try to extract from canonical_url / thread_url
307
+ url = target.get("canonical_url") or target.get("thread_url") or ""
308
+ import re as _re
309
+ m = _re.search(r"/r/([A-Za-z0-9_]+)", url)
310
+ if m:
311
+ sub = m.group(1).lower()
312
+ if sub:
313
+ sub = sub.lstrip("r/")
314
+ return f"reddit:r/{sub}"
315
+ return "reddit"
316
+ if platform == "twitter":
317
+ return "x"
318
+ return platform
319
+
320
+
223
321
  # ── Fit-floor decision ──────────────────────────────────────────────
224
322
 
225
323
 
@@ -348,6 +446,7 @@ def append_jsonl(path: Path, payload: Dict[str, Any]) -> None:
348
446
 
349
447
  __all__ = [
350
448
  "DEFAULT_COOLDOWN_DAYS",
449
+ "DEFAULT_COOLDOWN_HOURS",
351
450
  "DEFAULT_HIGH_ENGAGEMENT_FLOOR",
352
451
  "SOCIAL_LOG",
353
452
  "DELIMIT_DOMAIN_SIGNALS",
@@ -357,4 +456,7 @@ __all__ = [
357
456
  "append_jsonl",
358
457
  "_extract_topic_fingerprint",
359
458
  "_recent_topic_fingerprints",
459
+ "_log_entry_scope_key",
460
+ "target_scope_key",
461
+ "topics_for_scope",
360
462
  ]
@@ -48,6 +48,55 @@ QUEUE_FILE = Path.home() / ".delimit" / "social_scan_queue.jsonl"
48
48
  DEFAULT_DEDUPE_HOURS = 24 * 7 # don't re-queue a fingerprint within 7 days
49
49
  DEFAULT_EXPIRE_HOURS = 24 * 7 # entries older than 7 days roll to expired
50
50
 
51
+ # Per-platform freshness cap at claim_pending time. Reddit posts decay
52
+ # in comment-visibility VERY fast (Boris-Cherny LED-1335: <6h high-yield,
53
+ # <12h marginal, ~zero after 24h), so drafting on a 3-day-old post wastes
54
+ # a brand-account engagement. Other platforms (github, devto) have longer
55
+ # half-lives — issue threads can be relevant weeks later — so we don't
56
+ # apply the freshness cap there.
57
+ #
58
+ # Founder regression report 2026-05-18: drafts were being generated on
59
+ # posts queued 3+ days earlier because the queue is FIFO and the drafter
60
+ # falls behind the scanner. This filter ensures claim_pending() never
61
+ # returns reddit entries whose `queued_at` is more than CLAIM_FRESHNESS_HOURS
62
+ # old, regardless of queue position.
63
+ CLAIM_FRESHNESS_HOURS_BY_PLATFORM: Dict[str, int] = {
64
+ "reddit": 24,
65
+ # Phase C (2026-05-18): github targets fail ~96% of the time
66
+ # (historical 3256/3389 marked drafted_failed) and the queue grew
67
+ # to 1122 pending dominating FIFO order. 24h cap drains stale crud
68
+ # while preserving fresh github targets the drafter would actually
69
+ # process. Without this, github starves reddit/x/hn even with the
70
+ # round-robin claim (see CLAIM_MAX_PER_PLATFORM).
71
+ "github": 24,
72
+ }
73
+
74
+ # Phase C (2026-05-18): round-robin claim_pending across platforms so a
75
+ # noisy platform doesn't starve quieter ones. Drafter calls claim_pending
76
+ # with limit=10; pre-Phase-C this returned 10 oldest entries regardless
77
+ # of platform, which with github=1122 pending meant 10/10 github and
78
+ # reddit drafts never fired. With this cap, drafter sees a balanced mix.
79
+ # Within-platform order is FIFO (oldest pending first) EXCEPT for
80
+ # platforms in CLAIM_LIFO_PLATFORMS — see below.
81
+ CLAIM_MAX_PER_PLATFORM: int = 3
82
+
83
+ # Phase D (2026-05-18 founder request): "we need first-poster advantage."
84
+ # For time-critical engagement platforms, the drafter should pick the
85
+ # FRESHEST pending entry, not the oldest. Reddit comment visibility decays
86
+ # sharply after the first 15-30 minutes of a thread (the first 5-10 visible
87
+ # comments capture the bulk of upvotes + clickthrough). Pre-Phase-D's
88
+ # within-reddit FIFO meant the drafter pulled 22-24h-old entries (near
89
+ # the freshness cap) instead of brand-new ones.
90
+ #
91
+ # LIFO-within-platform reverses that for the listed platforms: within the
92
+ # eligible bucket, sort newest queued_at first. Across platforms, round-
93
+ # robin still applies. Entries that get displaced by newer ones are
94
+ # naturally cleaned up by the freshness cap groomer.
95
+ #
96
+ # Other platforms (github, devto, etc.) keep FIFO — their content has a
97
+ # longer half-life and oldest-first is the right discipline there.
98
+ CLAIM_LIFO_PLATFORMS: set = {"reddit"}
99
+
51
100
  PENDING = "pending"
52
101
  DRAFTED = "drafted"
53
102
  DRAFTED_FAILED = "drafted_failed"
@@ -184,23 +233,130 @@ def enqueue(target: Dict[str, Any], dedupe_hours: int = DEFAULT_DEDUPE_HOURS) ->
184
233
 
185
234
 
186
235
  def claim_pending(platform: Optional[str] = None, limit: int = 20) -> List[Dict[str, Any]]:
187
- """Return up to ``limit`` pending entries, optionally filtered by platform.
236
+ """Return up to ``limit`` pending entries, with round-robin balancing
237
+ across platforms when ``platform`` is None.
188
238
 
189
239
  Read-only — does NOT mutate state. The caller must call ``mark_drafted``
190
- or ``mark_failed`` once it processes the entry. Returns oldest-first
191
- (FIFO) so the queue drains in scan order.
240
+ or ``mark_failed`` once it processes the entry.
241
+
242
+ Round-robin (Phase C, 2026-05-18): without a platform filter, returns
243
+ at most CLAIM_MAX_PER_PLATFORM entries from any single platform per
244
+ call. Within each platform, oldest-first (FIFO). Across platforms,
245
+ interleaved so the drafter sees a balanced mix instead of saturating
246
+ on whichever platform has the deepest backlog.
247
+
248
+ With an explicit ``platform`` filter, behaves as FIFO over that single
249
+ platform's pending entries (no per-platform cap, since the caller is
250
+ already targeting one platform).
251
+
252
+ Freshness cap (Phase A, 2026-05-18): entries whose platform has a
253
+ CLAIM_FRESHNESS_HOURS_BY_PLATFORM cap and whose ``queued_at`` is
254
+ older than that cap are skipped silently. Those stale entries stay
255
+ in the file with status=pending; the separate ``expire_stale_for_
256
+ freshness_caps`` pass flips them so they don't pile up forever.
192
257
  """
193
- out: List[Dict[str, Any]] = []
194
- # Build a list because we want oldest-first; JSONL append order = FIFO.
195
- for entry in _iter_entries():
258
+ now = datetime.now(timezone.utc)
259
+
260
+ def _is_eligible(entry: Dict[str, Any]) -> bool:
196
261
  if entry.get("status") != PENDING:
197
- continue
262
+ return False
198
263
  if platform and entry.get("platform") != platform:
264
+ return False
265
+ cap = CLAIM_FRESHNESS_HOURS_BY_PLATFORM.get(entry.get("platform"))
266
+ if cap is not None:
267
+ qts = _parse_iso(entry.get("queued_at"))
268
+ if qts is not None and (now - qts) > timedelta(hours=cap):
269
+ return False
270
+ return True
271
+
272
+ # Single-platform filter path: keep legacy strict-FIFO behavior.
273
+ if platform:
274
+ out: List[Dict[str, Any]] = []
275
+ for entry in _iter_entries():
276
+ if not _is_eligible(entry):
277
+ continue
278
+ out.append(entry)
279
+ if len(out) >= limit:
280
+ break
281
+ return out
282
+
283
+ # Round-robin path: group by platform first (preserving within-platform
284
+ # FIFO via iteration order), then cap per-platform and interleave.
285
+ # CRITICAL Phase D change: for CLAIM_LIFO_PLATFORMS, collect the full
286
+ # eligible set per platform first, then sort newest-first BEFORE
287
+ # truncation — otherwise the early-break truncation in the FIFO path
288
+ # would keep the oldest entries even when we want the newest.
289
+ by_platform: Dict[str, List[Dict[str, Any]]] = {}
290
+ for entry in _iter_entries():
291
+ if not _is_eligible(entry):
199
292
  continue
200
- out.append(entry)
201
- if len(out) >= limit:
293
+ plat = entry.get("platform") or "unknown"
294
+ by_platform.setdefault(plat, []).append(entry)
295
+
296
+ # Sort + truncate each bucket.
297
+ for plat in list(by_platform.keys()):
298
+ if plat in CLAIM_LIFO_PLATFORMS:
299
+ # Newest queued_at first. Parse-failures sort last so a
300
+ # corrupted-timestamp entry doesn't block legitimate fresh
301
+ # entries from being claimed.
302
+ by_platform[plat].sort(
303
+ key=lambda e: _parse_iso(e.get("queued_at")) or datetime.min.replace(tzinfo=timezone.utc),
304
+ reverse=True,
305
+ )
306
+ # FIFO platforms keep insertion order (which is JSONL-append =
307
+ # oldest first); no sort needed.
308
+ by_platform[plat] = by_platform[plat][:CLAIM_MAX_PER_PLATFORM]
309
+
310
+ # Interleave: round-robin across platforms in alphabetical order for
311
+ # determinism. Stop when limit is reached or all buckets are drained.
312
+ out2: List[Dict[str, Any]] = []
313
+ plat_order = sorted(by_platform.keys())
314
+ idx = {p: 0 for p in plat_order}
315
+ while len(out2) < limit:
316
+ added = False
317
+ for p in plat_order:
318
+ if idx[p] < len(by_platform[p]):
319
+ out2.append(by_platform[p][idx[p]])
320
+ idx[p] += 1
321
+ added = True
322
+ if len(out2) >= limit:
323
+ break
324
+ if not added:
202
325
  break
203
- return out
326
+ return out2
327
+
328
+
329
+ def expire_stale_for_freshness_caps() -> Dict[str, int]:
330
+ """Roll pending entries past their platform's claim freshness cap to expired.
331
+
332
+ Companion to ``claim_pending``'s in-flight skip: without this, the
333
+ queue file fills up with pending-but-permanently-skipped entries
334
+ that we still re-scan on every claim. Returns a dict
335
+ ``{platform: count_expired}`` for observability.
336
+ """
337
+ now = datetime.now(timezone.utc)
338
+ entries = list(_iter_entries())
339
+ if not entries:
340
+ return {}
341
+ flipped: Dict[str, int] = {}
342
+ changed = False
343
+ for entry in entries:
344
+ if entry.get("status") != PENDING:
345
+ continue
346
+ plat = entry.get("platform")
347
+ cap = CLAIM_FRESHNESS_HOURS_BY_PLATFORM.get(plat)
348
+ if cap is None:
349
+ continue
350
+ qts = _parse_iso(entry.get("queued_at"))
351
+ if qts is None or (now - qts) <= timedelta(hours=cap):
352
+ continue
353
+ entry["status"] = EXPIRED
354
+ entry["error"] = f"expired_freshness_cap_{cap}h"
355
+ flipped[plat] = flipped.get(plat, 0) + 1
356
+ changed = True
357
+ if changed:
358
+ _atomic_rewrite(entries)
359
+ return flipped
204
360
 
205
361
 
206
362
  def _update_entry(fingerprint: str, mutator) -> bool: