delimit-cli 4.1.47 → 4.1.49
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 +34 -0
- package/bin/delimit-setup.js +20 -19
- 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 +57 -11
- package/lib/delimit-template.js +19 -85
- package/lib/hooks-installer.js +19 -11
- package/package.json +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,39 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [4.1.49] - 2026-04-09
|
|
4
|
+
|
|
5
|
+
### Fixed (full preservation audit follow-up to 4.1.48)
|
|
6
|
+
- **Project `.claude/settings.json` hooks clobber** — `installClaudeHooks` was replacing the project-level `.claude/settings.json` hooks object with the merged-with-global config, propagating global hooks into every project file and wiping any project-local hooks the user had set. Now merges only Delimit-owned hook groups (entries whose command contains `delimit`) into existing project hooks; project-specific user hooks survive.
|
|
7
|
+
- **Gemini `general.defaultApprovalMode` clobber** — `delimit-cli setup` was force-setting Gemini's `defaultApprovalMode` to `auto_edit` on every run, overwriting whatever the user had chosen (e.g. `manual`). Now only sets it when missing.
|
|
8
|
+
- **`~/.claude.json` MCP hooks replacement** — `lib/hooks-installer.js` (opt-in via `delimit-cli hooks install`) replaced `preCommand` / `postCommand` / `authentication` / `audit` keys on every install. Now only fills in missing keys, preserving any user-chosen MCP hook commands.
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- **`tests/setup-no-clobber.test.js`** — dedicated regression suite that runs setup helpers against synthetic fresh-user HOME directories with pre-populated user customizations (project hooks, Gemini approval mode, custom MCP hook commands) and asserts none get clobbered. 5 tests, all passing.
|
|
12
|
+
|
|
13
|
+
### Audit results
|
|
14
|
+
- Audited every `fs.writeFileSync` in `bin/delimit-setup.js`, `lib/cross-model-hooks.js`, `lib/hooks-installer.js`, `adapters/cursor-rules.js`, and `scripts/postinstall.js`.
|
|
15
|
+
- All remaining writes are either delimit-owned (shims, hook scripts, generated `delimit.md`), guarded by `!fs.existsSync` (models.json, social_target_config.json, codex empty file), or surgical merges that preserve user content (`.mcp.json` mcpServers, `.claude/settings.json` allowList, `.codex/config.toml` mcp_servers.delimit block, `.cursor/mcp.json` mcpServers, rc-file PATH append).
|
|
16
|
+
- The full preservation contract is now: `delimit-cli setup` may safely run on any user machine, including via the shim auto-update flow, without destroying user state. New installs and upgrades are equivalent for everything except delimit-owned files.
|
|
17
|
+
|
|
18
|
+
### Tests
|
|
19
|
+
- 129/129 passing (was 124).
|
|
20
|
+
|
|
21
|
+
## [4.1.48] - 2026-04-09
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
- **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.
|
|
25
|
+
- 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).
|
|
26
|
+
- 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.
|
|
27
|
+
|
|
28
|
+
### Security
|
|
29
|
+
- **axios** bumped from `1.13.6` → `1.15.0` to patch GHSA-3p68-rc4w-qgx5 (NO_PROXY hostname normalization bypass → SSRF, severity: critical).
|
|
30
|
+
|
|
31
|
+
### Changed
|
|
32
|
+
- 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`).
|
|
33
|
+
|
|
34
|
+
### Tests
|
|
35
|
+
- 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.
|
|
36
|
+
|
|
3
37
|
## [4.1.45] - 2026-04-09
|
|
4
38
|
|
|
5
39
|
### Fixed
|
package/bin/delimit-setup.js
CHANGED
|
@@ -430,9 +430,12 @@ async function main() {
|
|
|
430
430
|
cwd: path.join(DELIMIT_HOME, 'server'),
|
|
431
431
|
env: { PYTHONPATH: path.join(DELIMIT_HOME, 'server') }
|
|
432
432
|
};
|
|
433
|
-
// Auto-approve all tools — users should not be prompted for every Delimit call
|
|
433
|
+
// Auto-approve all tools — users should not be prompted for every Delimit call.
|
|
434
|
+
// Only set if missing — never clobber the user's chosen approval mode on upgrade.
|
|
434
435
|
if (!geminiConfig.general) geminiConfig.general = {};
|
|
435
|
-
geminiConfig.general.defaultApprovalMode
|
|
436
|
+
if (!geminiConfig.general.defaultApprovalMode) {
|
|
437
|
+
geminiConfig.general.defaultApprovalMode = 'auto_edit';
|
|
438
|
+
}
|
|
436
439
|
fs.writeFileSync(GEMINI_CONFIG, JSON.stringify(geminiConfig, null, 2));
|
|
437
440
|
if (geminiExisted) {
|
|
438
441
|
await logp(` ${green('✓')} Updated Delimit paths in Gemini CLI config`);
|
|
@@ -1337,9 +1340,16 @@ function getClaudeMdContent() {
|
|
|
1337
1340
|
|
|
1338
1341
|
/**
|
|
1339
1342
|
* Upsert the Delimit section in a file using <!-- delimit:start --> / <!-- delimit:end --> markers.
|
|
1340
|
-
*
|
|
1341
|
-
*
|
|
1342
|
-
*
|
|
1343
|
+
*
|
|
1344
|
+
* NEVER clobbers user-authored content outside the markers. The previous behavior
|
|
1345
|
+
* replaced the whole file whenever it detected "old Delimit content" heuristically,
|
|
1346
|
+
* which destroyed founder-customized CLAUDE.md files on every upgrade (v4.1.47 incident).
|
|
1347
|
+
*
|
|
1348
|
+
* Behavior:
|
|
1349
|
+
* - File missing → create with just the managed section.
|
|
1350
|
+
* - File has markers → replace only the region between them (user content above/below preserved).
|
|
1351
|
+
* - File has no markers → append the managed section at the bottom (user content at top preserved).
|
|
1352
|
+
*
|
|
1343
1353
|
* Returns { action: 'created' | 'updated' | 'unchanged' | 'appended' }
|
|
1344
1354
|
*/
|
|
1345
1355
|
function upsertDelimitSection(filePath) {
|
|
@@ -1354,7 +1364,7 @@ function upsertDelimitSection(filePath) {
|
|
|
1354
1364
|
|
|
1355
1365
|
const existing = fs.readFileSync(filePath, 'utf-8');
|
|
1356
1366
|
|
|
1357
|
-
// Check if markers already exist
|
|
1367
|
+
// Check if managed markers already exist
|
|
1358
1368
|
const startMarkerRe = /<!-- delimit:start[^>]*-->/;
|
|
1359
1369
|
const endMarker = '<!-- delimit:end -->';
|
|
1360
1370
|
const hasStart = startMarkerRe.test(existing);
|
|
@@ -1367,25 +1377,16 @@ function upsertDelimitSection(filePath) {
|
|
|
1367
1377
|
if (currentVersion === version) {
|
|
1368
1378
|
return { action: 'unchanged' };
|
|
1369
1379
|
}
|
|
1370
|
-
// Replace only the
|
|
1380
|
+
// Replace only the managed region — preserve content above/below
|
|
1371
1381
|
const before = existing.substring(0, existing.search(startMarkerRe));
|
|
1372
1382
|
const after = existing.substring(existing.indexOf(endMarker) + endMarker.length);
|
|
1373
1383
|
fs.writeFileSync(filePath, before + newSection + after);
|
|
1374
1384
|
return { action: 'updated' };
|
|
1375
1385
|
}
|
|
1376
1386
|
|
|
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
|
|
1387
|
+
// No markers present — append the managed section at the bottom.
|
|
1388
|
+
// User content at the top is preserved verbatim. Markers get added so future
|
|
1389
|
+
// upgrades can update just the managed region.
|
|
1389
1390
|
const separator = existing.endsWith('\n') ? '\n' : '\n\n';
|
|
1390
1391
|
fs.writeFileSync(filePath, existing + separator + newSection + '\n');
|
|
1391
1392
|
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
|
@@ -577,14 +577,38 @@ echo ""
|
|
|
577
577
|
const configJson = JSON.stringify(config, null, 2);
|
|
578
578
|
for (const target of writeTargets) {
|
|
579
579
|
try {
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
const existing = JSON.parse(fs.readFileSync(target, 'utf-8'));
|
|
583
|
-
existing.hooks = config.hooks;
|
|
584
|
-
fs.writeFileSync(target, JSON.stringify(existing, null, 2));
|
|
585
|
-
} else {
|
|
580
|
+
if (target === configPath) {
|
|
581
|
+
// Global ~/.claude/settings.json: write the merged config we built
|
|
586
582
|
fs.writeFileSync(target, configJson);
|
|
583
|
+
continue;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// Project settings (.claude/settings.json in cwd): merge ONLY the
|
|
587
|
+
// Delimit-added hook entries into existing project hooks. Never
|
|
588
|
+
// overwrite the project's own hook entries with global ones.
|
|
589
|
+
// Previous behavior (`existing.hooks = config.hooks`) propagated
|
|
590
|
+
// every global hook into project files, wiping project-local hooks
|
|
591
|
+
// and leaking unrelated user customizations across repos.
|
|
592
|
+
let existing = {};
|
|
593
|
+
if (fs.existsSync(target)) {
|
|
594
|
+
try { existing = JSON.parse(fs.readFileSync(target, 'utf-8')); } catch { existing = {}; }
|
|
595
|
+
}
|
|
596
|
+
if (!existing.hooks) existing.hooks = {};
|
|
597
|
+
|
|
598
|
+
for (const [event, groups] of Object.entries(config.hooks || {})) {
|
|
599
|
+
if (!Array.isArray(groups)) continue;
|
|
600
|
+
if (!existing.hooks[event]) existing.hooks[event] = [];
|
|
601
|
+
for (const group of groups) {
|
|
602
|
+
const cmds = (group.hooks || []).map(h => h.command || '');
|
|
603
|
+
// Only propagate Delimit-owned hook groups to project files
|
|
604
|
+
if (!cmds.some(c => c.includes('delimit'))) continue;
|
|
605
|
+
const alreadyHas = existing.hooks[event].some(eg =>
|
|
606
|
+
(eg.hooks || []).some(h => cmds.includes(h.command))
|
|
607
|
+
);
|
|
608
|
+
if (!alreadyHas) existing.hooks[event].push(group);
|
|
609
|
+
}
|
|
587
610
|
}
|
|
611
|
+
fs.writeFileSync(target, JSON.stringify(existing, null, 2));
|
|
588
612
|
} catch {}
|
|
589
613
|
}
|
|
590
614
|
return changes;
|
|
@@ -666,21 +690,43 @@ function installGeminiHooks(tool, hookConfig) {
|
|
|
666
690
|
} catch { config = {}; }
|
|
667
691
|
}
|
|
668
692
|
|
|
669
|
-
// LED-213:
|
|
693
|
+
// LED-213: canonical governance template (condensed for JSON).
|
|
694
|
+
// Detect via the stable <!-- delimit:start --> marker, not a prose phrase
|
|
695
|
+
// that may change between versions.
|
|
670
696
|
const govInstructions = getDelimitSectionCondensed();
|
|
697
|
+
const DELIMIT_MARKER = '<!-- delimit:start';
|
|
671
698
|
|
|
672
|
-
if (!config.customInstructions || !config.customInstructions.includes(
|
|
699
|
+
if (!config.customInstructions || !config.customInstructions.includes(DELIMIT_MARKER)) {
|
|
673
700
|
config.customInstructions = govInstructions;
|
|
674
701
|
changes.push('customInstructions');
|
|
675
702
|
}
|
|
676
703
|
|
|
677
704
|
fs.writeFileSync(tool.configPath, JSON.stringify(config, null, 2));
|
|
678
705
|
|
|
679
|
-
//
|
|
706
|
+
// GEMINI.md: use the same upsert pattern as CLAUDE.md so user content
|
|
707
|
+
// outside the managed markers is preserved across delimit-cli upgrades.
|
|
680
708
|
const geminiMd = path.join(geminiDir, 'GEMINI.md');
|
|
681
|
-
|
|
682
|
-
|
|
709
|
+
const managedSection = getDelimitSection();
|
|
710
|
+
if (!fs.existsSync(geminiMd)) {
|
|
711
|
+
fs.writeFileSync(geminiMd, managedSection + '\n');
|
|
683
712
|
changes.push('GEMINI.md');
|
|
713
|
+
} else {
|
|
714
|
+
const existing = fs.readFileSync(geminiMd, 'utf-8');
|
|
715
|
+
if (existing.includes(DELIMIT_MARKER) && existing.includes('<!-- delimit:end -->')) {
|
|
716
|
+
// Replace only the managed region
|
|
717
|
+
const before = existing.substring(0, existing.indexOf(DELIMIT_MARKER));
|
|
718
|
+
const after = existing.substring(existing.indexOf('<!-- delimit:end -->') + '<!-- delimit:end -->'.length);
|
|
719
|
+
const updated = before + managedSection + after;
|
|
720
|
+
if (updated !== existing) {
|
|
721
|
+
fs.writeFileSync(geminiMd, updated);
|
|
722
|
+
changes.push('GEMINI.md');
|
|
723
|
+
}
|
|
724
|
+
} else {
|
|
725
|
+
// Append managed section below existing user content
|
|
726
|
+
const sep = existing.endsWith('\n') ? '\n' : '\n\n';
|
|
727
|
+
fs.writeFileSync(geminiMd, existing + sep + managedSection + '\n');
|
|
728
|
+
changes.push('GEMINI.md');
|
|
729
|
+
}
|
|
684
730
|
}
|
|
685
731
|
|
|
686
732
|
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/lib/hooks-installer.js
CHANGED
|
@@ -185,29 +185,37 @@ class DelimitHooksInstaller {
|
|
|
185
185
|
|
|
186
186
|
async configureClaudeCode() {
|
|
187
187
|
const claudeConfigPath = path.join(process.env.HOME, '.claude.json');
|
|
188
|
-
|
|
188
|
+
|
|
189
189
|
if (fs.existsSync(claudeConfigPath)) {
|
|
190
190
|
try {
|
|
191
191
|
const config = JSON.parse(fs.readFileSync(claudeConfigPath, 'utf8'));
|
|
192
|
-
|
|
193
|
-
//
|
|
192
|
+
|
|
193
|
+
// Preserve any existing hooks the user has set. Only fill in
|
|
194
|
+
// Delimit MCP hooks if those specific keys are missing —
|
|
195
|
+
// never overwrite a user-chosen preCommand/postCommand.
|
|
194
196
|
if (!config.hooks) {
|
|
195
197
|
config.hooks = {};
|
|
196
198
|
}
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
199
|
+
const delimitHooks = {
|
|
200
|
+
preCommand: path.join(this.mcpHooksDir, 'pre-mcp-call'),
|
|
201
|
+
postCommand: path.join(this.mcpHooksDir, 'post-mcp-call'),
|
|
202
|
+
authentication: path.join(this.mcpHooksDir, 'mcp-auth'),
|
|
203
|
+
audit: path.join(this.mcpHooksDir, 'mcp-audit'),
|
|
204
|
+
};
|
|
205
|
+
for (const [key, value] of Object.entries(delimitHooks)) {
|
|
206
|
+
if (!config.hooks[key]) {
|
|
207
|
+
config.hooks[key] = value;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Add Delimit governance settings (own namespace, safe to set)
|
|
204
212
|
config.delimitGovernance = {
|
|
205
213
|
enabled: true,
|
|
206
214
|
agent: 'http://127.0.0.1:7823',
|
|
207
215
|
mode: 'auto',
|
|
208
216
|
hooks: this.mcpHooks.map(h => path.join(this.mcpHooksDir, h))
|
|
209
217
|
};
|
|
210
|
-
|
|
218
|
+
|
|
211
219
|
fs.writeFileSync(claudeConfigPath, JSON.stringify(config, null, 2));
|
|
212
220
|
console.log(chalk.green(' ✓ Claude Code configuration updated'));
|
|
213
221
|
} catch (e) {
|
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.49",
|
|
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": [
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
"postinstall": "node scripts/postinstall.js",
|
|
36
36
|
"sync-gateway": "bash scripts/sync-gateway.sh",
|
|
37
37
|
"prepublishOnly": "bash scripts/publish-ci-guard.sh && npm run sync-gateway && bash scripts/security-check.sh",
|
|
38
|
-
"test": "node --test tests/setup-onboarding.test.js tests/setup-matrix.test.js tests/config-export-import.test.js tests/cross-model-hooks.test.js tests/golden-path.test.js tests/v420-features.test.js"
|
|
38
|
+
"test": "node --test tests/setup-onboarding.test.js tests/setup-matrix.test.js tests/setup-no-clobber.test.js tests/config-export-import.test.js tests/cross-model-hooks.test.js tests/golden-path.test.js tests/v420-features.test.js"
|
|
39
39
|
},
|
|
40
40
|
"keywords": [
|
|
41
41
|
"openapi",
|
|
@@ -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",
|