delimit-cli 4.5.4 → 4.5.6

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.
@@ -4629,18 +4629,33 @@ program
4629
4629
  const prePushPath = path.join(hooksDir, 'pre-push');
4630
4630
  const marker = '# delimit-governance-hook';
4631
4631
 
4632
+ // Resolution order: local node_modules → global PATH → npx fallback.
4633
+ // npx is last because it can fail with Arborist 'extraneous' errors
4634
+ // when a project's node_modules / lockfile drift (LED-1248).
4632
4635
  const preCommitHook = `#!/bin/sh
4633
4636
  ${marker}
4634
4637
  # Delimit API governance gate
4635
4638
  # Blocks commits with breaking API changes
4636
- npx delimit-cli check --staged
4639
+ if [ -x ./node_modules/.bin/delimit-cli ]; then
4640
+ ./node_modules/.bin/delimit-cli check --staged
4641
+ elif command -v delimit-cli >/dev/null 2>&1; then
4642
+ delimit-cli check --staged
4643
+ else
4644
+ npx delimit-cli check --staged
4645
+ fi
4637
4646
  `;
4638
4647
 
4639
4648
  const prePushHook = `#!/bin/sh
4640
4649
  ${marker}
4641
4650
  # Delimit API governance gate
4642
4651
  # Blocks pushes with breaking API changes
4643
- npx delimit-cli check --base origin/main
4652
+ if [ -x ./node_modules/.bin/delimit-cli ]; then
4653
+ ./node_modules/.bin/delimit-cli check --base origin/main
4654
+ elif command -v delimit-cli >/dev/null 2>&1; then
4655
+ delimit-cli check --base origin/main
4656
+ else
4657
+ npx delimit-cli check --base origin/main
4658
+ fi
4644
4659
  `;
4645
4660
 
4646
4661
  if (action === 'install') {
@@ -26,10 +26,14 @@ try:
26
26
  # Autonomous build loop
27
27
  "delimit_next_task", "delimit_task_complete",
28
28
  "delimit_loop_status", "delimit_loop_config",
29
+ # LED-1253: vendor-news riff MCP wrappers
30
+ "delimit_vendor_news_scan", "delimit_vendor_news_health",
31
+ "delimit_vendor_news_draft",
29
32
  })
30
33
  except ImportError:
31
34
  # license_core not available (development mode or missing binary)
32
35
  import json
36
+ import os
33
37
  import time
34
38
  from pathlib import Path
35
39
 
@@ -78,6 +82,9 @@ except ImportError:
78
82
  # Autonomous build loop
79
83
  "delimit_next_task", "delimit_task_complete",
80
84
  "delimit_loop_status", "delimit_loop_config",
85
+ # LED-1253: vendor-news riff MCP wrappers
86
+ "delimit_vendor_news_scan", "delimit_vendor_news_health",
87
+ "delimit_vendor_news_draft",
81
88
  })
82
89
  FREE_TRIAL_LIMITS = {"delimit_deliberate": 3}
83
90
 
@@ -51,7 +51,7 @@ import subprocess
51
51
  import threading
52
52
  import traceback
53
53
  import uuid
54
- from datetime import datetime, timezone
54
+ from datetime import datetime, timedelta, timezone
55
55
  from pathlib import Path
56
56
  from typing import Any, Dict, List, Optional, Union
57
57
 
