delimit-cli 4.1.47 → 4.1.48

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/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## [4.1.48] - 2026-04-09
4
+
5
+ ### Fixed
6
+ - **CRITICAL: CLAUDE.md clobber on upgrade** — `delimit-cli setup` used a loose heuristic (`# Delimit` + `delimit_ledger_context` or `# Delimit AI Guardrails`) to decide whether to replace a user's entire `CLAUDE.md` with the stock template. Any founder-customized CLAUDE.md that happened to mention `delimit_ledger_context` got clobbered on every upgrade — this included 4.1.47's auto-update flow, which destroyed custom auto-trigger rules, paying-customer protection blocks, and incident-derived escalation rules. The clobber path is removed entirely. `upsertDelimitSection` now either upserts between `<!-- delimit:start -->` / `<!-- delimit:end -->` markers (preserving user content above and below), or — if no markers exist — appends the managed section at the bottom, preserving all existing user content verbatim.
7
+ - Same fix applied to `GEMINI.md` in `lib/cross-model-hooks.js` (previously did a whole-file overwrite if it did not contain the detection phrase).
8
+ - Detection marker changed from the prose phrase `Consensus 123` to the stable structural marker `<!-- delimit:start`, so future template copy changes never break preservation logic.
9
+
10
+ ### Security
11
+ - **axios** bumped from `1.13.6` → `1.15.0` to patch GHSA-3p68-rc4w-qgx5 (NO_PROXY hostname normalization bypass → SSRF, severity: critical).
12
+
13
+ ### Changed
14
+ - Stock `CLAUDE.md` template is now minimal (auto-trigger lifecycle, code/commit/deploy gates, audit trail, links). Founder-only sections (Paying Customers, Strategic/Business Operations, Escalation Rules, venture portfolio context) are no longer shipped in the npm package — they belong in `~/.delimit/CLAUDE.md` or `~/.claude/CLAUDE.md` (never touched by `delimit-cli setup`).
15
+
16
+ ### Tests
17
+ - Added two regression tests in `tests/setup-onboarding.test.js` covering (a) the exact legacy-content-preservation case and (b) the founder-customized CLAUDE.md pattern that triggered the 2026-04-09 incident.
18
+
3
19
  ## [4.1.45] - 2026-04-09
4
20
 
5
21
  ### Fixed
