nexo-brain 7.5.0 → 7.6.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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "7.5.0",
3
+ "version": "7.6.0",
4
4
  "description": "Local cognitive runtime for Claude Code \u2014 persistent memory, overnight learning, doctor diagnostics, personal scripts, recovery-aware jobs, startup preflight, and optional dashboard/power helper.",
5
5
  "author": {
6
6
  "name": "NEXO Brain",
package/README.md CHANGED
@@ -18,7 +18,9 @@
18
18
 
19
19
  [Watch the overview video](https://nexo-brain.com/watch/) · [Watch on YouTube](https://www.youtube.com/watch?v=i2lkGhKyVqI) · [Open the infographic](https://nexo-brain.com/assets/nexo-brain-infographic-v5.png)
20
20
 
21
- Version `7.5.0` is the current packaged-runtime line. Minor release that promotes `nexo_lifecycle_event` from ledger + reconciliation authority to **canonical authority of session-end**. Brain now owns the prompt, the sequence, and the timing of diary+stop; Desktop v0.25.0 (closed-source companion) is the conduit that executes Brain's plan against the live Claude process. The new 2-call contract`nexo_lifecycle_event` returns a versioned `canonical_plan` (resume_session inject_prompt stop_session, with stable ids and per-action timeouts) and `nexo_lifecycle_complete_canonical` confirms execution with a per-action results array — replaces polling with explicit acknowledgement. `canonical_plan_id` is deterministic: `sha256(event_id + "|v" + plan_version)[:24]`, so retries reuse the same id. Migration m52 extends `lifecycle_events` with six `canonical_*` columns plus an index; pre-v7.5 rows simply carry NULL. `session_diary` is the dedupe key on re-delivery: if Desktop crashes between executing the inject and sending the complete call, the next `nexo_lifecycle_event` for the same `event_id` checks for a diary written after `canonical_dispatched_at`; if one exists, Brain short-circuits to `already_processed` and refuses to re-dispatch. The seven explicit `delivery_status` values (`accepted`, `processed`, `canonical_pending`, `canonical_done`, `already_processed`, `retryable_error`, `rejected`) give the pipeline a diffable state machine. `switch` and `window-close` stay observational (no plan ever issued, even with a live `session_id`). `nexo lifecycle record` now returns exit code 0 for `canonical_pending`; older wrappers that treated it as an error are incompatible with v7.5. MCP tool count: 262 263.
21
+ Version `7.6.0` is the current packaged-runtime line. Minor release that closes the drift between `tool-enforcement-map.json` v2.2 and the two enforcement engines (Brain Python + Desktop JS), and moves several default rule modes toward the obedience target requested by the constructor-guardian-90 checklist. Brain now dispatches `before_tool`, `on_event`, and `conditional` alongside the previously-handled typesbefore v7.6 Brain silently ignored these three despite the map declaring them, so Brain users got the weaker contract. `after_tool` dependencies now satisfy per-instance (monotonic `_tool_instance_counter`) instead of once-per-session, which was the specific bug the checklist flagged. Map v2.2 tightens two triggers: `nexo_learning_add` grace_messages 3 0 (corrections produce the learning in the same turn) and `nexo_task_open` conditional threshold 10 4 with level `should must` and an explicit `inject_prompt`. `guardian_default.json` v1.4.0 raises `R15_project_context`, `R17_promise_debt`, `R22_personal_script` and `R_CATALOG_before_artifact_create` from soft to hard, and moves `R34_identity_coherence` from shadow to soft so identity denials surface a visible reminder instead of silent telemetry. A new contract test (`tests/test_v76_map_parity.py`) pins six invariants including Brain↔Desktop dispatch parity and per-instance satisfaction so a future refactor cannot quietly re-open the drift. Companion release: NEXO Desktop v0.26.0 mirrors the engine fixes + default hardening.
22
+
23
+ Previously in `7.5.0`: minor release that promoted `nexo_lifecycle_event` from ledger + reconciliation authority to **canonical authority of session-end**. Brain now owns the prompt, the sequence, and the timing of diary+stop; Desktop v0.25.0 (closed-source companion) is the conduit that executes Brain's plan against the live Claude process. The new 2-call contract — `nexo_lifecycle_event` returns a versioned `canonical_plan` (resume_session → inject_prompt → stop_session, with stable ids and per-action timeouts) and `nexo_lifecycle_complete_canonical` confirms execution with a per-action results array — replaces polling with explicit acknowledgement. `canonical_plan_id` is deterministic: `sha256(event_id + "|v" + plan_version)[:24]`, so retries reuse the same id. Migration m52 extends `lifecycle_events` with six `canonical_*` columns plus an index; pre-v7.5 rows simply carry NULL. `session_diary` is the dedupe key on re-delivery: if Desktop crashes between executing the inject and sending the complete call, the next `nexo_lifecycle_event` for the same `event_id` checks for a diary written after `canonical_dispatched_at`; if one exists, Brain short-circuits to `already_processed` and refuses to re-dispatch. The seven explicit `delivery_status` values (`accepted`, `processed`, `canonical_pending`, `canonical_done`, `already_processed`, `retryable_error`, `rejected`) give the pipeline a diffable state machine. `switch` and `window-close` stay observational (no plan ever issued, even with a live `session_id`). `nexo lifecycle record` now returns exit code 0 for `canonical_pending`; older wrappers that treated it as an error are incompatible with v7.5. MCP tool count: 262 → 263.
22
24
 
23
25
  Previously in `7.4.1`: patch release correcting the over-promise in v7.4.0's release notes and locking in the exact role of `nexo_lifecycle_event` as a ledger + reconciliation authority — NOT the canonical executor of diary+stop, which lived in Desktop. That responsibility moved to Brain in v7.5.
24
26
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "7.5.0",
3
+ "version": "7.6.0",
4
4
  "mcpName": "io.github.wazionapps/nexo",
5
5
  "description": "NEXO Brain \u2014 Shared brain for AI agents. Persistent memory, semantic RAG, natural forgetting, metacognitive guard, trust scoring, 150+ MCP tools. Works with Claude Code, Codex, Claude Desktop & any MCP client. 100% local, free.",
6
6
  "homepage": "https://nexo-brain.com",
@@ -399,12 +399,38 @@ class HeadlessEnforcer:
399
399
  self._periodic_msg: list[dict] = []
400
400
  self._periodic_time: list[dict] = []
401
401
  self._after_tool: dict[str, list[dict]] = {}
402
+ # v7.6.0 parity fix: three rule types were declared in the map
403
+ # (version 2.1) but never dispatched by Brain, only by Desktop.
404
+ # This broke the Brain↔Desktop parity contract (fase2_schema
405
+ # notes line) and made the map silently over-promise.
406
+ self._before_tool: dict[str, list[dict]] = {} # trigger_tool → [entries watching it]
407
+ self._on_event: dict[str, list[dict]] = {} # event_name → [entries listening]
408
+ self._conditional: list[dict] = [] # [entries with counter-based conditions]
409
+ # Monotonic tool-call instance counter. Used by after_tool /
410
+ # before_tool to implement "per-instance" satisfaction instead of
411
+ # the old "once in session" semantics that the checklist flagged
412
+ # as broken: once target_tool was called a single time, every
413
+ # subsequent trigger call was silently satisfied.
414
+ self._tool_instance_counter: int = 0
415
+ self._tool_last_instance: dict[str, int] = {}
416
+ # Mapping trigger_instance → satisfaction required. Key: (trigger_tool, trigger_instance, target_tool).
417
+ # The dependency is satisfied when target_tool is called with
418
+ # tool_last_instance[target] > trigger_instance.
419
+ self._after_tool_open_deps: list[tuple[str, int, str]] = []
420
+ # conditional counters — tool_calls_without_target per rule.
421
+ self._conditional_counters: dict[str, int] = {}
422
+ # on_event grace windows — per (event_name) counter of messages
423
+ # since event fired without the required tool being called.
424
+ self._on_event_pending: dict[str, dict] = {}
402
425
 
403
426
  if self.map:
404
427
  self._build_indexes()
405
- _logger.info("Map v%s loaded: %d on_start, %d on_end, %d periodic_msg, %d periodic_time, %d after_tool",
406
- self.map.get("version", "?"), len(self._on_start), len(self._on_end),
407
- len(self._periodic_msg), len(self._periodic_time), len(self._after_tool))
428
+ _logger.info(
429
+ "Map v%s loaded: %d on_start, %d on_end, %d periodic_msg, %d periodic_time, "
430
+ "%d after_tool, %d before_tool, %d on_event (events), %d conditional",
431
+ self.map.get("version", "?"), len(self._on_start), len(self._on_end),
432
+ len(self._periodic_msg), len(self._periodic_time), len(self._after_tool),
433
+ len(self._before_tool), len(self._on_event), len(self._conditional))
408
434
  else:
409
435
  _logger.warning("No enforcement map found")
410
436
 
@@ -427,6 +453,28 @@ class HeadlessEnforcer:
427
453
  elif rtype == "after_tool":
428
454
  for wt in rule.get("watch_tools", []):
429
455
  self._after_tool.setdefault(wt, []).append(entry)
456
+ elif rtype == "before_tool":
457
+ # Tool T declares a before_tool rule watching tools W1..Wn.
458
+ # When any Wi is about to be called, T must have been
459
+ # called first (since the last relevant reset). Example:
460
+ # nexo_guard_check has before_tool watching [Edit, Write].
461
+ for wt in rule.get("watch_tools", []):
462
+ self._before_tool.setdefault(wt, []).append(entry)
463
+ elif rtype == "on_event":
464
+ # Tool T declares an on_event rule listening for event E.
465
+ # Hooks / the engine itself raise E via raise_event(E).
466
+ # When E fires, if T was not called within grace_messages,
467
+ # inject the reminder.
468
+ event = rule.get("event", "")
469
+ if event:
470
+ self._on_event.setdefault(event, []).append(entry)
471
+ elif rtype == "conditional":
472
+ # Tool T must be called when a condition holds (currently
473
+ # "more_than_N_tool_calls_without_task_open" style). v7.6
474
+ # fix: the condition is evaluated at every tool call so
475
+ # the obligation re-opens per task, not just once in the
476
+ # session.
477
+ self._conditional.append(entry)
430
478
 
431
479
  for triggered in enf.get("triggers_after", []):
432
480
  self._after_tool.setdefault(tool_name, []).append({
@@ -1861,9 +1909,31 @@ class HeadlessEnforcer:
1861
1909
  def on_tool_call(self, raw_name: str, tool_input=None):
1862
1910
  name = _normalize(raw_name)
1863
1911
  self.tool_call_count += 1
1912
+ # v7.6 per-instance counter. Every tool call advances it and we
1913
+ # pin the tool's latest instance so after_tool/before_tool can
1914
+ # tell "has target been called AFTER this trigger?" without
1915
+ # relying on the broken set-membership check.
1916
+ self._tool_instance_counter += 1
1917
+ self._tool_last_instance[name] = self._tool_instance_counter
1864
1918
  self.tools_called.add(name)
1865
1919
  self.tool_timestamps[name] = time.time()
1866
1920
  self.msg_since_tool[name] = 0
1921
+
1922
+ # v7.6 conditional counter advance. Tools watched by a
1923
+ # conditional rule tick a counter on every non-matching call.
1924
+ # When task_open (or whichever tool holds the rule) fires, the
1925
+ # counter is reset via reset_task_cycle().
1926
+ for entry in self._conditional:
1927
+ tool = entry["tool"]
1928
+ if tool == name:
1929
+ self._conditional_counters[tool] = 0
1930
+ else:
1931
+ self._conditional_counters[tool] = self._conditional_counters.get(tool, 0) + 1
1932
+
1933
+ # v7.6 task_close observed → rearm conditional for the companion
1934
+ # open tool so the next task cycle re-opens the obligation.
1935
+ if name == "nexo_task_close":
1936
+ self.reset_task_cycle("nexo_task_open")
1867
1937
  # Track the recent tool_use with the file paths it targets so Fase 2
1868
1938
  # Capa 2 rules (R13, future R19/R20) can inspect the write path.
1869
1939
  files = self._extract_files(tool_input)
@@ -1942,12 +2012,160 @@ class HeadlessEnforcer:
1942
2012
  # R24 — stale memory window decay.
1943
2013
  self._advance_r24_window(name)
1944
2014
 
2015
+ # v7.6 per-instance satisfaction. The legacy check "target not in
2016
+ # tools_called" silently satisfied a dependency forever after the
2017
+ # first target call in the session. Now each trigger call opens a
2018
+ # fresh dependency; satisfaction is marked only when the target is
2019
+ # called AFTER this specific trigger instance.
2020
+ current_instance = self._tool_last_instance.get(name, self._tool_instance_counter)
1945
2021
  for entry in self._after_tool.get(name, []):
1946
2022
  target = entry["tool"]
1947
- if target not in self.tools_called:
2023
+ target_last = self._tool_last_instance.get(target, -1)
2024
+ if target_last < current_instance:
1948
2025
  prompt = entry["enf"].get("inject_prompt", "")
1949
2026
  if prompt:
1950
- self._enqueue(prompt, f"after:{name}->{target}", rule_id="after_tool_dependency")
2027
+ self._enqueue(
2028
+ prompt,
2029
+ f"after:{name}:{current_instance}->{target}",
2030
+ rule_id="after_tool_dependency",
2031
+ )
2032
+ self._after_tool_open_deps.append((name, current_instance, target))
2033
+
2034
+ # v7.6 on_event pending resolution. If the target tool was called
2035
+ # within its grace window, clear the pending state.
2036
+ for event_name, pending in list(self._on_event_pending.items()):
2037
+ required_tool = pending.get("tool")
2038
+ if required_tool and self._tool_last_instance.get(required_tool, -1) > pending.get("fired_at_instance", -1):
2039
+ self._on_event_pending.pop(event_name, None)
2040
+
2041
+ def on_tool_call_before(self, raw_name: str, tool_input=None):
2042
+ """Pre-invocation hook.
2043
+
2044
+ Dispatches `before_tool` rules declared in the map. If the caller
2045
+ routes every tool call through this method, a missing required
2046
+ predecessor (e.g. nexo_guard_check before Edit) produces a visible
2047
+ injection BEFORE the destructive operation lands. The canonical
2048
+ pre-edit guard (R13, Capa 2) already handles Edit/Write defensively
2049
+ elsewhere — this path covers any future `before_tool` wiring the
2050
+ map declares without needing a new custom rule each time.
2051
+ """
2052
+ name = _normalize(raw_name)
2053
+ entries = self._before_tool.get(name, [])
2054
+ if not entries:
2055
+ return
2056
+ for entry in entries:
2057
+ required_tool = entry["tool"]
2058
+ rule = entry.get("rule", {})
2059
+ # R13 already emits a dedicated, context-aware prompt for the
2060
+ # nexo_guard_check → Edit/Write case. Skip the generic
2061
+ # before_tool injection there to avoid double-firing.
2062
+ if required_tool == "nexo_guard_check" and name in ("Edit", "Write"):
2063
+ continue
2064
+ current_instance = self._tool_instance_counter + 1 # upcoming call
2065
+ last = self._tool_last_instance.get(required_tool, -1)
2066
+ if last < current_instance - 1: # required tool not called for this instance
2067
+ prompt = entry["enf"].get("inject_prompt", "") or rule.get("inject_prompt", "")
2068
+ if prompt:
2069
+ self._enqueue(
2070
+ prompt,
2071
+ f"before:{name}:{current_instance}->{required_tool}",
2072
+ rule_id="before_tool_dependency",
2073
+ )
2074
+
2075
+ def raise_event(self, event_name: str, context: dict | None = None):
2076
+ """External/hook trigger for `on_event` rules.
2077
+
2078
+ Call this when a semantic event occurs that the map references:
2079
+
2080
+ - `pre_compaction` / `post_compaction` (harness compaction hooks)
2081
+ - `factual_answer_with_high_stakes` (response contract upgrade)
2082
+ - `user_correction_without_learning` (R14-style detection)
2083
+ - `multi_step_task_detected` (3+ related edits or workflow-kind work)
2084
+ - `done_claimed_with_open_task` (R16 trigger on done/sent/fixed/published/deployed/shipped)
2085
+
2086
+ The required tool (declared in the map) must be called within
2087
+ `grace_messages` (0 by default after the v7.6 checklist tightening
2088
+ so corrections land immediately, not 3 messages later). If not,
2089
+ a pending state is recorded and re-evaluated on every subsequent
2090
+ tool call via the same dispatcher as after_tool.
2091
+ """
2092
+ entries = self._on_event.get(event_name, [])
2093
+ if not entries:
2094
+ return
2095
+ for entry in entries:
2096
+ required_tool = entry["tool"]
2097
+ rule = entry.get("rule", {})
2098
+ grace = int(rule.get("grace_messages", 0))
2099
+ # If already called after the event fired, nothing to do.
2100
+ last = self._tool_last_instance.get(required_tool, -1)
2101
+ if last >= self._tool_instance_counter and grace == 0:
2102
+ continue
2103
+ prompt = entry["enf"].get("inject_prompt", "") or rule.get("inject_prompt", "")
2104
+ if not prompt:
2105
+ continue
2106
+ # Record pending so check_periodic and next on_tool_call can
2107
+ # clear it when the required tool actually fires.
2108
+ self._on_event_pending[event_name] = {
2109
+ "tool": required_tool,
2110
+ "fired_at_instance": self._tool_instance_counter,
2111
+ "grace": grace,
2112
+ "messages_since": 0,
2113
+ }
2114
+ # For grace=0 the injection is immediate. For grace>0 the
2115
+ # injection is deferred to check_periodic. The checklist set
2116
+ # learning_add to grace=0, so the typical path is immediate.
2117
+ if grace == 0:
2118
+ self._enqueue(
2119
+ prompt,
2120
+ f"on_event:{event_name}->{required_tool}",
2121
+ rule_id=f"on_event:{event_name}",
2122
+ )
2123
+
2124
+ def _check_conditional(self):
2125
+ """Evaluate conditional rules (e.g. task_open threshold).
2126
+
2127
+ Called from check_periodic on every user turn boundary so the
2128
+ obligation opens per conversation turn rather than requiring a
2129
+ specific trigger event. v7.6 checklist fix: the previous
2130
+ threshold of 10 tool calls was criticized as "tarde" — the map
2131
+ now keeps the declared threshold but the engine halves it when
2132
+ the recent tool mix shows edit/execute/delegate signals (Edit,
2133
+ Write, Bash with mutation commands, Task dispatch).
2134
+ """
2135
+ for entry in self._conditional:
2136
+ tool = entry["tool"]
2137
+ rule = entry.get("rule", {})
2138
+ base_threshold = int(rule.get("threshold", 10))
2139
+ # Heuristic: if the recent window shows at least one Edit /
2140
+ # Write / Task call, we treat the work as "edit/execute/
2141
+ # delegate" and halve the threshold (rounding up). This is
2142
+ # the checklist-driven early trigger without changing the
2143
+ # declared contract for non-edit flows.
2144
+ recent_names = {getattr(r, "tool", "") for r in self.recent_tool_records[-10:]}
2145
+ is_edit_flow = bool(recent_names & {"Edit", "Write", "Task"})
2146
+ threshold = max(1, (base_threshold + 1) // 2) if is_edit_flow else base_threshold
2147
+ counter = self._conditional_counters.get(tool, 0)
2148
+ if tool in self.tools_called:
2149
+ # Once task_open has been called at least once, the
2150
+ # conditional rule is satisfied for this task cycle. The
2151
+ # counter is reset on every task_close via reset_task_cycle().
2152
+ continue
2153
+ if counter >= threshold:
2154
+ prompt = entry["enf"].get("inject_prompt", "") or rule.get("inject_prompt", "")
2155
+ if prompt:
2156
+ self._enqueue(
2157
+ prompt,
2158
+ f"conditional:{tool}:{counter}",
2159
+ rule_id="conditional_threshold",
2160
+ )
2161
+
2162
+ def reset_task_cycle(self, tool: str = "nexo_task_open"):
2163
+ """Called when a task_close lands, so the conditional counter for
2164
+ the matching open-tool rearms for the next task. Without this,
2165
+ the first task_open satisfies the condition forever — which is
2166
+ exactly the "satisfied-by-once" semantics the checklist flagged.
2167
+ """
2168
+ self._conditional_counters[tool] = 0
1951
2169
 
1952
2170
  def check_periodic(self):
1953
2171
  for entry in self._on_start:
@@ -1977,6 +2195,39 @@ class HeadlessEnforcer:
1977
2195
  if prompt:
1978
2196
  self._enqueue(prompt, f"periodic_time:{tool}", rule_id="periodic_by_time")
1979
2197
 
2198
+ # v7.6 conditional + deferred on_event reminders.
2199
+ self._check_conditional()
2200
+ self._check_on_event_pending()
2201
+
2202
+ def _check_on_event_pending(self):
2203
+ """Re-evaluate on_event rules with grace > 0 after message ticks.
2204
+
2205
+ Called from check_periodic. If the grace window has expired and
2206
+ the required tool was never called, fire the reminder. Otherwise
2207
+ the pending row stays put until the target fires or grace runs out.
2208
+ """
2209
+ for event_name, pending in list(self._on_event_pending.items()):
2210
+ required_tool = pending.get("tool")
2211
+ grace = int(pending.get("grace", 0))
2212
+ if required_tool and self._tool_last_instance.get(required_tool, -1) > pending.get("fired_at_instance", -1):
2213
+ self._on_event_pending.pop(event_name, None)
2214
+ continue
2215
+ pending["messages_since"] = int(pending.get("messages_since", 0)) + 1
2216
+ if pending["messages_since"] >= grace:
2217
+ # Locate the matching entry to pull the injection prompt.
2218
+ for entry in self._on_event.get(event_name, []):
2219
+ if entry["tool"] != required_tool:
2220
+ continue
2221
+ rule = entry.get("rule", {})
2222
+ prompt = entry["enf"].get("inject_prompt", "") or rule.get("inject_prompt", "")
2223
+ if prompt:
2224
+ self._enqueue(
2225
+ prompt,
2226
+ f"on_event:{event_name}:{pending['fired_at_instance']}->{required_tool}",
2227
+ rule_id=f"on_event:{event_name}",
2228
+ )
2229
+ break
2230
+
1980
2231
  def get_end_prompts(self) -> list[str]:
1981
2232
  prompts = []
1982
2233
  for entry in self._on_end:
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://nexo-brain.com/schemas/guardian-config-v1.json",
3
- "version": "1.3.3",
4
- "description": "Default Guardian configuration. Copied to ~/.nexo/personal/config/guardian.json at `nexo init` / `nexo update` if no user config exists. If user config exists, merge keys NOT present in user config without overwriting user overrides. Core rules (R13, R14, R16, R25, R30) can only be shadow/soft/hard — mode=off is rejected by validator.",
3
+ "version": "1.4.0",
4
+ "description": "Default Guardian configuration. Copied to ~/.nexo/personal/config/guardian.json at `nexo init` / `nexo update` if no user config exists. If user config exists, merge keys NOT present in user config without overwriting user overrides. Core rules (R13, R14, R16, R25, R30) can only be shadow/soft/hard — mode=off is rejected by validator. v1.4.0 (constructor-guardian-90 checklist): R15/R17/R22/R_CATALOG upgraded from soft to hard where false-positive telemetry was low enough; R34 identity_coherence raised from shadow to soft so identity denials surface an actual reminder instead of silent logging.",
5
5
  "enabled": true,
6
6
  "classifier_task_profile": "enforcer_classify",
7
7
  "classifier_tier": "muy_bajo",
@@ -27,14 +27,14 @@
27
27
  "R12_cognitive_write_dedup": "soft",
28
28
  "R13_pre_edit_guard": "hard",
29
29
  "R14_correction_learning": "hard",
30
- "R15_project_context": "soft",
30
+ "R15_project_context": "hard",
31
31
  "R16_declared_done": "hard",
32
- "R17_promise_debt": "soft",
32
+ "R17_promise_debt": "hard",
33
33
  "R18_followup_autocomplete": "soft",
34
34
  "R19_project_grep": "soft",
35
35
  "R20_constant_grep": "soft",
36
36
  "R21_legacy_path": "shadow",
37
- "R22_personal_script": "soft",
37
+ "R22_personal_script": "hard",
38
38
  "R23_ssh_without_atlas": "soft",
39
39
  "R23i_auto_deploy_ignored": "soft",
40
40
  "R23j_global_install": "shadow",
@@ -42,7 +42,7 @@
42
42
  "R24_stale_memory": "shadow",
43
43
  "R25_nora_maria_read_only": "hard",
44
44
  "R30_pre_done_evidence_system_prompt": "hard",
45
- "R_CATALOG_before_artifact_create": "soft",
45
+ "R_CATALOG_before_artifact_create": "hard",
46
46
  "R26_no_internal_jargon": "hard",
47
47
  "R27_concise_responses": "hard",
48
48
  "R28_correction_learning_system_prompt": "hard",
@@ -59,7 +59,7 @@
59
59
  "R23g_secrets_in_output": "soft",
60
60
  "R23m_message_duplicate": "soft",
61
61
  "R23h_shebang_mismatch": "shadow",
62
- "R34_identity_coherence": "shadow"
62
+ "R34_identity_coherence": "soft"
63
63
  },
64
64
  "core_rules": [
65
65
  "R13_pre_edit_guard",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "$schema": "https://nexo-brain.com/schemas/tool-enforcement-map-v2.1.json",
3
- "version": "2.1.0",
3
+ "version": "2.2.0",
4
4
  "description": "Canonical map of all NEXO Brain MCP tools with enforcement rules, dependency chains, and behavioral hints. Source of truth for Protocol Enforcer (Desktop + headless). v2.1 adds schema support for Fase 2 event-driven rules (R01-R33): server_side_rules at tool level, per-rule mode (shadow|soft|hard), core_rule flag, and new rule types (pre_tool_intent, post_user_message, on_output_classify, before_tool_strict_block). Backward-compatible: executors that only handle v2.0 rule types ignore the new fields.",
5
5
  "tools": {
6
6
  "nexo_adaptive_decay": {
@@ -1749,7 +1749,7 @@
1749
1749
  {
1750
1750
  "type": "on_event",
1751
1751
  "event": "user_correction_without_learning",
1752
- "grace_messages": 3
1752
+ "grace_messages": 0
1753
1753
  }
1754
1754
  ],
1755
1755
  "inject_prompt": "The user corrected you but no learning was captured. Execute nexo_learning_add to record this correction as a reusable learning. Do not produce visible text.",
@@ -3441,15 +3441,16 @@
3441
3441
  "_attention_snapshot"
3442
3442
  ],
3443
3443
  "enforcement": {
3444
- "level": "should",
3444
+ "level": "must",
3445
3445
  "rules": [
3446
3446
  {
3447
3447
  "type": "conditional",
3448
- "threshold": 10,
3449
- "condition": "more_than_10_tool_calls_without_task_open"
3448
+ "threshold": 4,
3449
+ "condition": "more_than_4_tool_calls_without_task_open_or_first_edit_write_task",
3450
+ "inject_prompt": "You are doing non-trivial work without nexo_task_open. Open a protocol task now (task_type=edit/execute/delegate/answer/analyze) so guard, rules, cortex review and evidence-backed closure apply. Do not produce visible text for this reminder — act on it silently."
3450
3451
  }
3451
3452
  ],
3452
- "reminder_prompt": "You have made significant tool calls without opening a protocol task. Consider using nexo_task_open to formally track this work. This is a reminder, not an obligation if the work is trivial, ignore this.",
3453
+ "reminder_prompt": "You have made significant tool calls without opening a protocol task. Open nexo_task_open now so guard, rules and evidence-backed closure apply. This is not optional for multi-step work.",
3453
3454
  "triggers_after": [
3454
3455
  "nexo_task_close"
3455
3456
  ]