@@ -7866,6 +7866,423 @@ def delimit_github_scan(
7866
7866
  return _with_next_steps("github_scan", result)
7867
7867
 
7868
7868
 
7869
+ # ═══════════════════════════════════════════════════════════════════════
7870
+ # VENDOR NEWS RIFF SYSTEM - LED-1250 / LED-1253 (Pro)
7871
+ # ═══════════════════════════════════════════════════════════════════════
7872
+ #
7873
+ # Diagnostic + ad-hoc invocation surface for the vendor-news riff cron.
7874
+ # The cron at scripts/vendor_news_cron.py is the production firing path;
7875
+ # these tools expose the same backend functions (scan_vendor_news,
7876
+ # draft_vendor_riff) for in-session inspection, dry-runs, and one-off
7877
+ # drafting against a specific source tweet.
7878
+
7879
+
7880
+ @mcp.tool()
7881
+ def delimit_vendor_news_scan(dry_run: bool = False) -> Dict[str, Any]:
7882
+ """Scan watchlisted vendor accounts for fresh, high-engagement posts and
7883
+ auto-draft brand-voice Delimit POV riffs (Pro, LED-1253).
7884
+
7885
+ Wraps ``ai.vendor_news.sensor.scan_vendor_news`` + ``draft_vendor_riff``
7886
+ in a single MCP-callable tool. This is the same code path the
7887
+ ``vendor_news_cron.py`` runs every 30 minutes; surfacing it as an MCP
7888
+ tool lets the orchestrator invoke a scan on demand (e.g. when an X
7889
+ thread looks ride-able and the next cron tick is too far away).
7890
+
7891
+ When ``dry_run=True``, the sensor still polls (cache-friendly) but
7892
+ suppresses its JSONL log write AND skips the drafter entirely so no
7893
+ queue insertion happens and the per-vendor 24h rate cap is not
7894
+ consumed. Useful for "what would fire if I ran the cron now?"
7895
+ inspection without side effects.
7896
+
7897
+ Args:
7898
+ dry_run: If True, run the sensor only (no drafter, no queue,
7899
+ no rate-cap consumption). Default False.
7900
+
7901
+ Returns:
7902
+ Dict with ``stats``, ``triggered``, ``queued``, ``rejected``,
7903
+ ``rate_capped``, ``errors`` — same shape as the cron summary.
7904
+ """
7905
+ from ai.license import require_premium
7906
+ gate = require_premium("vendor_news_scan")
7907
+ if gate:
7908
+ return gate
7909
+
7910
+ try:
7911
+ from ai.vendor_news import scan_vendor_news, draft_vendor_riff
7912
+ except Exception as exc:
7913
+ return _with_next_steps("vendor_news_scan", {
7914
+ "error": "vendor_news_unavailable",
7915
+ "message": f"could not import ai.vendor_news: {exc}",
7916
+ })
7917
+
7918
+ try:
7919
+ scan = scan_vendor_news(dry_run=dry_run)
7920
+ except Exception as exc:
7921
+ return _with_next_steps("vendor_news_scan", {
7922
+ "error": "scan_failed",
7923
+ "message": str(exc),
7924
+ })
7925
+
7926
+ triggered = scan.get("triggered") or []
7927
+ stats = scan.get("stats") or {}
7928
+
7929
+ queued = 0
7930
+ rejected = 0
7931
+ rate_capped = 0
7932
+ drafter_errors: List[Dict[str, Any]] = []
7933
+ drafts: List[Dict[str, Any]] = []
7934
+
7935
+ if not dry_run:
7936
+ for tw in triggered:
7937
+ try:
7938
+ res = draft_vendor_riff(tw)
7939
+ except Exception as exc:
7940
+ drafter_errors.append({"id": tw.get("id"), "error": str(exc)})
7941
+ continue
7942
+ decision = res.get("decision")
7943
+ reason = res.get("reason", "")
7944
+ drafts.append({
7945
+ "source_id": tw.get("id"),
7946
+ "vendor": tw.get("vendor"),
7947
+ "decision": decision,
7948
+ "reason": reason,
7949
+ })
7950
+ if decision == "queue":
7951
+ queued += 1
7952
+ elif reason == "rate_capped":
7953
+ rate_capped += 1
7954
+ else:
7955
+ rejected += 1
7956
+
7957
+ result = {
7958
+ "stats": stats,
7959
+ "triggered": [
7960
+ {
7961
+ "id": t.get("id"),
7962
+ "vendor": t.get("vendor"),
7963
+ "url": t.get("url"),
7964
+ "trigger_reason": t.get("trigger_reason"),
7965
+ "metrics": t.get("metrics"),
7966
+ }
7967
+ for t in triggered
7968
+ ],
7969
+ "queued": queued,
7970
+ "rejected": rejected,
7971
+ "rate_capped": rate_capped,
7972
+ "errors": list(scan.get("errors") or []) + drafter_errors,
7973
+ "drafts": drafts,
7974
+ "dry_run": bool(dry_run),
7975
+ }
7976
+ return _with_next_steps("vendor_news_scan", result)
7977
+
7978
+
7979
+ @mcp.tool()
7980
+ def delimit_vendor_news_health() -> Dict[str, Any]:
7981
+ """Health check for the vendor-news riff system (Pro, LED-1253).
7982
+
7983
+ Returns a snapshot of:
7984
+ * cron installation status (greps ``crontab -l`` for vendor_news_cron.py)
7985
+ * last sensor run timestamp + stats (from sensor JSONL log)
7986
+ * last 24h queued P0 vendor_news_riff entries (from tweet_queue.json)
7987
+ * last 24h rejected entries grouped by reason (from rejected JSONL)
7988
+ * watchlist account count
7989
+ * estimated daily live-call budget consumption
7990
+
7991
+ Use this when you want to quickly answer "is the cron firing? are
7992
+ drafts landing? what's getting rejected?" without grepping logs.
7993
+ """
7994
+ from ai.license import require_premium
7995
+ gate = require_premium("vendor_news_health")
7996
+ if gate:
7997
+ return gate
7998
+
7999
+ from ai.vendor_news.sensor import SENSOR_LOG_PATH, WATCHLIST_PATH, load_watchlist
8000
+ from ai.vendor_news.drafter import TWEET_QUEUE_PATH, REJECTED_LOG_PATH
8001
+
8002
+ out: Dict[str, Any] = {
8003
+ "cron_installed": False,
8004
+ "last_run_ts": None,
8005
+ "last_run_stats": None,
8006
+ "recent_queued_count_24h": 0,
8007
+ "recent_rejected_count_24h": 0,
8008
+ "recent_rejection_reasons_24h": {},
8009
+ "watchlist_account_count": 0,
8010
+ "daily_budget_used_estimate": 0,
8011
+ }
8012
+
8013
+ # 1) crontab check
8014
+ try:
8015
+ proc = subprocess.run(
8016
+ ["crontab", "-l"],
8017
+ capture_output=True, text=True, timeout=5,
8018
+ )
8019
+ if proc.returncode == 0 and "vendor_news_cron.py" in (proc.stdout or ""):
8020
+ out["cron_installed"] = True
8021
+ except (FileNotFoundError, subprocess.SubprocessError, OSError):
8022
+ # crontab binary missing (containers, CI) — cron_installed stays False
8023
+ out["cron_installed"] = False
8024
+
8025
+ cutoff = datetime.now(timezone.utc) - timedelta(hours=24)
8026
+
8027
+ # 2) sensor log: last run + 24h budget estimate
8028
+ try:
8029
+ if SENSOR_LOG_PATH.exists():
8030
+ last_line = None
8031
+ budget_used = 0
8032
+ with open(SENSOR_LOG_PATH, "r", encoding="utf-8") as f:
8033
+ for line in f:
8034
+ line = line.strip()
8035
+ if not line:
8036
+ continue
8037
+ try:
8038
+ entry = json.loads(line)
8039
+ except (json.JSONDecodeError, ValueError):
8040
+ continue
8041
+ last_line = entry
8042
+ ts_raw = entry.get("ts")
8043
+ try:
8044
+ ts_norm = ts_raw[:-1] + "+00:00" if ts_raw and ts_raw.endswith("Z") else ts_raw
8045
+ ts = datetime.fromisoformat(ts_norm) if ts_norm else None
8046
+ except (ValueError, TypeError):
8047
+ ts = None
8048
+ if ts is not None:
8049
+ if ts.tzinfo is None:
8050
+ ts = ts.replace(tzinfo=timezone.utc)
8051
+ if ts >= cutoff:
8052
+ budget_used += int(entry.get("live_calls") or 0)
8053
+ if last_line:
8054
+ out["last_run_ts"] = last_line.get("ts")
8055
+ out["last_run_stats"] = {
8056
+ k: v for k, v in last_line.items()
8057
+ if k not in ("triggered_ids", "error_handles")
8058
+ }
8059
+ out["daily_budget_used_estimate"] = budget_used
8060
+ except OSError:
8061
+ pass
8062
+
8063
+ # 3) tweet_queue.json: 24h queued vendor_news_riff entries
8064
+ try:
8065
+ if TWEET_QUEUE_PATH.exists():
8066
+ data = json.loads(TWEET_QUEUE_PATH.read_text(encoding="utf-8"))
8067
+ if isinstance(data, list):
8068
+ count = 0
8069
+ for entry in data:
8070
+ if not isinstance(entry, dict):
8071
+ continue
8072
+ if entry.get("priority") != "P0":
8073
+ continue
8074
+ if entry.get("category") != "vendor_news_riff":
8075
+ continue
8076
+ added_raw = entry.get("added_at")
8077
+ try:
8078
+ added_norm = (
8079
+ added_raw[:-1] + "+00:00"
8080
+ if added_raw and added_raw.endswith("Z")
8081
+ else added_raw
8082
+ )
8083
+ added = datetime.fromisoformat(added_norm) if added_norm else None
8084
+ except (ValueError, TypeError):
8085
+ added = None
8086
+ if added is None:
8087
+ continue
8088
+ if added.tzinfo is None:
8089
+ added = added.replace(tzinfo=timezone.utc)
8090
+ if added >= cutoff:
8091
+ count += 1
8092
+ out["recent_queued_count_24h"] = count
8093
+ except (OSError, json.JSONDecodeError, ValueError):
8094
+ pass
8095
+
8096
+ # 4) rejected JSONL: 24h count + reason histogram
8097
+ try:
8098
+ if REJECTED_LOG_PATH.exists():
8099
+ count = 0
8100
+ reasons: Dict[str, int] = {}
8101
+ with open(REJECTED_LOG_PATH, "r", encoding="utf-8") as f:
8102
+ for line in f:
8103
+ line = line.strip()
8104
+ if not line:
8105
+ continue
8106
+ try:
8107
+ entry = json.loads(line)
8108
+ except (json.JSONDecodeError, ValueError):
8109
+ continue
8110
+ ts_raw = entry.get("ts")
8111
+ try:
8112
+ ts_norm = (
8113
+ ts_raw[:-1] + "+00:00"
8114
+ if ts_raw and ts_raw.endswith("Z")
8115
+ else ts_raw
8116
+ )
8117
+ ts = datetime.fromisoformat(ts_norm) if ts_norm else None
8118
+ except (ValueError, TypeError):
8119
+ ts = None
8120
+ if ts is None:
8121
+ continue
8122
+ if ts.tzinfo is None:
8123
+ ts = ts.replace(tzinfo=timezone.utc)
8124
+ if ts < cutoff:
8125
+ continue
8126
+ count += 1
8127
+ reason = entry.get("reason") or "unknown"
8128
+ reasons[reason] = reasons.get(reason, 0) + 1
8129
+ out["recent_rejected_count_24h"] = count
8130
+ out["recent_rejection_reasons_24h"] = reasons
8131
+ except OSError:
8132
+ pass
8133
+
8134
+ # 5) watchlist account count
8135
+ try:
8136
+ cfg = load_watchlist()
8137
+ accounts = cfg.get("accounts") or []
8138
+ out["watchlist_account_count"] = len(accounts)
8139
+ except Exception:
8140
+ pass
8141
+
8142
+ return _with_next_steps("vendor_news_health", out)
8143
+
8144
+
8145
+ @mcp.tool()
8146
+ def delimit_vendor_news_draft(tweet_id: str = "", dry_run: bool = False) -> Dict[str, Any]:
8147
+ """Draft a brand-voice Delimit-POV riff for a specific X tweet (Pro, LED-1253).
8148
+
8149
+ Fetches the source tweet via the cached twttr241 path (same one
8150
+ ``delimit_x_fetch`` uses), shapes it into the dict ``draft_vendor_riff``
8151
+ expects, then runs the riff drafter end-to-end (rate cap, source-fit
8152
+ pre-filter, generator, capability validator, fit floor, queue insert).
8153
+
8154
+ When ``dry_run=True``, the underlying drafter still produces text and
8155
+ runs validators, but the queue insertion is skipped — useful for
8156
+ inspecting what the riff would look like without committing it. The
8157
+ 24h per-vendor rate cap IS still consulted (we don't bypass it on
8158
+ dry runs because spurious dry runs would otherwise look like riffs
8159
+ in the rejected log).
8160
+
8161
+ Args:
8162
+ tweet_id: Source X tweet id (numeric string) or full x.com URL.
8163
+ dry_run: If True, suppress queue insertion. Default False.
8164
+ """
8165
+ from ai.license import require_premium
8166
+ gate = require_premium("vendor_news_draft")
8167
+ if gate:
8168
+ return gate
8169
+
8170
+ raw = (tweet_id or "").strip()
8171
+ if not raw:
8172
+ return _with_next_steps("vendor_news_draft", {
8173
+ "error": "missing_tweet_id",
8174
+ "message": "tweet_id is required (status id or x.com URL)",
8175
+ })
8176
+
8177
+ # Normalize id (accept URL or bare id)
8178
+ try:
8179
+ from ai.social_target import extract_status_id, fetch_tweet_by_id
8180
+ except Exception as exc:
8181
+ return _with_next_steps("vendor_news_draft", {
8182
+ "error": "social_target_unavailable",
8183
+ "message": str(exc),
8184
+ })
8185
+
8186
+ sid = extract_status_id(raw)
8187
+ if not sid:
8188
+ return _with_next_steps("vendor_news_draft", {
8189
+ "error": "invalid_tweet_id",
8190
+ "message": f"could not parse status id from {raw!r}",
8191
+ })
8192
+
8193
+ fetched = fetch_tweet_by_id(sid)
8194
+ if not isinstance(fetched, dict) or fetched.get("error"):
8195
+ return _with_next_steps("vendor_news_draft", {
8196
+ "error": "fetch_failed",
8197
+ "message": (fetched or {}).get("error", "unknown fetch error"),
8198
+ "tweet_id": sid,
8199
+ })
8200
+
8201
+ # Map watchlist vendor metadata onto the source author. Falls back
8202
+ # gracefully if the tweet author isn't in the watchlist (e.g.
8203
+ # founder is testing a one-off riff against an off-watchlist post).
8204
+ try:
8205
+ from ai.vendor_news.sensor import load_watchlist
8206
+ from ai.vendor_news import draft_vendor_riff
8207
+ except Exception as exc:
8208
+ return _with_next_steps("vendor_news_draft", {
8209
+ "error": "vendor_news_unavailable",
8210
+ "message": str(exc),
8211
+ })
8212
+
8213
+ author = (fetched.get("author") or "").lstrip("@")
8214
+ vendor_name = ""
8215
+ products: List[str] = []
8216
+ try:
8217
+ cfg = load_watchlist()
8218
+ for acc in cfg.get("accounts") or []:
8219
+ if (acc.get("handle") or "").lstrip("@").lower() == author.lower():
8220
+ vendor_name = acc.get("vendor", "") or author
8221
+ products = list(acc.get("products") or [])
8222
+ break
8223
+ except Exception:
8224
+ pass
8225
+ if not vendor_name:
8226
+ vendor_name = author or "unknown"
8227
+
8228
+ triggered = {
8229
+ "id": fetched.get("id") or sid,
8230
+ "text": fetched.get("text") or "",
8231
+ "author": author,
8232
+ "url": fetched.get("url") or f"https://x.com/i/status/{sid}",
8233
+ "created_at": fetched.get("created_at") or "",
8234
+ "metrics": fetched.get("metrics") or {},
8235
+ "vendor": vendor_name,
8236
+ "products": products,
8237
+ "trigger_reason": "manual_draft",
8238
+ }
8239
+
8240
+ # On dry_run, route the queue write to a temp path so the real
8241
+ # tweet queue is untouched. Drafter still runs validator + fit gates.
8242
+ queue_path_override = None
8243
+ if dry_run:
8244
+ try:
8245
+ import tempfile as _tempfile
8246
+ tmp = _tempfile.NamedTemporaryFile(
8247
+ mode="w", suffix=".json", prefix="vendor_news_dry_",
8248
+ delete=False, encoding="utf-8",
8249
+ )
8250
+ tmp.write("[]")
8251
+ tmp.close()
8252
+ queue_path_override = Path(tmp.name)
8253
+ except Exception:
8254
+ queue_path_override = None
8255
+
8256
+ try:
8257
+ if queue_path_override is not None:
8258
+ res = draft_vendor_riff(triggered, queue_path=queue_path_override)
8259
+ else:
8260
+ res = draft_vendor_riff(triggered)
8261
+ except Exception as exc:
8262
+ return _with_next_steps("vendor_news_draft", {
8263
+ "error": "drafter_failed",
8264
+ "message": str(exc),
8265
+ "tweet_id": sid,
8266
+ })
8267
+
8268
+ out = {
8269
+ "decision": res.get("decision"),
8270
+ "text": res.get("text"),
8271
+ "reason": res.get("reason"),
8272
+ "queue_entry": res.get("queue_entry") if not dry_run else None,
8273
+ "validator_result": res.get("validator_result"),
8274
+ "fit_result": res.get("fit_result"),
8275
+ "source": {
8276
+ "id": triggered["id"],
8277
+ "author": author,
8278
+ "vendor": vendor_name,
8279
+ "url": triggered["url"],
8280
+ },
8281
+ "dry_run": bool(dry_run),
8282
+ }
8283
+ return _with_next_steps("vendor_news_draft", out)
8284
+
8285
+
7869
8286
  # ═══════════════════════════════════════════════════════════════════════
7870
8287
  # CONTENT ENGINE - Autonomous video + tweet pipeline (Pro)
7871
8288
  # ═══════════════════════════════════════════════════════════════════════
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "delimit-cli",
3
3
  "mcpName": "io.github.delimit-ai/delimit-mcp-server",
4
- "version": "4.5.4",
4
+ "version": "4.5.6",
5
5
  "description": "Unify Claude Code, Codex, Cursor, and Gemini CLI with persistent context, governance, and multi-model debate.",
6
6
  "main": "index.js",
7
7
  "files": [