@@ -1337,9 +1337,16 @@ function getClaudeMdContent() {
1337
1337
 
1338
1338
  /**
1339
1339
  * Upsert the Delimit section in a file using <!-- delimit:start --> / <!-- delimit:end --> markers.
1340
- * If markers exist, replaces only that region (preserving user content above/below).
1341
- * If no markers exist but old Delimit content is detected, replaces the whole file.
1342
- * If no Delimit content at all, appends the section.
1340
+ *
1341
+ * NEVER clobbers user-authored content outside the markers. The previous behavior
1342
+ * replaced the whole file whenever it detected "old Delimit content" heuristically,
1343
+ * which destroyed founder-customized CLAUDE.md files on every upgrade (v4.1.47 incident).
1344
+ *
1345
+ * Behavior:
1346
+ * - File missing → create with just the managed section.
1347
+ * - File has markers → replace only the region between them (user content above/below preserved).
1348
+ * - File has no markers → append the managed section at the bottom (user content at top preserved).
1349
+ *
1343
1350
  * Returns { action: 'created' | 'updated' | 'unchanged' | 'appended' }
1344
1351
  */
1345
1352
  function upsertDelimitSection(filePath) {
@@ -1354,7 +1361,7 @@ function upsertDelimitSection(filePath) {
1354
1361
 
1355
1362
  const existing = fs.readFileSync(filePath, 'utf-8');
1356
1363
 
1357
- // Check if markers already exist
1364
+ // Check if managed markers already exist
1358
1365
  const startMarkerRe = /<!-- delimit:start[^>]*-->/;
1359
1366
  const endMarker = '<!-- delimit:end -->';
1360
1367
  const hasStart = startMarkerRe.test(existing);
@@ -1367,25 +1374,16 @@ function upsertDelimitSection(filePath) {
1367
1374
  if (currentVersion === version) {
1368
1375
  return { action: 'unchanged' };
1369
1376
  }
1370
- // Replace only the delimit section
1377
+ // Replace only the managed region — preserve content above/below
1371
1378
  const before = existing.substring(0, existing.search(startMarkerRe));
1372
1379
  const after = existing.substring(existing.indexOf(endMarker) + endMarker.length);
1373
1380
  fs.writeFileSync(filePath, before + newSection + after);
1374
1381
  return { action: 'updated' };
1375
1382
  }
1376
1383
 
1377
- // No markers — check for old Delimit content that should be replaced
1378
- const isOldDelimit = existing.includes('# Delimit AI Guardrails') ||
1379
- existing.includes('delimit_init') ||
1380
- existing.includes('persistent memory, verified execution') ||
1381
- (existing.includes('# Delimit') && existing.includes('delimit_ledger_context'));
1382
-
1383
- if (isOldDelimit) {
1384
- fs.writeFileSync(filePath, newSection + '\n');
1385
- return { action: 'updated' };
1386
- }
1387
-
1388
- // File exists with user content but no Delimit section — append
1384
+ // No markers present append the managed section at the bottom.
1385
+ // User content at the top is preserved verbatim. Markers get added so future
1386
+ // upgrades can update just the managed region.
1389
1387
  const separator = existing.endsWith('\n') ? '\n' : '\n\n';
1390
1388
  fs.writeFileSync(filePath, existing + separator + newSection + '\n');
1391
1389
  return { action: 'appended' };
@@ -0,0 +1,445 @@
1
+ """Cross-session MCP hot reload (LED-799).
2
+
3
+ Solves the pain where one Claude session edits ai/*.py and other sessions
4
+ have to restart the MCP server to pick up the change. There are three
5
+ distinct cases this module handles:
6
+
7
+ 1. **Edited helper module** (e.g. ai/content_intel.py changed):
8
+ importlib.reload() the module so tools that lazily `from ai.X import Y`
9
+ inside their function body pick up the new code on the next call.
10
+
11
+ 2. **New helper module** (e.g. ai/foo.py added by another session):
12
+ importlib.import_module() to bring it into sys.modules so subsequent
13
+ lazy imports inside tool bodies succeed.
14
+
15
+ 3. **New @mcp.tool() decoration** in a freshly added module (ai/tools/*.py):
16
+ walk the module globals for fastmcp.tools.tool.FunctionTool instances
17
+ and add them to the live FastMCP tool_manager via add_tool(). New tool
18
+ files become callable without a server restart.
19
+
20
+ Out of scope (still requires restart):
21
+ - Edits to ai/server.py itself. That module is too large, has too many
22
+ side effects on import, and reloading it would create a NEW FastMCP
23
+ instance disconnected from the running server. Convention: put NEW
24
+ tools in ai/tools/<name>.py, not in ai/server.py.
25
+
26
+ Dead-letter behavior: every reload/import is wrapped in try/except. Failures
27
+ are logged to ~/.delimit/logs/hot_reload.jsonl and never crash the server.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import importlib
33
+ import json
34
+ import logging
35
+ import os
36
+ import sys
37
+ import threading
38
+ import time
39
+ import traceback
40
+ from datetime import datetime, timezone
41
+ from pathlib import Path
42
+ from typing import Any, Callable, Dict, List, Optional, Set
43
+
44
+ logger = logging.getLogger("delimit.ai.hot_reload")
45
+
46
+ LOG_DIR = Path.home() / ".delimit" / "logs"
47
+ LOG_FILE = LOG_DIR / "hot_reload.jsonl"
48
+
49
+ # Modules whose reload would do more harm than good. server.py defines the
50
+ # live FastMCP instance — reloading it would create a fresh disconnected
51
+ # instance. Tests confirm reload of these modules creates duplicate state.
52
+ RELOAD_DENY_LIST: Set[str] = {
53
+ "ai.server",
54
+ "ai.hot_reload", # don't reload self
55
+ "ai", # the package itself
56
+ }
57
+
58
+
59
+ # ── logging ──────────────────────────────────────────────────────────
60
+
61
+
62
+ def _log(event: Dict[str, Any]) -> None:
63
+ """Append a structured event to the hot-reload audit log. Never raises."""
64
+ try:
65
+ LOG_DIR.mkdir(parents=True, exist_ok=True)
66
+ event = {
67
+ **event,
68
+ "timestamp": datetime.now(timezone.utc).isoformat(),
69
+ "pid": os.getpid(),
70
+ }
71
+ with open(LOG_FILE, "a", encoding="utf-8") as f:
72
+ f.write(json.dumps(event) + "\n")
73
+ except OSError as e:
74
+ logger.debug("hot_reload log write failed: %s", e)
75
+
76
+
77
+ # ── tool re-registration ──────────────────────────────────────────────
78
+
79
+
80
+ def _is_function_tool(obj: Any) -> bool:
81
+ """True if `obj` is a fastmcp FunctionTool (registered tool)."""
82
+ cls = type(obj)
83
+ return cls.__module__.startswith("fastmcp.") and cls.__name__ == "FunctionTool"
84
+
85
+
86
+ def register_module_tools(mcp: Any, module: Any) -> List[str]:
87
+ """Walk a module's globals and register every FunctionTool against the live mcp.
88
+
89
+ Returns the list of tool keys registered. Existing tools with the same
90
+ key are *replaced* — that lets edits to a tool's metadata or schema
91
+ take effect without a restart.
92
+ """
93
+ if mcp is None or module is None:
94
+ return []
95
+ registered: List[str] = []
96
+ try:
97
+ tool_manager = getattr(mcp, "_tool_manager", None)
98
+ if tool_manager is None or not hasattr(tool_manager, "_tools"):
99
+ return []
100
+ for name, value in list(vars(module).items()):
101
+ if not _is_function_tool(value):
102
+ continue
103
+ try:
104
+ key = getattr(value, "key", name)
105
+ tool_manager._tools[key] = value
106
+ registered.append(key)
107
+ except Exception as e:
108
+ _log({
109
+ "event": "tool_register_failed",
110
+ "module": module.__name__,
111
+ "name": name,
112
+ "error": str(e),
113
+ })
114
+ except Exception as e: # noqa: BLE001
115
+ _log({
116
+ "event": "register_module_tools_failed",
117
+ "module": getattr(module, "__name__", "?"),
118
+ "error": str(e),
119
+ "traceback": traceback.format_exc(limit=3),
120
+ })
121
+ if registered:
122
+ _log({
123
+ "event": "tools_registered",
124
+ "module": getattr(module, "__name__", "?"),
125
+ "count": len(registered),
126
+ "keys": registered,
127
+ })
128
+ return registered
129
+
130
+
131
+ def reload_module(mcp: Any, module_name: str) -> Dict[str, Any]:
132
+ """Reload an existing module and re-register any tools it defines.
133
+
134
+ Returns a status dict with the module name, whether the reload succeeded,
135
+ and the list of tool keys registered. Reload failures keep the previous
136
+ module in place (importlib.reload either replaces atomically or raises).
137
+ """
138
+ if module_name in RELOAD_DENY_LIST:
139
+ return {"module": module_name, "ok": False, "skipped": "deny_list"}
140
+ if module_name not in sys.modules:
141
+ return {"module": module_name, "ok": False, "skipped": "not_loaded"}
142
+ try:
143
+ module = importlib.reload(sys.modules[module_name])
144
+ tools = register_module_tools(mcp, module)
145
+ _log({
146
+ "event": "module_reloaded",
147
+ "module": module_name,
148
+ "tools_registered": tools,
149
+ })
150
+ return {"module": module_name, "ok": True, "tools_registered": tools}
151
+ except Exception as e: # noqa: BLE001
152
+ _log({
153
+ "event": "module_reload_failed",
154
+ "module": module_name,
155
+ "error": str(e),
156
+ "traceback": traceback.format_exc(limit=5),
157
+ })
158
+ return {"module": module_name, "ok": False, "error": str(e)}
159
+
160
+
161
+ def import_new_module(
162
+ mcp: Any,
163
+ file_path: Path,
164
+ package_root: Path,
165
+ package_prefix: str = "ai",
166
+ ) -> Dict[str, Any]:
167
+ """Import a freshly added file under the watched package and register its tools.
168
+
169
+ `file_path` must live under `package_root`. The module name is derived
170
+ from the relative path: ai/tools/foo.py → ai.tools.foo.
171
+ """
172
+ try:
173
+ rel = file_path.relative_to(package_root)
174
+ except ValueError:
175
+ return {"file": str(file_path), "ok": False, "error": "outside_package_root"}
176
+
177
+ parts = list(rel.with_suffix("").parts)
178
+ if not parts:
179
+ return {"file": str(file_path), "ok": False, "error": "invalid_path"}
180
+ if parts[-1] == "__init__":
181
+ parts = parts[:-1]
182
+ if package_prefix:
183
+ # The package_root is the directory CONTAINING the package (e.g. delimit-gateway/),
184
+ # so the relative path already starts with the package name. If not, prepend.
185
+ if not parts or parts[0] != package_prefix:
186
+ parts = [package_prefix] + parts
187
+ module_name = ".".join(parts)
188
+
189
+ if module_name in RELOAD_DENY_LIST:
190
+ return {"file": str(file_path), "module": module_name, "ok": False, "skipped": "deny_list"}
191
+
192
+ try:
193
+ # Critical: drop cached finders so a new file inside an already-imported
194
+ # package becomes visible. Without this, importlib's package finder
195
+ # uses a stale directory listing.
196
+ importlib.invalidate_caches()
197
+ if module_name in sys.modules:
198
+ module = importlib.reload(sys.modules[module_name])
199
+ action = "reloaded"
200
+ else:
201
+ module = importlib.import_module(module_name)
202
+ action = "imported"
203
+ tools = register_module_tools(mcp, module)
204
+ _log({
205
+ "event": "new_module_handled",
206
+ "module": module_name,
207
+ "action": action,
208
+ "tools_registered": tools,
209
+ })
210
+ return {
211
+ "file": str(file_path),
212
+ "module": module_name,
213
+ "action": action,
214
+ "ok": True,
215
+ "tools_registered": tools,
216
+ }
217
+ except Exception as e: # noqa: BLE001
218
+ _log({
219
+ "event": "new_module_import_failed",
220
+ "module": module_name,
221
+ "error": str(e),
222
+ "traceback": traceback.format_exc(limit=5),
223
+ })
224
+ return {
225
+ "file": str(file_path),
226
+ "module": module_name,
227
+ "ok": False,
228
+ "error": str(e),
229
+ }
230
+
231
+
232
+ # ── file watcher ──────────────────────────────────────────────────────
233
+
234
+
235
+ class HotReloadWatcher:
236
+ """Polling-based file watcher (no inotify dependency).
237
+
238
+ Tracks mtimes for every .py file under `watch_dir`. On each tick:
239
+ - New files trigger import_new_module().
240
+ - Changed files trigger reload_module() (unless on the deny list).
241
+ - Deleted files are noted in the log but no action is taken (the
242
+ cached sys.modules entry stays — that's safer than fighting against
243
+ another session that may be mid-edit).
244
+ """
245
+
246
+ def __init__(
247
+ self,
248
+ mcp: Any,
249
+ watch_dir: Path,
250
+ package_root: Path,
251
+ package_prefix: str = "ai",
252
+ interval: float = 2.0,
253
+ ) -> None:
254
+ self.mcp = mcp
255
+ self.watch_dir = Path(watch_dir)
256
+ self.package_root = Path(package_root)
257
+ self.package_prefix = package_prefix
258
+ self.interval = interval
259
+ self._mtimes: Dict[str, float] = {}
260
+ self._stop = threading.Event()
261
+ self._thread: Optional[threading.Thread] = None
262
+ self._snapshot_initial()
263
+
264
+ def _snapshot_initial(self) -> None:
265
+ """Record current mtimes so the first tick doesn't reload everything."""
266
+ for path in self.watch_dir.rglob("*.py"):
267
+ try:
268
+ self._mtimes[str(path)] = path.stat().st_mtime
269
+ except OSError:
270
+ pass
271
+
272
+ def tick(self) -> Dict[str, Any]:
273
+ """Run a single scan pass. Returns counts of actions taken."""
274
+ new_files: List[Path] = []
275
+ changed_files: List[Path] = []
276
+ seen: Set[str] = set()
277
+
278
+ try:
279
+ for path in self.watch_dir.rglob("*.py"):
280
+ key = str(path)
281
+ seen.add(key)
282
+ try:
283
+ mtime = path.stat().st_mtime
284
+ except OSError:
285
+ continue
286
+ prev = self._mtimes.get(key)
287
+ if prev is None:
288
+ new_files.append(path)
289
+ elif mtime > prev:
290
+ changed_files.append(path)
291
+ self._mtimes[key] = mtime
292
+ except OSError as e:
293
+ _log({"event": "watch_scan_error", "error": str(e)})
294
+ return {"new": 0, "changed": 0, "errors": 1}
295
+
296
+ results: Dict[str, Any] = {"new": [], "changed": [], "errors": 0}
297
+ for path in new_files:
298
+ r = import_new_module(self.mcp, path, self.package_root, self.package_prefix)
299
+ results["new"].append(r)
300
+ if not r.get("ok"):
301
+ results["errors"] += 1
302
+
303
+ for path in changed_files:
304
+ module_name = self._path_to_module(path)
305
+ if module_name is None:
306
+ continue
307
+ if module_name in RELOAD_DENY_LIST:
308
+ continue
309
+ r = reload_module(self.mcp, module_name)
310
+ results["changed"].append(r)
311
+ if not r.get("ok") and r.get("skipped") is None:
312
+ results["errors"] += 1
313
+
314
+ return results
315
+
316
+ def _path_to_module(self, path: Path) -> Optional[str]:
317
+ try:
318
+ rel = path.relative_to(self.package_root)
319
+ except ValueError:
320
+ return None
321
+ parts = list(rel.with_suffix("").parts)
322
+ if not parts:
323
+ return None
324
+ if parts[-1] == "__init__":
325
+ parts = parts[:-1]
326
+ if self.package_prefix and (not parts or parts[0] != self.package_prefix):
327
+ parts = [self.package_prefix] + parts
328
+ return ".".join(parts)
329
+
330
+ def _loop(self) -> None:
331
+ _log({"event": "watcher_started", "watch_dir": str(self.watch_dir), "interval": self.interval})
332
+ while not self._stop.is_set():
333
+ try:
334
+ self.tick()
335
+ except Exception as e: # noqa: BLE001
336
+ _log({
337
+ "event": "watcher_tick_error",
338
+ "error": str(e),
339
+ "traceback": traceback.format_exc(limit=3),
340
+ })
341
+ self._stop.wait(timeout=self.interval)
342
+ _log({"event": "watcher_stopped"})
343
+
344
+ def start(self) -> None:
345
+ if self._thread and self._thread.is_alive():
346
+ return
347
+ self._stop.clear()
348
+ self._thread = threading.Thread(
349
+ target=self._loop, name="delimit-hot-reload", daemon=True
350
+ )
351
+ self._thread.start()
352
+
353
+ def stop(self) -> None:
354
+ self._stop.set()
355
+ if self._thread:
356
+ self._thread.join(timeout=5)
357
+
358
+
359
+ # ── module-level singleton + bootstrap helper ─────────────────────────
360
+
361
+
362
+ _singleton: Optional[HotReloadWatcher] = None
363
+ _singleton_lock = threading.Lock()
364
+
365
+
366
+ def start_hot_reload(
367
+ mcp: Any,
368
+ watch_dir: Optional[Path] = None,
369
+ package_root: Optional[Path] = None,
370
+ interval: float = 2.0,
371
+ ) -> Dict[str, Any]:
372
+ """Start the global hot-reload watcher. Idempotent.
373
+
374
+ Args:
375
+ mcp: The live FastMCP instance from ai/server.py.
376
+ watch_dir: Directory to watch. Defaults to the directory containing
377
+ ai/server.py (i.e. the ai/ package directory).
378
+ package_root: Directory whose first child is the package. Used to
379
+ derive module names from file paths. Defaults to the parent of
380
+ watch_dir.
381
+ interval: Poll interval in seconds. Default 2.0.
382
+
383
+ Returns a status dict. Will not raise — failures are logged.
384
+ """
385
+ global _singleton
386
+ with _singleton_lock:
387
+ if _singleton is not None:
388
+ return {"status": "already_running"}
389
+ try:
390
+ if watch_dir is None:
391
+ watch_dir = Path(__file__).parent
392
+ if package_root is None:
393
+ package_root = Path(watch_dir).parent
394
+ _singleton = HotReloadWatcher(
395
+ mcp=mcp,
396
+ watch_dir=Path(watch_dir),
397
+ package_root=Path(package_root),
398
+ interval=interval,
399
+ )
400
+ _singleton.start()
401
+ _log({
402
+ "event": "hot_reload_started",
403
+ "watch_dir": str(watch_dir),
404
+ "package_root": str(package_root),
405
+ "interval": interval,
406
+ })
407
+ return {
408
+ "status": "started",
409
+ "watch_dir": str(watch_dir),
410
+ "package_root": str(package_root),
411
+ "interval": interval,
412
+ }
413
+ except Exception as e: # noqa: BLE001
414
+ _log({
415
+ "event": "hot_reload_start_failed",
416
+ "error": str(e),
417
+ "traceback": traceback.format_exc(limit=5),
418
+ })
419
+ return {"status": "failed", "error": str(e)}
420
+
421
+
422
+ def stop_hot_reload() -> Dict[str, Any]:
423
+ """Stop the global watcher. Idempotent."""
424
+ global _singleton
425
+ with _singleton_lock:
426
+ if _singleton is None:
427
+ return {"status": "not_running"}
428
+ _singleton.stop()
429
+ _singleton = None
430
+ _log({"event": "hot_reload_stopped_via_api"})
431
+ return {"status": "stopped"}
432
+
433
+
434
+ def hot_reload_status() -> Dict[str, Any]:
435
+ """Inspect the watcher state."""
436
+ with _singleton_lock:
437
+ if _singleton is None:
438
+ return {"running": False}
439
+ return {
440
+ "running": True,
441
+ "watch_dir": str(_singleton.watch_dir),
442
+ "package_root": str(_singleton.package_root),
443
+ "interval": _singleton.interval,
444
+ "tracked_files": len(_singleton._mtimes),
445
+ }
@@ -8308,6 +8308,53 @@ def delimit_content_intel_weekly(date: str = "") -> Dict[str, Any]:
8308
8308
  return {"error": str(e)}
8309
8309
 
8310
8310
 
8311
+ # ═══════════════════════════════════════════════════════════════════════
8312
+ # HOT RELOAD (LED-799) — pick up new tools/modules without restart
8313
+ # ═══════════════════════════════════════════════════════════════════════
8314
+
8315
+
8316
+ @mcp.tool()
8317
+ def delimit_hot_reload(action: str = "status", interval: float = 2.0) -> Dict[str, Any]:
8318
+ """Control the cross-session MCP hot-reload watcher (LED-799).
8319
+
8320
+ The watcher polls ai/*.py for new files and changed mtimes, reloads
8321
+ helper modules in place, and registers any new @mcp.tool() decorations
8322
+ against the live FastMCP instance — so other Claude sessions can pick
8323
+ up your edits without restarting the MCP server.
8324
+
8325
+ Limitation: edits to ai/server.py itself still require a restart.
8326
+ Convention: put new tools in ai/tools/<name>.py instead.
8327
+
8328
+ Args:
8329
+ action: 'start', 'stop', 'status', or 'tick' (run a single scan now).
8330
+ interval: Poll interval in seconds. Only used on start. Default 2.0.
8331
+ """
8332
+ from ai import hot_reload as _hr
8333
+ action = (action or "status").strip().lower()
8334
+ if action == "start":
8335
+ return _hr.start_hot_reload(mcp, interval=interval)
8336
+ if action == "stop":
8337
+ return _hr.stop_hot_reload()
8338
+ if action == "tick":
8339
+ with _hr._singleton_lock:
8340
+ watcher = _hr._singleton
8341
+ if watcher is None:
8342
+ return {"error": "watcher not running"}
8343
+ return watcher.tick()
8344
+ return _hr.hot_reload_status()
8345
+
8346
+
8347
+ # Auto-start the watcher unless explicitly disabled. New sessions get the
8348
+ # benefit of cross-session reload without any setup. Set DELIMIT_HOT_RELOAD=0
8349
+ # to opt out (e.g. for tests that need a stable module table).
8350
+ try:
8351
+ if os.environ.get("DELIMIT_HOT_RELOAD", "1") != "0":
8352
+ from ai import hot_reload as _hot_reload_boot
8353
+ _hot_reload_boot.start_hot_reload(mcp)
8354
+ except Exception as _e:
8355
+ logger.warning("hot_reload boot failed (non-fatal): %s", _e)
8356
+
8357
+
8311
8358
  @mcp.tool()
8312
8359
  def delimit_reddit_fetch_thread(thread_id: str) -> Dict[str, Any]:
8313
8360
  """Surgically fetch a single Reddit thread by ID (e.g. 'OSKJVH7f35')."""
@@ -952,6 +952,7 @@ def hot_reload(reason: str = "update") -> Dict[str, Any]:
952
952
  "ai.social",
953
953
  "ai.reddit_scanner",
954
954
  "ai.ledger_manager",
955
+ "ai.deliberation", # added 2026-04-09 per LED-805 — CLI stdin fix needed hot reload
955
956
  "ai.backends.repo_bridge",
956
957
  "ai.backends.tools_infra",
957
958
  "backends.repo_bridge", # alias used by server.py lazy imports
@@ -666,21 +666,43 @@ function installGeminiHooks(tool, hookConfig) {
666
666
  } catch { config = {}; }
667
667
  }
668
668
 
669
- // LED-213: Use canonical Consensus 123 template (condensed for JSON)
669
+ // LED-213: canonical governance template (condensed for JSON).
670
+ // Detect via the stable <!-- delimit:start --> marker, not a prose phrase
671
+ // that may change between versions.
670
672
  const govInstructions = getDelimitSectionCondensed();
673
+ const DELIMIT_MARKER = '<!-- delimit:start';
671
674
 
672
- if (!config.customInstructions || !config.customInstructions.includes('Consensus 123')) {
675
+ if (!config.customInstructions || !config.customInstructions.includes(DELIMIT_MARKER)) {
673
676
  config.customInstructions = govInstructions;
674
677
  changes.push('customInstructions');
675
678
  }
676
679
 
677
680
  fs.writeFileSync(tool.configPath, JSON.stringify(config, null, 2));
678
681
 
679
- // LED-213: Write GEMINI.md with canonical Consensus 123 template
682
+ // GEMINI.md: use the same upsert pattern as CLAUDE.md so user content
683
+ // outside the managed markers is preserved across delimit-cli upgrades.
680
684
  const geminiMd = path.join(geminiDir, 'GEMINI.md');
681
- if (!fs.existsSync(geminiMd) || !fs.readFileSync(geminiMd, 'utf-8').includes('Consensus 123')) {
682
- fs.writeFileSync(geminiMd, getDelimitSection() + '\n');
685
+ const managedSection = getDelimitSection();
686
+ if (!fs.existsSync(geminiMd)) {
687
+ fs.writeFileSync(geminiMd, managedSection + '\n');
683
688
  changes.push('GEMINI.md');
689
+ } else {
690
+ const existing = fs.readFileSync(geminiMd, 'utf-8');
691
+ if (existing.includes(DELIMIT_MARKER) && existing.includes('<!-- delimit:end -->')) {
692
+ // Replace only the managed region
693
+ const before = existing.substring(0, existing.indexOf(DELIMIT_MARKER));
694
+ const after = existing.substring(existing.indexOf('<!-- delimit:end -->') + '<!-- delimit:end -->'.length);
695
+ const updated = before + managedSection + after;
696
+ if (updated !== existing) {
697
+ fs.writeFileSync(geminiMd, updated);
698
+ changes.push('GEMINI.md');
699
+ }
700
+ } else {
701
+ // Append managed section below existing user content
702
+ const sep = existing.endsWith('\n') ? '\n' : '\n\n';
703
+ fs.writeFileSync(geminiMd, existing + sep + managedSection + '\n');
704
+ changes.push('GEMINI.md');
705
+ }
684
706
  }
685
707
 
686
708
  return changes;
@@ -21,85 +21,36 @@ function getDelimitSection() {
21
21
 
22
22
  Unify all AI coding assistants with persistent context, governance, and multi-model debate.
23
23
 
24
- ## Auto-Trigger Rules (Consensus 123)
24
+ ## What Delimit does
25
+ - **API governance** — lint, diff, semver classification, migration guides
26
+ - **Persistent context** — memory and ledger survive across sessions and models
27
+ - **Security audit** — dependency scanning, secret detection
28
+ - **Test verification** — confirm tests actually ran, measure coverage
29
+ - **Multi-model deliberation** — consensus from multiple AI models (3 free, then BYOK)
25
30
 
26
- These rules fire automatically. The AI MUST call the specified tools without asking permission.
27
- The founder reviews and approves via email. The AI's job is to draft, send, and chain — not to ask.
31
+ ## Auto-Trigger Rules
32
+
33
+ These rules fire automatically. Call the listed tools without asking permission.
28
34
 
29
35
  ### Session Lifecycle
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
36
+ - **Session start**: call \`delimit_revive\` + \`delimit_ledger_context\` + \`delimit_gov_health\`
37
+ - **Session end or idle**: call \`delimit_soul_capture\` + \`delimit_session_handoff\`
32
38
  - If returning user, summarize what's pending. If new project, call \`delimit_scan\`.
33
39
 
34
40
  ### Code Development (debounced per edit batch)
35
41
  - After editing UI/CSS: call \`delimit_design_validate_responsive\`
36
42
  - After editing API specs: call \`delimit_lint\` + \`delimit_drift_check\`
37
- - After editing tests: call \`delimit_test_smoke\`
38
- - After any significant code change: call \`delimit_test_smoke\` if applicable tests exist
43
+ - After editing tests or significant code: call \`delimit_test_smoke\`
39
44
 
40
45
  ### Pre-Commit / Pre-Deploy Gates
41
46
  - Before \`git commit\`: call \`delimit_repo_diagnose\`
42
- - Deploy requested: \`delimit_security_audit\` then \`delimit_test_smoke\` then \`delimit_changelog\` then \`delimit_deploy_plan\`
47
+ - Deploy requested: \`delimit_security_audit\` \`delimit_test_smoke\` \`delimit_changelog\` \`delimit_deploy_plan\`
43
48
  - Deploy succeeds: \`delimit_deploy_verify\` + \`delimit_evidence_collect\`
44
49
  - Verify succeeds: \`delimit_ledger_done\` (related task) + \`delimit_notify\`
45
50
 
46
- ### Change Management (update docs with every ship)
47
- - After shipping a feature: update README if CLI commands or tool count changed
48
- - After version bump: update version badge, tool count, test count on landing page
49
- - After npm publish: sync gateway server.py to npm bundle, scrub PII, run security-check.sh
50
- - After changing pricing/tiers: update pricing page and Lemon Squeezy product
51
- - Before deploy: call \`delimit_swarm(action="docs_check")\` to verify docs freshness
52
- - Before submitting a PR to an external repo: call \`delimit_deliberate\` on the diff + PR description (Pro only — if using free tier deliberations and quota is exhausted, skip this gate but log a warning)
53
-
54
- ### Content / Outreach (all drafts, never auto-post)
55
- - Social content drafted: ALWAYS call \`delimit_social_post\` or \`delimit_notify\` immediately
56
- - \`delimit_social_target\` scan completes: call \`delimit_notify\` with target summary
57
- - Screenshot of social thread shared: auto-draft reply + call \`delimit_notify\`
58
- - During ANY social interaction: scan for competitive signals, integration opportunities, and strategic items. Add to strategy ledger if significant.
59
-
60
- ### Strategic / Business Operations
61
- - Strategic opportunity identified: call \`delimit_ledger_add\` immediately
62
- - \`delimit_ledger_add\` fires (non-P0): \`delimit_notify\` (batched, 15-min digest)
63
- - \`delimit_ledger_add\` fires (P0): \`delimit_notify\` (immediate) + \`delimit_agent_dispatch\`
64
- - Conflicting high-severity signals: call \`delimit_deliberate\` for multi-model consensus
65
-
66
- ### Monitoring / Sensors
67
- - GitHub issue state change (closed, merged, labeled): \`delimit_ledger_update\` + \`delimit_notify\` (immediate)
68
- - GitHub issue new comments: \`delimit_ledger_update\` + \`delimit_notify\` (batched digest)
69
- - \`delimit_drift_check\` finds drift: \`delimit_ledger_add\` + \`delimit_notify\`
70
-
71
- ### Approval Loop (Email Control Plane)
72
- - Approval email detected: execute the approved action based on context
73
- - Rejection email detected: \`delimit_ledger_update\` (mark rejected)
74
-
75
51
  ### Audit Trail
76
52
  - After security audit, test run, or deploy: call \`delimit_evidence_collect\`
77
- - Any gate failure: \`delimit_evidence_collect\` + \`delimit_ledger_add\` + \`delimit_notify\` (immediate)
78
-
79
- ### Storm Prevention
80
- - Edit triggers: debounce to once per assistant turn
81
- - Non-P0 notifications: batch into 15-minute digests
82
- - Max 5 immediate notifications per hour (overflow to digest)
83
- - Per-scan fan-out: max 10 targets per notification
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
-
97
- ## What Delimit does:
98
- - **API governance** -- lint, diff, semver classification, migration guides
99
- - **Persistent context** -- memory and ledger survive across sessions and models
100
- - **Security audit** -- dependency scanning, secret detection
101
- - **Test verification** -- confirm tests actually ran, measure coverage
102
- - **Multi-model deliberation** -- consensus from multiple AI models (3 free, then BYOK)
53
+ - Any gate failure: \`delimit_evidence_collect\` + \`delimit_ledger_add\` + \`delimit_notify\`
103
54
 
104
55
  ## GitHub Action
105
56
  Add breaking change detection to any repo:
@@ -109,28 +60,11 @@ Add breaking change detection to any repo:
109
60
  spec: api/openapi.yaml
110
61
  \`\`\`
111
62
 
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)
63
+ ## Project-specific overrides
64
+
65
+ You can add your own rules anywhere **outside** the \`<!-- delimit:start -->\` / \`<!-- delimit:end -->\` markers in this file \`delimit-cli\` upgrades only touch content between the markers and preserve everything else.
66
+
67
+ For user-global overrides (rules that apply to every project and every Claude Code session on this machine), put them in \`~/.claude/CLAUDE.md\` or \`~/.delimit/CLAUDE.md\`. Those files are never shipped in the npm package and never overwritten by \`delimit-cli setup\`.
134
68
 
135
69
  ## Links
136
70
  - Docs: https://delimit.ai/docs
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.47",
4
+ "version": "4.1.48",
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": [
@@ -76,7 +76,7 @@
76
76
  "url": "https://github.com/delimit-ai/delimit-mcp-server.git"
77
77
  },
78
78
  "dependencies": {
79
- "axios": "1.13.6",
79
+ "axios": "1.15.0",
80
80
  "chalk": "^4.1.2",
81
81
  "commander": "^12.1.0",
82
82
  "express": "^4.18.0",