smart-claude-memory-mcp 2.1.0
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/.claude-plugin/plugin.json +38 -0
- package/CHANGELOG.md +52 -0
- package/LICENSE +21 -0
- package/README.md +790 -0
- package/dist/chunker.js +33 -0
- package/dist/chunker.js.map +1 -0
- package/dist/config.js +23 -0
- package/dist/config.js.map +1 -0
- package/dist/curriculum/daemon.js +190 -0
- package/dist/curriculum/daemon.js.map +1 -0
- package/dist/curriculum/scanner.js +237 -0
- package/dist/curriculum/scanner.js.map +1 -0
- package/dist/index.js +429 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/migrations.js +128 -0
- package/dist/lib/migrations.js.map +1 -0
- package/dist/ollama.js +59 -0
- package/dist/ollama.js.map +1 -0
- package/dist/project-detect.js +102 -0
- package/dist/project-detect.js.map +1 -0
- package/dist/project.js +26 -0
- package/dist/project.js.map +1 -0
- package/dist/sleep/daemon.js +215 -0
- package/dist/sleep/daemon.js.map +1 -0
- package/dist/sleep/miner.js +285 -0
- package/dist/sleep/miner.js.map +1 -0
- package/dist/supabase.js +405 -0
- package/dist/supabase.js.map +1 -0
- package/dist/telemetry/emit.js +19 -0
- package/dist/telemetry/emit.js.map +1 -0
- package/dist/telemetry/pruner.js +141 -0
- package/dist/telemetry/pruner.js.map +1 -0
- package/dist/telemetry/types.js +2 -0
- package/dist/telemetry/types.js.map +1 -0
- package/dist/tools/backlog.js +599 -0
- package/dist/tools/backlog.js.map +1 -0
- package/dist/tools/batch-freeze-patterns.js +243 -0
- package/dist/tools/batch-freeze-patterns.js.map +1 -0
- package/dist/tools/bloat-audit.js +101 -0
- package/dist/tools/bloat-audit.js.map +1 -0
- package/dist/tools/checkpoint.js +259 -0
- package/dist/tools/checkpoint.js.map +1 -0
- package/dist/tools/compact.js +60 -0
- package/dist/tools/compact.js.map +1 -0
- package/dist/tools/conflict.js +102 -0
- package/dist/tools/conflict.js.map +1 -0
- package/dist/tools/curriculum.js +225 -0
- package/dist/tools/curriculum.js.map +1 -0
- package/dist/tools/frozen-cache.js +106 -0
- package/dist/tools/frozen-cache.js.map +1 -0
- package/dist/tools/health.js +368 -0
- package/dist/tools/health.js.map +1 -0
- package/dist/tools/hygiene.js +309 -0
- package/dist/tools/hygiene.js.map +1 -0
- package/dist/tools/image.js +107 -0
- package/dist/tools/image.js.map +1 -0
- package/dist/tools/list-global-patterns.js +101 -0
- package/dist/tools/list-global-patterns.js.map +1 -0
- package/dist/tools/orchestrator.js +113 -0
- package/dist/tools/orchestrator.js.map +1 -0
- package/dist/tools/policy.js +90 -0
- package/dist/tools/policy.js.map +1 -0
- package/dist/tools/refactor.js +220 -0
- package/dist/tools/refactor.js.map +1 -0
- package/dist/tools/save.js +42 -0
- package/dist/tools/save.js.map +1 -0
- package/dist/tools/search.js +189 -0
- package/dist/tools/search.js.map +1 -0
- package/dist/tools/setup.js +868 -0
- package/dist/tools/setup.js.map +1 -0
- package/dist/tools/shared-schemas.js +24 -0
- package/dist/tools/shared-schemas.js.map +1 -0
- package/dist/tools/skills.js +174 -0
- package/dist/tools/skills.js.map +1 -0
- package/dist/tools/sleep.js +239 -0
- package/dist/tools/sleep.js.map +1 -0
- package/dist/tools/sovereign-constitution.js +319 -0
- package/dist/tools/sovereign-constitution.js.map +1 -0
- package/dist/tools/summarize.js +55 -0
- package/dist/tools/summarize.js.map +1 -0
- package/dist/tools/sync.js +255 -0
- package/dist/tools/sync.js.map +1 -0
- package/dist/tools/system_dashboard.js +181 -0
- package/dist/tools/system_dashboard.js.map +1 -0
- package/dist/tools/update-rule.js +15 -0
- package/dist/tools/update-rule.js.map +1 -0
- package/dist/tools/verification.js +333 -0
- package/dist/tools/verification.js.map +1 -0
- package/dist/trajectory/daemon.js +270 -0
- package/dist/trajectory/daemon.js.map +1 -0
- package/dist/trajectory/stripper.js +124 -0
- package/dist/trajectory/stripper.js.map +1 -0
- package/dist/trajectory/summarizer.js +77 -0
- package/dist/trajectory/summarizer.js.map +1 -0
- package/dist/transactions/checkpoint.js +272 -0
- package/dist/transactions/checkpoint.js.map +1 -0
- package/dist/verification-gate.js +43 -0
- package/dist/verification-gate.js.map +1 -0
- package/dist/version.js +16 -0
- package/dist/version.js.map +1 -0
- package/hooks/README.md +54 -0
- package/hooks/md-policy.py +497 -0
- package/marketplace.json +13 -0
- package/package.json +66 -0
|
@@ -0,0 +1,497 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Zero-Local-MD policy + Guardian hook (PreToolUse for Write|Edit|Bash).
|
|
4
|
+
|
|
5
|
+
Enforces four rules:
|
|
6
|
+
1. Zero-Local-MD: only CLAUDE.md / README.md / ARCHITECTURE.md allowed at project root.
|
|
7
|
+
2. 750-line hard limit: block writes that PUSH a file past 750 lines. Files already
|
|
8
|
+
over the limit are "grandfathered" — edits allowed with a warning banner.
|
|
9
|
+
3. Frozen features: for files matching a configured pattern, block `Write`; Edit only.
|
|
10
|
+
4. Hard Stop / Manual Test Gate: if a pending-verification flag file exists, block
|
|
11
|
+
Write/Edit/Bash until confirm_verification clears it.
|
|
12
|
+
|
|
13
|
+
Environment variables:
|
|
14
|
+
CLAUDE_MD_POLICY_WORKSPACE absolute path of the project root (required for MD rule)
|
|
15
|
+
CLAUDE_MD_POLICY_ALLOW_ROOT_MD comma-separated allowlist (default: CLAUDE.md,README.md,ARCHITECTURE.md)
|
|
16
|
+
CLAUDE_MD_POLICY_TOKEN_LIMIT soft token limit for CLAUDE.md/MEMORY.md (default 3000)
|
|
17
|
+
SMART_CLAUDE_MEMORY_GATE_DIR where the verification flag lives (default: ~/.claude-memory)
|
|
18
|
+
SMART_CLAUDE_MEMORY_LINE_LIMIT override the 750-line limit
|
|
19
|
+
SMART_CLAUDE_MEMORY_FROZEN_PATTERNS comma-separated substrings that mark a file as frozen
|
|
20
|
+
SMART_CLAUDE_MEMORY_ORCHESTRATOR_MODE hard-block direct Write/Edit/Bash in the Orchestrator session
|
|
21
|
+
|
|
22
|
+
Legacy CLAUDE_MEMORY_* names are still honored as a one-time fallback; will be removed in v1.2.0.
|
|
23
|
+
"""
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
import json
|
|
26
|
+
import os
|
|
27
|
+
import shutil
|
|
28
|
+
import sys
|
|
29
|
+
from datetime import datetime, timezone
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
|
|
32
|
+
ALLOW_ROOT_DEFAULT = "CLAUDE.md,README.md,ARCHITECTURE.md"
|
|
33
|
+
BYTES_PER_TOKEN = 4
|
|
34
|
+
|
|
35
|
+
# Auto-generated files that bypass the 750-line hygiene check entirely.
|
|
36
|
+
EXCLUDE_EXACT_BASENAMES = {"types.ts"}
|
|
37
|
+
EXCLUDE_SUFFIXES = (".arb", ".l10n.dart", ".g.dart", ".freezed.dart")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def is_excluded(path: Path) -> bool:
|
|
41
|
+
name = path.name.lower()
|
|
42
|
+
if path.name in EXCLUDE_EXACT_BASENAMES:
|
|
43
|
+
return True
|
|
44
|
+
return any(name.endswith(suf) for suf in EXCLUDE_SUFFIXES)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def env_path(name: str) -> Path | None:
|
|
48
|
+
raw = os.environ.get(name, "")
|
|
49
|
+
if not raw:
|
|
50
|
+
return None
|
|
51
|
+
try:
|
|
52
|
+
return Path(raw).expanduser().resolve()
|
|
53
|
+
except OSError:
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def allow_root() -> set[str]:
|
|
58
|
+
raw = os.environ.get("CLAUDE_MD_POLICY_ALLOW_ROOT_MD", ALLOW_ROOT_DEFAULT)
|
|
59
|
+
return {n.strip() for n in raw.split(",") if n.strip()}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def token_soft_limit() -> int:
|
|
63
|
+
try:
|
|
64
|
+
return int(os.environ.get("CLAUDE_MD_POLICY_TOKEN_LIMIT", "3000"))
|
|
65
|
+
except ValueError:
|
|
66
|
+
return 3000
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def line_hard_limit() -> int:
|
|
70
|
+
# TODO(v1.2.0): drop the legacy CLAUDE_MEMORY_LINE_LIMIT fallback.
|
|
71
|
+
raw = os.environ.get(
|
|
72
|
+
"SMART_CLAUDE_MEMORY_LINE_LIMIT",
|
|
73
|
+
os.environ.get("CLAUDE_MEMORY_LINE_LIMIT", "750"),
|
|
74
|
+
)
|
|
75
|
+
try:
|
|
76
|
+
return int(raw)
|
|
77
|
+
except ValueError:
|
|
78
|
+
return 750
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
import re as _re
|
|
82
|
+
import unicodedata as _unicodedata
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _slugify(s: str) -> str:
|
|
86
|
+
"""Must mirror src/project.ts slugify() so cache project keys line up."""
|
|
87
|
+
s = _unicodedata.normalize("NFKD", s or "").lower()
|
|
88
|
+
s = "".join(c for c in s if not _unicodedata.combining(c))
|
|
89
|
+
s = _re.sub(r"[^a-z0-9\s_-]", "", s)
|
|
90
|
+
s = s.strip()
|
|
91
|
+
s = _re.sub(r"[\s_]+", "-", s)
|
|
92
|
+
s = _re.sub(r"-+", "-", s)
|
|
93
|
+
s = s.strip("-")
|
|
94
|
+
return s or "default"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def frozen_patterns_env() -> list[str]:
|
|
98
|
+
# TODO(v1.2.0): drop the legacy CLAUDE_MEMORY_FROZEN_PATTERNS fallback.
|
|
99
|
+
raw = os.environ.get(
|
|
100
|
+
"SMART_CLAUDE_MEMORY_FROZEN_PATTERNS",
|
|
101
|
+
os.environ.get("CLAUDE_MEMORY_FROZEN_PATTERNS", ""),
|
|
102
|
+
)
|
|
103
|
+
return [p.strip() for p in raw.split(",") if p.strip()]
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def frozen_patterns_cache() -> list[str]:
|
|
107
|
+
"""Read patterns the MCP server exported for the current workspace.
|
|
108
|
+
|
|
109
|
+
The MCP server writes ~/.claude-memory/frozen-patterns.json on startup
|
|
110
|
+
and after every addFrozenPattern/removeFrozenPattern call. Reading that
|
|
111
|
+
file is cheap; spawning a pg client per hook invocation would not be.
|
|
112
|
+
"""
|
|
113
|
+
cache = gate_dir() / "frozen-patterns.json"
|
|
114
|
+
if not cache.exists():
|
|
115
|
+
return []
|
|
116
|
+
ws = env_path("CLAUDE_MD_POLICY_WORKSPACE")
|
|
117
|
+
if ws is None:
|
|
118
|
+
return []
|
|
119
|
+
project_id = _slugify(ws.name)
|
|
120
|
+
try:
|
|
121
|
+
data = json.loads(cache.read_text("utf8"))
|
|
122
|
+
except (OSError, ValueError):
|
|
123
|
+
return []
|
|
124
|
+
entries = (data.get("projects") or {}).get(project_id, [])
|
|
125
|
+
out: list[str] = []
|
|
126
|
+
for e in entries:
|
|
127
|
+
if isinstance(e, str):
|
|
128
|
+
out.append(e)
|
|
129
|
+
elif isinstance(e, dict) and isinstance(e.get("pattern"), str):
|
|
130
|
+
out.append(e["pattern"])
|
|
131
|
+
return out
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def frozen_patterns() -> list[str]:
|
|
135
|
+
"""Combined list: env-var configured + cloud-synced. Deduplicated."""
|
|
136
|
+
seen: set[str] = set()
|
|
137
|
+
merged: list[str] = []
|
|
138
|
+
for p in frozen_patterns_env() + frozen_patterns_cache():
|
|
139
|
+
if p and p not in seen:
|
|
140
|
+
seen.add(p)
|
|
141
|
+
merged.append(p)
|
|
142
|
+
return merged
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def gate_dir() -> Path:
|
|
146
|
+
# TODO(v1.2.0): drop the legacy CLAUDE_MEMORY_GATE_DIR fallback.
|
|
147
|
+
# The on-disk dir `~/.claude-memory` is intentionally preserved to keep existing backups discoverable.
|
|
148
|
+
raw = os.environ.get(
|
|
149
|
+
"SMART_CLAUDE_MEMORY_GATE_DIR",
|
|
150
|
+
os.environ.get("CLAUDE_MEMORY_GATE_DIR", ""),
|
|
151
|
+
)
|
|
152
|
+
if raw:
|
|
153
|
+
return Path(raw).expanduser()
|
|
154
|
+
return Path.home() / ".claude-memory"
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def flag_path() -> Path:
|
|
158
|
+
return gate_dir() / "verification-pending.json"
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def backup_root() -> Path:
|
|
162
|
+
return gate_dir() / "backups"
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def backup_index_path() -> Path:
|
|
166
|
+
return gate_dir() / "backup-index.json"
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _iso_timestamp_for_path() -> str:
|
|
170
|
+
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H-%M-%S-%fZ")
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _update_backup_index(source: str, backup: str, tool_name: str, ts: str) -> None:
|
|
174
|
+
"""Append-or-replace a {source → latest-backup} record the TS side reads
|
|
175
|
+
to tell Claude where to recover from if verification fails. Never throws."""
|
|
176
|
+
idx_path = backup_index_path()
|
|
177
|
+
try:
|
|
178
|
+
if idx_path.exists():
|
|
179
|
+
data = json.loads(idx_path.read_text("utf8"))
|
|
180
|
+
else:
|
|
181
|
+
data = {"entries": {}}
|
|
182
|
+
except (OSError, ValueError):
|
|
183
|
+
data = {"entries": {}}
|
|
184
|
+
entries = data.setdefault("entries", {})
|
|
185
|
+
entries[source] = {"backup": backup, "tool": tool_name, "timestamp": ts}
|
|
186
|
+
data["updated_at"] = ts
|
|
187
|
+
try:
|
|
188
|
+
idx_path.parent.mkdir(parents=True, exist_ok=True)
|
|
189
|
+
idx_path.write_text(json.dumps(data, indent=2), "utf8")
|
|
190
|
+
except OSError:
|
|
191
|
+
pass
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _make_backup(target: Path, tool_name: str) -> tuple[str | None, str | None]:
|
|
195
|
+
"""Copy target into ~/.claude-memory/backups/<project>/<ts>/<relpath>.
|
|
196
|
+
Returns (backup_path, error_message). Both None if nothing to back up."""
|
|
197
|
+
if not target.exists() or not target.is_file():
|
|
198
|
+
return (None, None)
|
|
199
|
+
|
|
200
|
+
ws = env_path("CLAUDE_MD_POLICY_WORKSPACE")
|
|
201
|
+
project_slug = _slugify(ws.name) if ws is not None else "default"
|
|
202
|
+
ts = _iso_timestamp_for_path()
|
|
203
|
+
|
|
204
|
+
try:
|
|
205
|
+
rel = target.relative_to(ws) if ws is not None else Path(target.name)
|
|
206
|
+
except ValueError:
|
|
207
|
+
rel = Path(target.name)
|
|
208
|
+
|
|
209
|
+
dest = backup_root() / project_slug / ts / rel
|
|
210
|
+
try:
|
|
211
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
212
|
+
shutil.copy2(str(target), str(dest))
|
|
213
|
+
except OSError as e:
|
|
214
|
+
return (None, f"backup copy failed: {e}")
|
|
215
|
+
|
|
216
|
+
_update_backup_index(str(target), str(dest), tool_name, ts)
|
|
217
|
+
return (str(dest), None)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _target_matches_frozen(target: Path) -> bool:
|
|
221
|
+
target_str = str(target).replace("\\", "/")
|
|
222
|
+
for pat in frozen_patterns():
|
|
223
|
+
if pat and pat in target_str:
|
|
224
|
+
return True
|
|
225
|
+
return False
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
# ─── checks ────────────────────────────────────────────────────────────────
|
|
229
|
+
|
|
230
|
+
def check_orchestrator_advisory(tool_name: str) -> dict | None:
|
|
231
|
+
"""Hard-block enforcement for the Orchestrator pattern (v1.1.0).
|
|
232
|
+
|
|
233
|
+
Enable with SMART_CLAUDE_MEMORY_ORCHESTRATOR_MODE=1. Hard-blocks direct
|
|
234
|
+
Write/Edit/Bash calls in the main session, forcing delegation to a
|
|
235
|
+
worker sub-agent via the delegate_task MCP tool. Sub-agents set
|
|
236
|
+
SMART_CLAUDE_MEMORY_ORCHESTRATOR_MODE=0 in their spawned env so this
|
|
237
|
+
hook does not trip them.
|
|
238
|
+
|
|
239
|
+
Legacy CLAUDE_MEMORY_ORCHESTRATOR_MODE is honored as a one-time fallback;
|
|
240
|
+
TODO(v1.2.0): drop the legacy name.
|
|
241
|
+
"""
|
|
242
|
+
mode = os.environ.get(
|
|
243
|
+
"SMART_CLAUDE_MEMORY_ORCHESTRATOR_MODE",
|
|
244
|
+
os.environ.get("CLAUDE_MEMORY_ORCHESTRATOR_MODE", "0"),
|
|
245
|
+
)
|
|
246
|
+
if mode not in ("1", "true", "yes"):
|
|
247
|
+
return None
|
|
248
|
+
if tool_name not in {"Write", "Edit", "Bash"}:
|
|
249
|
+
return None
|
|
250
|
+
return {
|
|
251
|
+
"decision": "block",
|
|
252
|
+
"reason": (
|
|
253
|
+
"Orchestrator mode is ON — direct "
|
|
254
|
+
f"{tool_name} in the main session is blocked. Delegate this work "
|
|
255
|
+
"to a worker sub-agent via the delegate_task MCP tool instead."
|
|
256
|
+
),
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def check_verification_gate(tool_name: str) -> dict | None:
|
|
261
|
+
"""If a pending-verification flag exists, block all destructive tools."""
|
|
262
|
+
if tool_name not in {"Write", "Edit", "Bash"}:
|
|
263
|
+
return None
|
|
264
|
+
fp = flag_path()
|
|
265
|
+
if not fp.exists():
|
|
266
|
+
return None
|
|
267
|
+
try:
|
|
268
|
+
payload = json.loads(fp.read_text("utf8"))
|
|
269
|
+
except (OSError, ValueError):
|
|
270
|
+
payload = {}
|
|
271
|
+
return {
|
|
272
|
+
"decision": "block",
|
|
273
|
+
"reason": (
|
|
274
|
+
"Hard Stop: a pending manual-verification gate is active. "
|
|
275
|
+
"After the most recent code change, you must manually confirm it works, "
|
|
276
|
+
"then call confirm_verification({success:true}) to clear this gate. "
|
|
277
|
+
f"Pending flag: {fp}. Details: {json.dumps(payload)[:200]}"
|
|
278
|
+
),
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def check_md_policy(target: Path) -> dict | None:
|
|
283
|
+
ws = env_path("CLAUDE_MD_POLICY_WORKSPACE")
|
|
284
|
+
if ws is None:
|
|
285
|
+
return None
|
|
286
|
+
if target.suffix.lower() != ".md":
|
|
287
|
+
return None
|
|
288
|
+
try:
|
|
289
|
+
target.relative_to(ws)
|
|
290
|
+
except ValueError:
|
|
291
|
+
return None
|
|
292
|
+
if target.parent != ws:
|
|
293
|
+
return {
|
|
294
|
+
"decision": "block",
|
|
295
|
+
"reason": (
|
|
296
|
+
f"Zero-Local-MD policy: `{target.name}` is outside the allowed root ({ws}). "
|
|
297
|
+
"Store it in cloud memory via save_memory or sync_local_memory."
|
|
298
|
+
),
|
|
299
|
+
}
|
|
300
|
+
if target.name not in allow_root():
|
|
301
|
+
return {
|
|
302
|
+
"decision": "block",
|
|
303
|
+
"reason": (
|
|
304
|
+
f"Zero-Local-MD policy: only {sorted(allow_root())} are allowed at the root. "
|
|
305
|
+
f"`{target.name}` must live in cloud memory."
|
|
306
|
+
),
|
|
307
|
+
}
|
|
308
|
+
return None
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def check_line_limit(target: Path, tool_input: dict, tool_name: str) -> dict | None:
|
|
312
|
+
if is_excluded(target):
|
|
313
|
+
return None # auto-generated files bypass hygiene entirely
|
|
314
|
+
|
|
315
|
+
limit = line_hard_limit()
|
|
316
|
+
current_lines = 0
|
|
317
|
+
if target.exists():
|
|
318
|
+
try:
|
|
319
|
+
current_lines = target.read_text("utf8").count("\n") + 1
|
|
320
|
+
except OSError:
|
|
321
|
+
current_lines = 0
|
|
322
|
+
|
|
323
|
+
# Project the new line count.
|
|
324
|
+
if tool_name == "Write":
|
|
325
|
+
incoming = tool_input.get("content", "") or ""
|
|
326
|
+
projected = incoming.count("\n") + 1 if incoming else 0
|
|
327
|
+
else: # Edit
|
|
328
|
+
old_string = tool_input.get("old_string", "") or ""
|
|
329
|
+
new_string = tool_input.get("new_string", "") or ""
|
|
330
|
+
replace_all = bool(tool_input.get("replace_all", False))
|
|
331
|
+
old_nl = old_string.count("\n")
|
|
332
|
+
new_nl = new_string.count("\n")
|
|
333
|
+
if replace_all and old_string and target.exists():
|
|
334
|
+
try:
|
|
335
|
+
text = target.read_text("utf8")
|
|
336
|
+
occurrences = text.count(old_string)
|
|
337
|
+
except OSError:
|
|
338
|
+
occurrences = 1
|
|
339
|
+
projected = current_lines + (new_nl - old_nl) * occurrences
|
|
340
|
+
else:
|
|
341
|
+
projected = current_lines + (new_nl - old_nl)
|
|
342
|
+
|
|
343
|
+
# Grandfather rule — file was already oversized; edits allowed, with warning.
|
|
344
|
+
if current_lines > limit:
|
|
345
|
+
return {
|
|
346
|
+
"decision": "allow",
|
|
347
|
+
"warning": (
|
|
348
|
+
f"Grandfathered file: `{target.name}` is {current_lines} lines "
|
|
349
|
+
f"(over the {limit}-line limit). Edit permitted, but please prioritize "
|
|
350
|
+
f"splitting — run check_code_hygiene({{paths:['{target.as_posix()}']}}) "
|
|
351
|
+
"for an automatic refactor plan."
|
|
352
|
+
),
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
# Ceiling rule — was under the limit; this operation must not cross it.
|
|
356
|
+
if projected > limit:
|
|
357
|
+
return {
|
|
358
|
+
"decision": "block",
|
|
359
|
+
"reason": (
|
|
360
|
+
f"block_and_refactor: `{target.name}` was {current_lines} lines (≤ {limit}); "
|
|
361
|
+
f"this {tool_name} would take it to ~{projected} lines, crossing the hard limit. "
|
|
362
|
+
f"Split the file first — run check_code_hygiene({{paths:['{target.as_posix()}']}}) "
|
|
363
|
+
"for a split plan, then apply the refactor before re-attempting this edit."
|
|
364
|
+
),
|
|
365
|
+
}
|
|
366
|
+
return None
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def check_frozen(target: Path, tool_name: str) -> dict | None:
|
|
370
|
+
patterns = frozen_patterns()
|
|
371
|
+
if not patterns:
|
|
372
|
+
return None
|
|
373
|
+
target_str = str(target).replace("\\", "/")
|
|
374
|
+
for pat in patterns:
|
|
375
|
+
if pat and pat in target_str:
|
|
376
|
+
if tool_name == "Write":
|
|
377
|
+
return {
|
|
378
|
+
"decision": "block",
|
|
379
|
+
"reason": (
|
|
380
|
+
"FROZEN: Use 'Edit' for surgical changes. If a full Refactor "
|
|
381
|
+
"is needed, justify it to the user and request an unfreeze. "
|
|
382
|
+
f"(Pattern: '{pat}')"
|
|
383
|
+
),
|
|
384
|
+
}
|
|
385
|
+
# Edit on a frozen file is the intended path — allow silently.
|
|
386
|
+
break
|
|
387
|
+
return None
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def check_memory_file_size(target: Path, incoming: str) -> dict | None:
|
|
391
|
+
if target.name not in {"CLAUDE.md", "MEMORY.md"}:
|
|
392
|
+
return None
|
|
393
|
+
est = len(incoming) // BYTES_PER_TOKEN
|
|
394
|
+
limit = token_soft_limit()
|
|
395
|
+
if est > limit:
|
|
396
|
+
return {
|
|
397
|
+
"decision": "allow",
|
|
398
|
+
"warning": (
|
|
399
|
+
f"{target.name} is ~{est} tokens, over the {limit} soft limit. "
|
|
400
|
+
"Consider calling summarize_memory_file to compress it back under the limit."
|
|
401
|
+
),
|
|
402
|
+
}
|
|
403
|
+
return None
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
# ─── orchestration ────────────────────────────────────────────────────────
|
|
407
|
+
|
|
408
|
+
def decide(tool_name: str, tool_input: dict) -> dict:
|
|
409
|
+
# 0. Orchestrator-mode hard-block (v1.1.0). Opt-in via env var.
|
|
410
|
+
orch = check_orchestrator_advisory(tool_name)
|
|
411
|
+
if orch is not None and orch.get("decision") == "block":
|
|
412
|
+
return orch
|
|
413
|
+
|
|
414
|
+
# 1. Verification gate trumps everything (applies to Write/Edit/Bash).
|
|
415
|
+
gate = check_verification_gate(tool_name)
|
|
416
|
+
if gate is not None:
|
|
417
|
+
return gate
|
|
418
|
+
|
|
419
|
+
if tool_name not in {"Write", "Edit"}:
|
|
420
|
+
return {"decision": "allow"}
|
|
421
|
+
|
|
422
|
+
raw_path = tool_input.get("file_path") or tool_input.get("path") or ""
|
|
423
|
+
if not raw_path:
|
|
424
|
+
return {"decision": "allow"}
|
|
425
|
+
try:
|
|
426
|
+
target = Path(raw_path).resolve()
|
|
427
|
+
except OSError:
|
|
428
|
+
return {"decision": "allow"}
|
|
429
|
+
|
|
430
|
+
incoming = tool_input.get("content") or tool_input.get("new_string") or ""
|
|
431
|
+
|
|
432
|
+
# 2. Zero-Local-MD policy
|
|
433
|
+
r = check_md_policy(target)
|
|
434
|
+
if r is not None:
|
|
435
|
+
return r
|
|
436
|
+
|
|
437
|
+
# 3. Frozen features
|
|
438
|
+
r = check_frozen(target, tool_name)
|
|
439
|
+
if r is not None:
|
|
440
|
+
return r
|
|
441
|
+
|
|
442
|
+
# 4. 750-line rule (source files only; bypassed for binaries, images, and
|
|
443
|
+
# auto-generated files matching is_excluded()).
|
|
444
|
+
if target.suffix.lower() in {
|
|
445
|
+
".ts", ".tsx", ".js", ".jsx",
|
|
446
|
+
".py", ".sql", ".md",
|
|
447
|
+
".json", ".yaml", ".yml", ".toml",
|
|
448
|
+
".dart",
|
|
449
|
+
}:
|
|
450
|
+
r = check_line_limit(target, tool_input, tool_name)
|
|
451
|
+
if r is not None:
|
|
452
|
+
return r
|
|
453
|
+
|
|
454
|
+
# 5. CLAUDE.md / MEMORY.md size advisory
|
|
455
|
+
size_warning = check_memory_file_size(target, incoming)
|
|
456
|
+
|
|
457
|
+
# 6. Mandatory backup before the edit/write goes through.
|
|
458
|
+
# Write → always (full refactor risk).
|
|
459
|
+
# Edit → only on frozen files (the surgical path, but still worth a snapshot).
|
|
460
|
+
# Skipped if the file doesn't exist yet (nothing to back up).
|
|
461
|
+
backup_warning = None
|
|
462
|
+
if target.exists() and target.is_file():
|
|
463
|
+
should_backup = tool_name == "Write" or (
|
|
464
|
+
tool_name == "Edit" and _target_matches_frozen(target)
|
|
465
|
+
)
|
|
466
|
+
if should_backup:
|
|
467
|
+
bp, err = _make_backup(target, tool_name)
|
|
468
|
+
if bp:
|
|
469
|
+
backup_warning = (
|
|
470
|
+
f"Backup saved before {tool_name}: {bp}. "
|
|
471
|
+
"If verification fails, read this file to restore the prior state."
|
|
472
|
+
)
|
|
473
|
+
elif err:
|
|
474
|
+
backup_warning = f"Backup FAILED for {target.name}: {err} — proceed with caution."
|
|
475
|
+
|
|
476
|
+
warnings = [w for w in (
|
|
477
|
+
(size_warning or {}).get("warning"),
|
|
478
|
+
backup_warning,
|
|
479
|
+
) if w]
|
|
480
|
+
|
|
481
|
+
if warnings:
|
|
482
|
+
return {"decision": "allow", "warning": " | ".join(warnings)}
|
|
483
|
+
return {"decision": "allow"}
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def main() -> None:
|
|
487
|
+
try:
|
|
488
|
+
payload = json.load(sys.stdin)
|
|
489
|
+
except (json.JSONDecodeError, ValueError):
|
|
490
|
+
sys.stdout.write(json.dumps({"decision": "allow"}))
|
|
491
|
+
return
|
|
492
|
+
result = decide(payload.get("tool_name", ""), payload.get("tool_input", {}) or {})
|
|
493
|
+
sys.stdout.write(json.dumps(result))
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
if __name__ == "__main__":
|
|
497
|
+
main()
|
package/marketplace.json
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "smart-claude-memory",
|
|
3
|
+
"version": "2.1.0",
|
|
4
|
+
"description": "Sovereign memory protocol for Claude Code — typed, dual-scope, observability-grade. Bring your own empty Supabase project; the plugin handles the rest.",
|
|
5
|
+
"author": {
|
|
6
|
+
"name": "NABILNET.AI",
|
|
7
|
+
"url": "https://nabilnet.ai"
|
|
8
|
+
},
|
|
9
|
+
"homepage": "https://nabilnet.ai",
|
|
10
|
+
"license": "MIT",
|
|
11
|
+
"keywords": ["claude-code", "memory", "mcp", "supabase", "ollama", "sovereign", "observability"],
|
|
12
|
+
"categories": ["memory", "observability"]
|
|
13
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "smart-claude-memory-mcp",
|
|
3
|
+
"version": "2.1.0",
|
|
4
|
+
"description": "Sovereign memory protocol for Claude Code — typed, dual-scope, observability-grade. Bring your own empty Supabase project; the plugin handles the rest.",
|
|
5
|
+
"author": "NABILNET.AI <https://nabilnet.ai>",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"homepage": "https://nabilnet.ai",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/NABILNET-ORG/Smart-Claude-Memory.git"
|
|
11
|
+
},
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/NABILNET-ORG/Smart-Claude-Memory/issues"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"claude-code",
|
|
17
|
+
"memory",
|
|
18
|
+
"mcp",
|
|
19
|
+
"supabase",
|
|
20
|
+
"ollama",
|
|
21
|
+
"sovereign",
|
|
22
|
+
"observability"
|
|
23
|
+
],
|
|
24
|
+
"type": "module",
|
|
25
|
+
"files": [
|
|
26
|
+
"dist/",
|
|
27
|
+
"hooks/",
|
|
28
|
+
".claude-plugin/",
|
|
29
|
+
"README.md",
|
|
30
|
+
"LICENSE",
|
|
31
|
+
"CHANGELOG.md",
|
|
32
|
+
"marketplace.json"
|
|
33
|
+
],
|
|
34
|
+
"bin": {
|
|
35
|
+
"smart-claude-memory-mcp": "dist/index.js"
|
|
36
|
+
},
|
|
37
|
+
"scripts": {
|
|
38
|
+
"lint:boundaries": "tsx scripts/lint-boundaries.ts",
|
|
39
|
+
"build": "npm run lint:boundaries && tsc",
|
|
40
|
+
"dev": "tsx src/index.ts",
|
|
41
|
+
"start": "node dist/index.js",
|
|
42
|
+
"schema": "tsx scripts/apply-schema.ts",
|
|
43
|
+
"backup": "tsx scripts/backup-and-remove.ts",
|
|
44
|
+
"test": "node --import tsx --experimental-test-module-mocks --no-warnings --test tests/trajectory-stripper.test.ts tests/trajectory-summarizer.test.ts tests/trajectory-daemon.test.ts tests/health.test.ts tests/migrations.test.ts tests/list-global-patterns.test.ts tests/capabilities.test.ts"
|
|
45
|
+
},
|
|
46
|
+
"engines": {
|
|
47
|
+
"node": ">=20"
|
|
48
|
+
},
|
|
49
|
+
"dependencies": {
|
|
50
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
51
|
+
"@supabase/supabase-js": "^2.104.0",
|
|
52
|
+
"dotenv": "^17.4.2",
|
|
53
|
+
"glob": "^13.0.6",
|
|
54
|
+
"ollama": "^0.6.3",
|
|
55
|
+
"pg": "^8.20.0",
|
|
56
|
+
"zod": "^4.3.6"
|
|
57
|
+
},
|
|
58
|
+
"devDependencies": {
|
|
59
|
+
"@types/archiver": "^7.0.0",
|
|
60
|
+
"@types/node": "^25.6.0",
|
|
61
|
+
"@types/pg": "^8.20.0",
|
|
62
|
+
"archiver": "^7.0.1",
|
|
63
|
+
"tsx": "^4.21.0",
|
|
64
|
+
"typescript": "^6.0.3"
|
|
65
|
+
}
|
|
66
|
+
}
|