delimit-cli 4.1.41 → 4.1.43
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/gateway/ai/social_daemon.py +281 -18
- package/lib/delimit-template.js +37 -2
- package/package.json +1 -1
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
Social sensing daemon for Delimit.
|
|
3
3
|
|
|
4
4
|
Runs social discovery scans (X, Reddit, GitHub, Dev.to) on a regular interval.
|
|
5
|
-
Deduplicates findings and emits HTML draft emails for human approval.
|
|
5
|
+
Deduplicates findings via SQLite cache and emits HTML draft emails for human approval.
|
|
6
6
|
Also monitors for direct replies to owned posts (LED-300).
|
|
7
7
|
|
|
8
8
|
Consensus 123: Part of the continuous sensing loop.
|
|
@@ -24,6 +24,10 @@ logger = logging.getLogger("delimit.ai.social_daemon")
|
|
|
24
24
|
SCAN_INTERVAL = int(os.environ.get("DELIMIT_SOCIAL_SCAN_INTERVAL", "900"))
|
|
25
25
|
MAX_CONSECUTIVE_FAILURES = 3
|
|
26
26
|
|
|
27
|
+
# Retry config: exponential backoff (5s, 15s, 45s)
|
|
28
|
+
RETRY_DELAYS = [5, 15, 45]
|
|
29
|
+
MAX_RETRIES = len(RETRY_DELAYS)
|
|
30
|
+
|
|
27
31
|
ALERTS_DIR = Path.home() / ".delimit" / "alerts"
|
|
28
32
|
ALERT_FILE = ALERTS_DIR / "social_daemon.json"
|
|
29
33
|
DAEMON_STATE = Path.home() / ".delimit" / "social_daemon_state.json"
|
|
@@ -42,10 +46,12 @@ class SocialDaemonState:
|
|
|
42
46
|
self._lock = threading.Lock()
|
|
43
47
|
self._thread: Optional[threading.Thread] = None
|
|
44
48
|
self._stop_event = threading.Event()
|
|
49
|
+
# Scan stats for compact output
|
|
50
|
+
self.last_scan_stats: Optional[Dict[str, Any]] = None
|
|
45
51
|
|
|
46
52
|
def to_dict(self) -> Dict[str, Any]:
|
|
47
53
|
with self._lock:
|
|
48
|
-
|
|
54
|
+
result = {
|
|
49
55
|
"running": self.running,
|
|
50
56
|
"last_scan": self.last_scan,
|
|
51
57
|
"targets_found": self.targets_found,
|
|
@@ -54,13 +60,18 @@ class SocialDaemonState:
|
|
|
54
60
|
"stopped_reason": self.stopped_reason,
|
|
55
61
|
"scan_interval_seconds": SCAN_INTERVAL,
|
|
56
62
|
}
|
|
63
|
+
if self.last_scan_stats:
|
|
64
|
+
result["last_scan_stats"] = self.last_scan_stats
|
|
65
|
+
return result
|
|
57
66
|
|
|
58
|
-
def record_success(self, found: int):
|
|
67
|
+
def record_success(self, found: int, stats: Optional[Dict[str, Any]] = None):
|
|
59
68
|
with self._lock:
|
|
60
69
|
self.consecutive_failures = 0
|
|
61
70
|
self.targets_found += found
|
|
62
71
|
self.total_scans += 1
|
|
63
72
|
self.last_scan = datetime.now(timezone.utc).isoformat()
|
|
73
|
+
if stats:
|
|
74
|
+
self.last_scan_stats = stats
|
|
64
75
|
|
|
65
76
|
def record_failure(self) -> int:
|
|
66
77
|
with self._lock:
|
|
@@ -71,43 +82,283 @@ class SocialDaemonState:
|
|
|
71
82
|
|
|
72
83
|
_daemon_state = SocialDaemonState()
|
|
73
84
|
|
|
74
|
-
|
|
75
|
-
|
|
85
|
+
|
|
86
|
+
def _scan_with_retry() -> Dict[str, Any]:
|
|
87
|
+
"""Execute scan_targets with exponential backoff retry on failure.
|
|
88
|
+
|
|
89
|
+
Retries up to MAX_RETRIES times with delays of 5s, 15s, 45s.
|
|
90
|
+
"""
|
|
76
91
|
from ai.social_target import scan_targets, process_targets
|
|
77
|
-
|
|
92
|
+
|
|
93
|
+
last_error = None
|
|
94
|
+
for attempt in range(MAX_RETRIES + 1):
|
|
95
|
+
try:
|
|
96
|
+
targets = []
|
|
97
|
+
# Use broad reddit_scanner (scans 25+ subreddits with relevance scoring)
|
|
98
|
+
try:
|
|
99
|
+
from ai.reddit_scanner import scan_all
|
|
100
|
+
reddit_result = scan_all(sort="hot", limit_per_sub=10)
|
|
101
|
+
reddit_targets = reddit_result.get("targets", [])
|
|
102
|
+
# Sort by engagement (score + comments), take top 5 for drafts
|
|
103
|
+
MAX_REDDIT_DRAFTS = 5
|
|
104
|
+
eligible = [rt for rt in reddit_targets if rt.get("priority") in ("high", "medium")]
|
|
105
|
+
eligible.sort(key=lambda t: (t.get("score", 0) or 0) + (t.get("num_comments", 0) or 0) * 2, reverse=True)
|
|
106
|
+
top_ids = set(id(rt) for rt in eligible[:MAX_REDDIT_DRAFTS])
|
|
107
|
+
for rt in reddit_targets:
|
|
108
|
+
if id(rt) in top_ids:
|
|
109
|
+
rt.setdefault("classification", "reply")
|
|
110
|
+
else:
|
|
111
|
+
rt.setdefault("classification", "skip")
|
|
112
|
+
rt.setdefault("platform", "reddit")
|
|
113
|
+
rt.setdefault("venture", "delimit")
|
|
114
|
+
rt.setdefault("fingerprint", f"reddit:{rt.get('id', '')}")
|
|
115
|
+
targets.extend(reddit_targets)
|
|
116
|
+
logger.info("Reddit broad scan: %d targets", len(reddit_targets))
|
|
117
|
+
except Exception as reddit_err:
|
|
118
|
+
logger.warning("Reddit broad scan failed: %s", reddit_err)
|
|
119
|
+
|
|
120
|
+
# Also run venture-based scan for non-Reddit platforms
|
|
121
|
+
try:
|
|
122
|
+
other_targets = scan_targets(platforms=["x", "github", "hn", "devto"])
|
|
123
|
+
targets.extend(other_targets)
|
|
124
|
+
except Exception as other_err:
|
|
125
|
+
logger.warning("Venture scan failed: %s", other_err)
|
|
126
|
+
|
|
127
|
+
return {"targets": targets, "attempt": attempt + 1}
|
|
128
|
+
except Exception as e:
|
|
129
|
+
last_error = e
|
|
130
|
+
if attempt < MAX_RETRIES:
|
|
131
|
+
delay = RETRY_DELAYS[attempt]
|
|
132
|
+
logger.warning(
|
|
133
|
+
"Scan attempt %d/%d failed: %s. Retrying in %ds...",
|
|
134
|
+
attempt + 1, MAX_RETRIES + 1, e, delay,
|
|
135
|
+
)
|
|
136
|
+
# Use stop_event.wait so we can be interrupted during retry sleep
|
|
137
|
+
if _daemon_state._stop_event.wait(timeout=delay):
|
|
138
|
+
# Daemon was stopped during retry
|
|
139
|
+
raise
|
|
140
|
+
else:
|
|
141
|
+
logger.error(
|
|
142
|
+
"All %d scan attempts failed. Last error: %s",
|
|
143
|
+
MAX_RETRIES + 1, e,
|
|
144
|
+
)
|
|
145
|
+
raise
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _build_compact_summary(targets: List[Dict], processed: Dict) -> Dict[str, Any]:
|
|
149
|
+
"""Build a compact scan summary instead of returning all 322 posts.
|
|
150
|
+
|
|
151
|
+
Returns summary counts + only new high-priority posts.
|
|
152
|
+
"""
|
|
153
|
+
# Separate high-priority from regular targets
|
|
154
|
+
high_priority = [
|
|
155
|
+
t for t in targets
|
|
156
|
+
if not t.get("error")
|
|
157
|
+
and t.get("relevance_score", 0) > 0.8
|
|
158
|
+
]
|
|
159
|
+
medium_priority = [
|
|
160
|
+
t for t in targets
|
|
161
|
+
if not t.get("error")
|
|
162
|
+
and 0.3 < t.get("relevance_score", 0) <= 0.8
|
|
163
|
+
]
|
|
164
|
+
auto_ledger = [t for t in targets if t.get("auto_ledger")]
|
|
165
|
+
|
|
166
|
+
# Platform breakdown
|
|
167
|
+
platform_counts: Dict[str, int] = {}
|
|
168
|
+
for t in targets:
|
|
169
|
+
if not t.get("error"):
|
|
170
|
+
p = t.get("platform", "unknown")
|
|
171
|
+
platform_counts[p] = platform_counts.get(p, 0) + 1
|
|
172
|
+
|
|
173
|
+
# Get cache stats if available
|
|
174
|
+
cache_stats = {}
|
|
175
|
+
try:
|
|
176
|
+
from ai.social_cache import get_scan_stats
|
|
177
|
+
cache_stats = get_scan_stats()
|
|
178
|
+
except Exception:
|
|
179
|
+
pass
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
"summary": {
|
|
183
|
+
"total_new_targets": len([t for t in targets if not t.get("error")]),
|
|
184
|
+
"high_priority": len(high_priority),
|
|
185
|
+
"medium_priority": len(medium_priority),
|
|
186
|
+
"auto_ledger_flagged": len(auto_ledger),
|
|
187
|
+
"platform_breakdown": platform_counts,
|
|
188
|
+
"drafted": len(processed.get("drafted", [])),
|
|
189
|
+
"ledger_items": len(processed.get("ledger_items", [])),
|
|
190
|
+
"owner_actions": len(processed.get("owner_actions", [])),
|
|
191
|
+
},
|
|
192
|
+
"high_priority_targets": [
|
|
193
|
+
{
|
|
194
|
+
"fingerprint": t.get("fingerprint"),
|
|
195
|
+
"subreddit": t.get("subreddit"),
|
|
196
|
+
"post_title": t.get("post_title"),
|
|
197
|
+
"relevance_score": t.get("relevance_score"),
|
|
198
|
+
"canonical_url": t.get("canonical_url"),
|
|
199
|
+
"venture": t.get("venture"),
|
|
200
|
+
"auto_ledger": t.get("auto_ledger", False),
|
|
201
|
+
}
|
|
202
|
+
for t in high_priority[:10] # Cap at 10 for compact output
|
|
203
|
+
],
|
|
204
|
+
"cache_stats": cache_stats,
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _send_scan_digest(compact: Dict, processed: Dict) -> None:
|
|
209
|
+
"""Send a digest email summarizing the scan results.
|
|
210
|
+
|
|
211
|
+
Only sends if there are new high-priority targets, new drafts, or new ledger items.
|
|
212
|
+
Suppresses digest if nothing actionable to avoid email fatigue.
|
|
213
|
+
"""
|
|
78
214
|
try:
|
|
79
|
-
|
|
80
|
-
|
|
215
|
+
from ai.notify import send_email
|
|
216
|
+
|
|
217
|
+
s = compact.get("summary", {})
|
|
218
|
+
high = s.get("high_priority", 0)
|
|
219
|
+
drafted = s.get("drafted", 0)
|
|
220
|
+
ledger_items = s.get("ledger_items", 0)
|
|
221
|
+
total = s.get("total_new_targets", 0)
|
|
222
|
+
platforms = s.get("platform_breakdown", {})
|
|
223
|
+
owner_actions = len(processed.get("owner_actions", []))
|
|
224
|
+
|
|
225
|
+
# Only send if there's something actionable
|
|
226
|
+
if high == 0 and drafted == 0 and ledger_items == 0 and owner_actions == 0:
|
|
227
|
+
return
|
|
228
|
+
|
|
229
|
+
lines = []
|
|
230
|
+
lines.append(f"Social scan found {total} new targets across {platforms}.")
|
|
231
|
+
lines.append("")
|
|
232
|
+
|
|
233
|
+
if high > 0:
|
|
234
|
+
lines.append(f"HIGH PRIORITY: {high} targets need attention")
|
|
235
|
+
for t in compact.get("high_priority_targets", [])[:5]:
|
|
236
|
+
sub = t.get("subreddit", t.get("platform", ""))
|
|
237
|
+
title = t.get("title", t.get("text", ""))[:80]
|
|
238
|
+
url = t.get("url", t.get("canonical_url", ""))
|
|
239
|
+
lines.append(f" [{sub}] {title}")
|
|
240
|
+
if url:
|
|
241
|
+
lines.append(f" {url}")
|
|
242
|
+
lines.append("")
|
|
243
|
+
|
|
244
|
+
if drafted > 0:
|
|
245
|
+
lines.append(f"DRAFTS: {drafted} reply drafts created")
|
|
246
|
+
lines.append("")
|
|
247
|
+
# Include actual draft text for ready drafts
|
|
248
|
+
for action in processed.get("owner_actions", []):
|
|
249
|
+
draft_id = action.get("draft_id", "")
|
|
250
|
+
if not draft_id:
|
|
251
|
+
continue
|
|
252
|
+
try:
|
|
253
|
+
from ai.social import list_drafts
|
|
254
|
+
all_drafts = list_drafts(status="pending")
|
|
255
|
+
for d in all_drafts:
|
|
256
|
+
if d.get("draft_id") == draft_id and d.get("quality") == "ready":
|
|
257
|
+
link = action.get("link", "")
|
|
258
|
+
platform = action.get("platform", "X")
|
|
259
|
+
lines.append(f"--- {platform} DRAFT ---")
|
|
260
|
+
if link:
|
|
261
|
+
lines.append(f"REPLY TO: {link}")
|
|
262
|
+
lines.append(f"WHY: {action.get('summary', '')[:100]}")
|
|
263
|
+
lines.append("")
|
|
264
|
+
lines.append("COPY THIS:")
|
|
265
|
+
lines.append(d.get("text", ""))
|
|
266
|
+
lines.append("--- END ---")
|
|
267
|
+
lines.append("")
|
|
268
|
+
break
|
|
269
|
+
except Exception:
|
|
270
|
+
pass
|
|
271
|
+
|
|
272
|
+
if ledger_items > 0:
|
|
273
|
+
lines.append(f"LEDGER: {ledger_items} items added to project ledger")
|
|
274
|
+
lines.append("")
|
|
275
|
+
|
|
276
|
+
if owner_actions > 0:
|
|
277
|
+
lines.append(f"ACTIONS: {owner_actions} items need your review")
|
|
278
|
+
lines.append("")
|
|
279
|
+
|
|
280
|
+
cache = compact.get("cache_stats", {})
|
|
281
|
+
lines.append(f"Cache: {cache.get('total_cached', 0)} posts tracked, "
|
|
282
|
+
f"{cache.get('high_relevance', 0)} high relevance")
|
|
283
|
+
|
|
284
|
+
send_email(
|
|
285
|
+
message="\n".join(lines),
|
|
286
|
+
subject=f"[SOCIAL] {high} high-pri, {drafted} drafts, {total} targets",
|
|
287
|
+
event_type="social_digest",
|
|
288
|
+
)
|
|
289
|
+
except Exception as e:
|
|
290
|
+
logger.warning("Failed to send scan digest email: %s", e)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
_scan_lock = threading.Lock()
|
|
294
|
+
|
|
295
|
+
def scan_once() -> Dict[str, Any]:
|
|
296
|
+
"""Execute a single social scan cycle and process results (LED-238).
|
|
297
|
+
|
|
298
|
+
Uses retry with exponential backoff and returns compact summary.
|
|
299
|
+
Thread-safe: only one scan runs at a time.
|
|
300
|
+
"""
|
|
301
|
+
if not _scan_lock.acquire(blocking=False):
|
|
302
|
+
return {"error": "Scan already in progress", "skipped": True}
|
|
303
|
+
try:
|
|
304
|
+
from ai.social_target import process_targets
|
|
305
|
+
|
|
306
|
+
# 1. DISCOVER: Scan all platforms (with retry)
|
|
307
|
+
scan_result = _scan_with_retry()
|
|
308
|
+
targets = scan_result["targets"]
|
|
309
|
+
attempt = scan_result["attempt"]
|
|
81
310
|
found = len(targets)
|
|
82
|
-
|
|
311
|
+
|
|
83
312
|
# 2. ORCHESTRATE: Process discovered targets (LED-238)
|
|
84
|
-
# draft_replies=True -> emits social_draft emails
|
|
85
|
-
# create_ledger=True -> creates strategic ledger items
|
|
86
313
|
processed = process_targets(targets, draft_replies=True, create_ledger=True)
|
|
314
|
+
|
|
315
|
+
# 3. Build compact summary
|
|
316
|
+
compact = _build_compact_summary(targets, processed)
|
|
317
|
+
|
|
318
|
+
# 4. Write owner action summary
|
|
87
319
|
OWNER_ACTION_SUMMARY.parent.mkdir(parents=True, exist_ok=True)
|
|
88
320
|
OWNER_ACTION_SUMMARY.write_text(json.dumps({
|
|
89
321
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
90
322
|
"targets_found": found,
|
|
323
|
+
"scan_attempt": attempt,
|
|
91
324
|
"owner_actions": len(processed.get("owner_actions", [])),
|
|
92
325
|
"drafted": len(processed.get("drafted", [])),
|
|
93
326
|
"ledger_items": len(processed.get("ledger_items", [])),
|
|
94
327
|
"strategy_items": len(processed.get("strategy_items", [])),
|
|
328
|
+
"compact_summary": compact["summary"],
|
|
95
329
|
}, indent=2) + "\n")
|
|
96
|
-
|
|
97
|
-
|
|
330
|
+
|
|
331
|
+
# 5. Log scan stats
|
|
332
|
+
s = compact["summary"]
|
|
333
|
+
logger.info(
|
|
334
|
+
"Scan complete: %d new targets (%d high-pri, %d med-pri, %d auto-ledger) "
|
|
335
|
+
"in %d attempt(s). Platforms: %s",
|
|
336
|
+
s["total_new_targets"], s["high_priority"], s["medium_priority"],
|
|
337
|
+
s["auto_ledger_flagged"], attempt, s["platform_breakdown"],
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
_daemon_state.record_success(found, stats=compact["summary"])
|
|
341
|
+
|
|
342
|
+
# 6. Send digest email if there are actionable items
|
|
343
|
+
_send_scan_digest(compact, processed)
|
|
344
|
+
|
|
345
|
+
# Return compact output (not all 322 targets)
|
|
98
346
|
return {
|
|
99
347
|
"targets_found": found,
|
|
100
|
-
"
|
|
348
|
+
"scan_attempt": attempt,
|
|
349
|
+
"compact_summary": compact,
|
|
101
350
|
}
|
|
102
351
|
except Exception as e:
|
|
103
352
|
failures = _daemon_state.record_failure()
|
|
104
|
-
logger.error("Social scan failed: %s", e)
|
|
353
|
+
logger.error("Social scan failed after retries: %s", e)
|
|
105
354
|
if failures >= MAX_CONSECUTIVE_FAILURES:
|
|
106
355
|
reason = f"3 consecutive social scan failures. Last: {e}"
|
|
107
356
|
_daemon_state.stopped_reason = reason
|
|
108
357
|
_daemon_state.running = False
|
|
109
358
|
_daemon_state._stop_event.set()
|
|
110
359
|
return {"error": str(e), "consecutive_failures": failures}
|
|
360
|
+
finally:
|
|
361
|
+
_scan_lock.release()
|
|
111
362
|
|
|
112
363
|
def _daemon_loop() -> None:
|
|
113
364
|
"""Main scanning loop."""
|
|
@@ -119,7 +370,13 @@ def _daemon_loop() -> None:
|
|
|
119
370
|
if "error" in result:
|
|
120
371
|
logger.warning("Scan cycle error: %s", result["error"])
|
|
121
372
|
else:
|
|
122
|
-
|
|
373
|
+
summary = result.get("compact_summary", {}).get("summary", {})
|
|
374
|
+
logger.info(
|
|
375
|
+
"Scan cycle done: %d targets, %d high-pri, cache=%s",
|
|
376
|
+
result.get("targets_found", 0),
|
|
377
|
+
summary.get("high_priority", 0),
|
|
378
|
+
result.get("compact_summary", {}).get("cache_stats", {}).get("total_cached", "?"),
|
|
379
|
+
)
|
|
123
380
|
except Exception as e:
|
|
124
381
|
logger.error("Unexpected error in social daemon loop: %s", e)
|
|
125
382
|
failures = _daemon_state.record_failure()
|
|
@@ -161,8 +418,14 @@ def stop_daemon() -> Dict[str, Any]:
|
|
|
161
418
|
return {"status": "stopped", **_daemon_state.to_dict()}
|
|
162
419
|
|
|
163
420
|
def get_daemon_status() -> Dict[str, Any]:
|
|
164
|
-
"""Get current daemon status."""
|
|
165
|
-
|
|
421
|
+
"""Get current daemon status including cache stats."""
|
|
422
|
+
status = _daemon_state.to_dict()
|
|
423
|
+
try:
|
|
424
|
+
from ai.social_cache import get_scan_stats
|
|
425
|
+
status["cache_stats"] = get_scan_stats()
|
|
426
|
+
except Exception:
|
|
427
|
+
pass
|
|
428
|
+
return status
|
|
166
429
|
|
|
167
430
|
def main():
|
|
168
431
|
"""Run as standalone process."""
|
package/lib/delimit-template.js
CHANGED
|
@@ -27,8 +27,8 @@ These rules fire automatically. The AI MUST call the specified tools without ask
|
|
|
27
27
|
The founder reviews and approves via email. The AI's job is to draft, send, and chain — not to ask.
|
|
28
28
|
|
|
29
29
|
### Session Lifecycle
|
|
30
|
-
- **Session start**: call \`delimit_ledger_context\` + \`delimit_gov_health\` + \`delimit_inbox_daemon(action="start")\` + \`delimit_notify_routing(action="status")\`
|
|
31
|
-
- **Session end or idle**: call \`delimit_session_handoff\` to preserve context for next session
|
|
30
|
+
- **Session start**: call \`delimit_revive\` + \`delimit_ledger_context\` + \`delimit_gov_health\` + \`delimit_inbox_daemon(action="start")\` + \`delimit_notify_routing(action="status")\`
|
|
31
|
+
- **Session end or idle**: call \`delimit_soul_capture\` + \`delimit_session_handoff\` to preserve context for next session
|
|
32
32
|
- If returning user, summarize what's pending. If new project, call \`delimit_scan\`.
|
|
33
33
|
|
|
34
34
|
### Code Development (debounced per edit batch)
|
|
@@ -82,6 +82,18 @@ The founder reviews and approves via email. The AI's job is to draft, send, and
|
|
|
82
82
|
- Max 5 immediate notifications per hour (overflow to digest)
|
|
83
83
|
- Per-scan fan-out: max 10 targets per notification
|
|
84
84
|
|
|
85
|
+
## Memory Rules (CRITICAL)
|
|
86
|
+
|
|
87
|
+
Routine code changes are auto-captured from git on session exit. You do NOT need to call memory_store for normal work.
|
|
88
|
+
|
|
89
|
+
ONLY use \`memory_store\` to capture:
|
|
90
|
+
- **Failed approaches** — "tried X, it didn't work because Y" (git only shows what survived, not dead ends)
|
|
91
|
+
- **Architecture decisions** — "chose PostgreSQL over Redis for sessions because Z"
|
|
92
|
+
- **Key context** — customer requirements, API constraints, deployment gotchas
|
|
93
|
+
- **Things that will confuse the next session** — non-obvious setups, workarounds, tech debt
|
|
94
|
+
|
|
95
|
+
Do NOT store: routine code changes, file lists, commit messages, status updates — these are in git.
|
|
96
|
+
|
|
85
97
|
## What Delimit does:
|
|
86
98
|
- **API governance** -- lint, diff, semver classification, migration guides
|
|
87
99
|
- **Persistent context** -- memory and ledger survive across sessions and models
|
|
@@ -97,6 +109,29 @@ Add breaking change detection to any repo:
|
|
|
97
109
|
spec: api/openapi.yaml
|
|
98
110
|
\`\`\`
|
|
99
111
|
|
|
112
|
+
## Paying Customers (CRITICAL — Read Before Any Change)
|
|
113
|
+
|
|
114
|
+
Delimit has paying Pro customers. Every code change, MCP tool modification, server update, or API change MUST consider impact on existing users.
|
|
115
|
+
|
|
116
|
+
### Customer Protection Rules
|
|
117
|
+
- **Before modifying any MCP tool signature** (params, return schema): check if it would break existing Pro users' workflows
|
|
118
|
+
- **Before renaming/removing CLI commands**: these are documented and users depend on them
|
|
119
|
+
- **Before changing license validation**: customers have active license keys (Lemon Squeezy)
|
|
120
|
+
- **Before modifying server.py tool definitions**: Pro users have the MCP server installed locally at ~/.delimit/server/
|
|
121
|
+
- **Before changing JSONL/JSON storage formats**: memory, ledger, evidence files may exist on customer machines
|
|
122
|
+
- **npm publish is a production deploy**: every publish goes to real users, not just us
|
|
123
|
+
- **Gateway → npm sync**: when syncing server.py to the npm bundle, verify no breaking tool changes
|
|
124
|
+
- **Test with \`delimit doctor\`** before any publish to catch config/setup breaks
|
|
125
|
+
- **Backwards compatibility**: new features must not break existing installations. Add, don't remove.
|
|
126
|
+
|
|
127
|
+
### What Constitutes a Breaking Change for Users
|
|
128
|
+
- MCP tool parameter renamed or removed
|
|
129
|
+
- CLI command renamed or removed
|
|
130
|
+
- Storage format change (memories.jsonl, ledger, evidence, license.json)
|
|
131
|
+
- Python import path changes in server.py
|
|
132
|
+
- Hook format changes in settings.json
|
|
133
|
+
- Default behavior changes (e.g., changing what \`delimit scan\` does with no args)
|
|
134
|
+
|
|
100
135
|
## Links
|
|
101
136
|
- Docs: https://delimit.ai/docs
|
|
102
137
|
- GitHub: https://github.com/delimit-ai/delimit-mcp-server
|
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.1.
|
|
4
|
+
"version": "4.1.43",
|
|
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": [
|