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 +16 -0
- package/bin/delimit-setup.js +15 -17
- package/gateway/ai/hot_reload.py +445 -0
- package/gateway/ai/server.py +47 -0
- package/gateway/ai/swarm.py +1 -0
- package/lib/cross-model-hooks.js +27 -5
- package/lib/delimit-template.js +19 -85
- package/package.json +2 -2
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
|
package/bin/delimit-setup.js
CHANGED
|
@@ -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
|
-
*
|
|
1341
|
-
*
|
|
1342
|
-
*
|
|
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
|
|
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 —
|
|
1378
|
-
|
|
1379
|
-
|
|
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
|
+
}
|
package/gateway/ai/server.py
CHANGED
|
@@ -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')."""
|
package/gateway/ai/swarm.py
CHANGED
|
@@ -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
|
package/lib/cross-model-hooks.js
CHANGED
|
@@ -666,21 +666,43 @@ function installGeminiHooks(tool, hookConfig) {
|
|
|
666
666
|
} catch { config = {}; }
|
|
667
667
|
}
|
|
668
668
|
|
|
669
|
-
// LED-213:
|
|
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(
|
|
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
|
-
//
|
|
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
|
-
|
|
682
|
-
|
|
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;
|
package/lib/delimit-template.js
CHANGED
|
@@ -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
|
-
##
|
|
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
|
-
|
|
27
|
-
|
|
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\`
|
|
31
|
-
- **Session end or idle**: call \`delimit_soul_capture\` + \`delimit_session_handoff\`
|
|
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\`
|
|
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\`
|
|
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
|
-
##
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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.
|
|
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.
|
|
79
|
+
"axios": "1.15.0",
|
|
80
80
|
"chalk": "^4.1.2",
|
|
81
81
|
"commander": "^12.1.0",
|
|
82
82
|
"express": "^4.18.0",
|