nexo-brain 7.5.0 → 7.7.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 +1 -1
- package/README.md +5 -1
- package/package.json +1 -1
- package/src/enforcement_engine.py +380 -9
- package/src/presets/guardian_default.json +9 -8
- package/src/r_catalog.py +63 -17
- package/src/r_primitive_choice.py +111 -0
- package/templates/core-prompts/r-primitive-choice.md +1 -0
- package/templates/core-prompts/r16-declared-done-question.md +1 -1
- package/tool-enforcement-map.json +7 -6
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.7.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,11 @@
|
|
|
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.
|
|
21
|
+
Version `7.7.0` is the current packaged-runtime line. Minor release that closes the six gaps left partial after v7.6.0's constructor-guardian-90 pass 1. Gap 1: an autonomous detector now raises `multi_step_task_detected` after three recent Edit/Write/Task calls without a prior `nexo_skill_match` (v7.6 dispatched the event but nothing raised it). Gap 2: the R16 classifier prompt recognises the full done-claim vocabulary (sent / delivered / published / deployed / released / fixed / resolved / merged / pushed plus Spanish equivalents) so the on_event trigger `done_claimed_with_open_task` fires beyond the word "done". Gap 3: R_CATALOG extends to plain `Edit` / `Write` into artefact-bearing paths (skills/, plugins/, personal scripts, `templates/core-prompts/`) and grows its discovery set to include `nexo_personal_scripts_list` + `nexo_plugin_list`. Gap 4: new `R_PRIMITIVE_CHOICE` runtime rule gates Edit/Write of a brand-new artefact without a recent primitive-choice probe (SK-CREATE-NEXO-PRIMITIVE). Gap 5: `guardian_default.json` v1.5.0 raises `R11_plugin_load_pre_inventory` from soft to hard. Gap 6: new `tests/test_v77_enforcement_gaps.py` pins 12 invariants across all six rails. Pytest 2070 passing (+14 vs v7.6.0). Companion release: NEXO Desktop v0.27.0 mirrors the guardian default bumps.
|
|
22
|
+
|
|
23
|
+
Previously in `7.6.0`: minor release that closed the drift between `tool-enforcement-map.json` v2.2 and the two enforcement engines (Brain Python + Desktop JS), added per-instance `after_tool` satisfaction, tightened `learning_add` grace to 0 and `task_open` threshold to 4/must, hardened R15/R17/R22/R_CATALOG from soft to hard, and raised R34 from shadow to soft.
|
|
24
|
+
|
|
25
|
+
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
26
|
|
|
23
27
|
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
28
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.7.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",
|
|
@@ -80,6 +80,11 @@ try:
|
|
|
80
80
|
except ImportError: # pragma: no cover
|
|
81
81
|
_r_catalog_should = None # type: ignore
|
|
82
82
|
|
|
83
|
+
try:
|
|
84
|
+
from r_primitive_choice import should_inject_r_primitive as _r_primitive_should
|
|
85
|
+
except ImportError: # pragma: no cover
|
|
86
|
+
_r_primitive_should = None # type: ignore
|
|
87
|
+
|
|
83
88
|
try:
|
|
84
89
|
from r34_identity_coherence import should_inject_r34 as _r34_should
|
|
85
90
|
except ImportError: # pragma: no cover
|
|
@@ -399,12 +404,41 @@ class HeadlessEnforcer:
|
|
|
399
404
|
self._periodic_msg: list[dict] = []
|
|
400
405
|
self._periodic_time: list[dict] = []
|
|
401
406
|
self._after_tool: dict[str, list[dict]] = {}
|
|
407
|
+
# v7.6.0 parity fix: three rule types were declared in the map
|
|
408
|
+
# (version 2.1) but never dispatched by Brain, only by Desktop.
|
|
409
|
+
# This broke the Brain↔Desktop parity contract (fase2_schema
|
|
410
|
+
# notes line) and made the map silently over-promise.
|
|
411
|
+
self._before_tool: dict[str, list[dict]] = {} # trigger_tool → [entries watching it]
|
|
412
|
+
self._on_event: dict[str, list[dict]] = {} # event_name → [entries listening]
|
|
413
|
+
self._conditional: list[dict] = [] # [entries with counter-based conditions]
|
|
414
|
+
# Monotonic tool-call instance counter. Used by after_tool /
|
|
415
|
+
# before_tool to implement "per-instance" satisfaction instead of
|
|
416
|
+
# the old "once in session" semantics that the checklist flagged
|
|
417
|
+
# as broken: once target_tool was called a single time, every
|
|
418
|
+
# subsequent trigger call was silently satisfied.
|
|
419
|
+
self._tool_instance_counter: int = 0
|
|
420
|
+
self._tool_last_instance: dict[str, int] = {}
|
|
421
|
+
# Mapping trigger_instance → satisfaction required. Key: (trigger_tool, trigger_instance, target_tool).
|
|
422
|
+
# The dependency is satisfied when target_tool is called with
|
|
423
|
+
# tool_last_instance[target] > trigger_instance.
|
|
424
|
+
self._after_tool_open_deps: list[tuple[str, int, str]] = []
|
|
425
|
+
# conditional counters — tool_calls_without_target per rule.
|
|
426
|
+
self._conditional_counters: dict[str, int] = {}
|
|
427
|
+
# on_event grace windows — per (event_name) counter of messages
|
|
428
|
+
# since event fired without the required tool being called.
|
|
429
|
+
self._on_event_pending: dict[str, dict] = {}
|
|
430
|
+
# v7.7 Gap 1: latch so multi_step_task_detected fires at most once
|
|
431
|
+
# per task cycle. Cleared on skill_match OR task_close.
|
|
432
|
+
self._multi_step_event_fired: bool = False
|
|
402
433
|
|
|
403
434
|
if self.map:
|
|
404
435
|
self._build_indexes()
|
|
405
|
-
_logger.info(
|
|
406
|
-
|
|
407
|
-
|
|
436
|
+
_logger.info(
|
|
437
|
+
"Map v%s loaded: %d on_start, %d on_end, %d periodic_msg, %d periodic_time, "
|
|
438
|
+
"%d after_tool, %d before_tool, %d on_event (events), %d conditional",
|
|
439
|
+
self.map.get("version", "?"), len(self._on_start), len(self._on_end),
|
|
440
|
+
len(self._periodic_msg), len(self._periodic_time), len(self._after_tool),
|
|
441
|
+
len(self._before_tool), len(self._on_event), len(self._conditional))
|
|
408
442
|
else:
|
|
409
443
|
_logger.warning("No enforcement map found")
|
|
410
444
|
|
|
@@ -427,6 +461,28 @@ class HeadlessEnforcer:
|
|
|
427
461
|
elif rtype == "after_tool":
|
|
428
462
|
for wt in rule.get("watch_tools", []):
|
|
429
463
|
self._after_tool.setdefault(wt, []).append(entry)
|
|
464
|
+
elif rtype == "before_tool":
|
|
465
|
+
# Tool T declares a before_tool rule watching tools W1..Wn.
|
|
466
|
+
# When any Wi is about to be called, T must have been
|
|
467
|
+
# called first (since the last relevant reset). Example:
|
|
468
|
+
# nexo_guard_check has before_tool watching [Edit, Write].
|
|
469
|
+
for wt in rule.get("watch_tools", []):
|
|
470
|
+
self._before_tool.setdefault(wt, []).append(entry)
|
|
471
|
+
elif rtype == "on_event":
|
|
472
|
+
# Tool T declares an on_event rule listening for event E.
|
|
473
|
+
# Hooks / the engine itself raise E via raise_event(E).
|
|
474
|
+
# When E fires, if T was not called within grace_messages,
|
|
475
|
+
# inject the reminder.
|
|
476
|
+
event = rule.get("event", "")
|
|
477
|
+
if event:
|
|
478
|
+
self._on_event.setdefault(event, []).append(entry)
|
|
479
|
+
elif rtype == "conditional":
|
|
480
|
+
# Tool T must be called when a condition holds (currently
|
|
481
|
+
# "more_than_N_tool_calls_without_task_open" style). v7.6
|
|
482
|
+
# fix: the condition is evaluated at every tool call so
|
|
483
|
+
# the obligation re-opens per task, not just once in the
|
|
484
|
+
# session.
|
|
485
|
+
self._conditional.append(entry)
|
|
430
486
|
|
|
431
487
|
for triggered in enf.get("triggers_after", []):
|
|
432
488
|
self._after_tool.setdefault(tool_name, []).append({
|
|
@@ -602,6 +658,14 @@ class HeadlessEnforcer:
|
|
|
602
658
|
self._r14_correction_seen_for_turn = True
|
|
603
659
|
_logger.info("[R14 %s] correction detected; window opened for %d tool calls",
|
|
604
660
|
mode.upper(), self._r14_window_remaining)
|
|
661
|
+
# v7.7 Gap 7.2 — wire on_event so the map's
|
|
662
|
+
# `user_correction_without_learning` rule fires in the live
|
|
663
|
+
# stream. grace_messages was set to 0 in map v2.2 so the
|
|
664
|
+
# learning reminder must surface the same turn.
|
|
665
|
+
try:
|
|
666
|
+
self.raise_event("user_correction_without_learning", {"text_hash": hash(text or "")})
|
|
667
|
+
except Exception:
|
|
668
|
+
pass # telemetry-style; never crash R14 detection
|
|
605
669
|
|
|
606
670
|
def _run_session_end_detection(self, text: str, *, detector=None) -> bool:
|
|
607
671
|
if not self._on_end:
|
|
@@ -800,6 +864,13 @@ class HeadlessEnforcer:
|
|
|
800
864
|
return
|
|
801
865
|
self._enqueue(_R16_PROMPT, "r16:declared-done-without-close", rule_id="R16_declared_done")
|
|
802
866
|
_logger.info("[R16 %s] enqueued declared-done reminder", mode.upper())
|
|
867
|
+
# v7.7 Gap 7.2 — fire the on_event rule wired to task_close so
|
|
868
|
+
# the map's `done_claimed_with_open_task` trigger actually runs
|
|
869
|
+
# from the live stream, not only via test harnesses.
|
|
870
|
+
try:
|
|
871
|
+
self.raise_event("done_claimed_with_open_task", {"source": "R16"})
|
|
872
|
+
except Exception:
|
|
873
|
+
pass
|
|
803
874
|
|
|
804
875
|
def _r25_context(self) -> tuple[set[str], list[str]]:
|
|
805
876
|
"""Resolve the (read_only_hosts, destructive_patterns) pair from
|
|
@@ -1705,8 +1776,15 @@ class HeadlessEnforcer:
|
|
|
1705
1776
|
self._enqueue(prompt, decision["tag"], rule_id="R22_personal_script")
|
|
1706
1777
|
_logger.info("[R22 %s] enqueued path=%s missing=%s", mode.upper(), decision["path"], decision["missing"])
|
|
1707
1778
|
|
|
1708
|
-
def _check_r_catalog(self, tool_name: str):
|
|
1709
|
-
"""R-CATALOG
|
|
1779
|
+
def _check_r_catalog(self, tool_name: str, files: list[str] | None = None):
|
|
1780
|
+
"""R-CATALOG — pre-create discovery probe.
|
|
1781
|
+
|
|
1782
|
+
v7.7 Gap 3: the trigger set is now {nexo_*_create/_open/_add}
|
|
1783
|
+
UNION {Edit / Write into artefact-bearing paths}. The caller
|
|
1784
|
+
passes the extracted file list so plain Edit/Write materialising
|
|
1785
|
+
a skill / plugin / script without going through a dedicated MCP
|
|
1786
|
+
tool still triggers the probe.
|
|
1787
|
+
"""
|
|
1710
1788
|
if _r_catalog_should is None:
|
|
1711
1789
|
return
|
|
1712
1790
|
mode = self._guardian_rule_mode("R_CATALOG_before_artifact_create")
|
|
@@ -1720,7 +1798,7 @@ class HeadlessEnforcer:
|
|
|
1720
1798
|
r.tool for r in self.recent_tool_records[:-1]
|
|
1721
1799
|
if (now - getattr(r, "ts", now)) <= window
|
|
1722
1800
|
]
|
|
1723
|
-
should, prompt = _r_catalog_should(tool_name, recent_tool_names=names)
|
|
1801
|
+
should, prompt = _r_catalog_should(tool_name, recent_tool_names=names, files=files or [])
|
|
1724
1802
|
if not should:
|
|
1725
1803
|
return
|
|
1726
1804
|
if mode == "shadow":
|
|
@@ -1729,6 +1807,45 @@ class HeadlessEnforcer:
|
|
|
1729
1807
|
self._enqueue(prompt, f"R_CATALOG:{tool_name}", rule_id="R_CATALOG_before_artifact_create")
|
|
1730
1808
|
_logger.info("[R_CATALOG %s] enqueued tool=%s", mode.upper(), tool_name)
|
|
1731
1809
|
|
|
1810
|
+
def _check_r_primitive_choice(self, tool_name: str, files: list[str] | None):
|
|
1811
|
+
"""R_PRIMITIVE_CHOICE (v7.7 Gap 4) — SK-CREATE-NEXO-PRIMITIVE gate.
|
|
1812
|
+
|
|
1813
|
+
Flags Edit/Write of a NEW artefact file without a recent primitive-
|
|
1814
|
+
choice probe. Does not duplicate R_CATALOG: R_CATALOG fires on
|
|
1815
|
+
every artefact-path write without inventory consultation, while
|
|
1816
|
+
this rule fires only when the file is genuinely new (no prior
|
|
1817
|
+
Read / Grep / Edit on the same path).
|
|
1818
|
+
"""
|
|
1819
|
+
if _r_primitive_should is None:
|
|
1820
|
+
return
|
|
1821
|
+
mode = self._guardian_rule_mode("R_PRIMITIVE_CHOICE")
|
|
1822
|
+
if mode == "off":
|
|
1823
|
+
return
|
|
1824
|
+
window = 120.0
|
|
1825
|
+
now = time.time()
|
|
1826
|
+
names = [
|
|
1827
|
+
r.tool for r in self.recent_tool_records[:-1]
|
|
1828
|
+
if (now - getattr(r, "ts", now)) <= window
|
|
1829
|
+
]
|
|
1830
|
+
records = [r for r in self.recent_tool_records[:-1]]
|
|
1831
|
+
should, prompt = _r_primitive_should(
|
|
1832
|
+
tool_name,
|
|
1833
|
+
files=files or [],
|
|
1834
|
+
recent_tool_names=names,
|
|
1835
|
+
recent_tool_records=records,
|
|
1836
|
+
)
|
|
1837
|
+
if not should:
|
|
1838
|
+
return
|
|
1839
|
+
if mode == "shadow":
|
|
1840
|
+
_logger.info("[R_PRIMITIVE_CHOICE SHADOW] would inject for %s", tool_name)
|
|
1841
|
+
return
|
|
1842
|
+
self._enqueue(
|
|
1843
|
+
prompt,
|
|
1844
|
+
f"R_PRIMITIVE_CHOICE:{tool_name}",
|
|
1845
|
+
rule_id="R_PRIMITIVE_CHOICE",
|
|
1846
|
+
)
|
|
1847
|
+
_logger.info("[R_PRIMITIVE_CHOICE %s] enqueued tool=%s", mode.upper(), tool_name)
|
|
1848
|
+
|
|
1732
1849
|
def _check_r18(self, tool_name: str, tool_input):
|
|
1733
1850
|
"""R18 — suggest followup_complete on closure-class actions."""
|
|
1734
1851
|
if _r18_should is None or _r18_format is None:
|
|
@@ -1861,9 +1978,54 @@ class HeadlessEnforcer:
|
|
|
1861
1978
|
def on_tool_call(self, raw_name: str, tool_input=None):
|
|
1862
1979
|
name = _normalize(raw_name)
|
|
1863
1980
|
self.tool_call_count += 1
|
|
1981
|
+
# v7.6 per-instance counter. Every tool call advances it and we
|
|
1982
|
+
# pin the tool's latest instance so after_tool/before_tool can
|
|
1983
|
+
# tell "has target been called AFTER this trigger?" without
|
|
1984
|
+
# relying on the broken set-membership check.
|
|
1985
|
+
self._tool_instance_counter += 1
|
|
1986
|
+
self._tool_last_instance[name] = self._tool_instance_counter
|
|
1864
1987
|
self.tools_called.add(name)
|
|
1865
1988
|
self.tool_timestamps[name] = time.time()
|
|
1866
1989
|
self.msg_since_tool[name] = 0
|
|
1990
|
+
|
|
1991
|
+
# v7.6 conditional counter advance. Tools watched by a
|
|
1992
|
+
# conditional rule tick a counter on every non-matching call.
|
|
1993
|
+
# When task_open (or whichever tool holds the rule) fires, the
|
|
1994
|
+
# counter is reset via reset_task_cycle().
|
|
1995
|
+
for entry in self._conditional:
|
|
1996
|
+
tool = entry["tool"]
|
|
1997
|
+
if tool == name:
|
|
1998
|
+
self._conditional_counters[tool] = 0
|
|
1999
|
+
else:
|
|
2000
|
+
self._conditional_counters[tool] = self._conditional_counters.get(tool, 0) + 1
|
|
2001
|
+
|
|
2002
|
+
# v7.6 task_close observed → rearm conditional for the companion
|
|
2003
|
+
# open tool so the next task cycle re-opens the obligation.
|
|
2004
|
+
if name == "nexo_task_close":
|
|
2005
|
+
self.reset_task_cycle("nexo_task_open")
|
|
2006
|
+
|
|
2007
|
+
# v7.7 Gap 1 — autonomous detector for multi_step_task_detected.
|
|
2008
|
+
# The event was dispatched by the map but nothing ever raised it.
|
|
2009
|
+
# Heuristic: three or more edit/execute/delegate calls within the
|
|
2010
|
+
# recent window (Edit/Write/Task/Bash-with-write-command) without
|
|
2011
|
+
# a nexo_skill_match in between signals multi-step work that
|
|
2012
|
+
# should consult skills first. We raise the event at most once per
|
|
2013
|
+
# task cycle — skill_match clears it; task_close rearms it.
|
|
2014
|
+
if not self._multi_step_event_fired:
|
|
2015
|
+
edit_like = {"Edit", "Write", "Task"}
|
|
2016
|
+
recent_edit_calls = sum(
|
|
2017
|
+
1 for r in self.recent_tool_records[-10:] if r.tool in edit_like
|
|
2018
|
+
)
|
|
2019
|
+
if recent_edit_calls >= 3 and "nexo_skill_match" not in self.tools_called:
|
|
2020
|
+
try:
|
|
2021
|
+
self.raise_event("multi_step_task_detected", {"recent_edits": recent_edit_calls})
|
|
2022
|
+
except Exception:
|
|
2023
|
+
pass # telemetry-style; never crash enforcement
|
|
2024
|
+
self._multi_step_event_fired = True
|
|
2025
|
+
if name == "nexo_skill_match" or name == "nexo_task_close":
|
|
2026
|
+
# Both signals clear the multi-step flag so the next task
|
|
2027
|
+
# cycle gets its own detection window.
|
|
2028
|
+
self._multi_step_event_fired = False
|
|
1867
2029
|
# Track the recent tool_use with the file paths it targets so Fase 2
|
|
1868
2030
|
# Capa 2 rules (R13, future R19/R20) can inspect the write path.
|
|
1869
2031
|
files = self._extract_files(tool_input)
|
|
@@ -1934,7 +2096,13 @@ class HeadlessEnforcer:
|
|
|
1934
2096
|
|
|
1935
2097
|
# R-CATALOG (Plan 0.X.2) — nudge if we are about to create/open/add
|
|
1936
2098
|
# without having consulted the live inventory in the last 60 s.
|
|
1937
|
-
self._check_r_catalog(name)
|
|
2099
|
+
self._check_r_catalog(name, files)
|
|
2100
|
+
|
|
2101
|
+
# v7.7 Gap 4 — R_PRIMITIVE_CHOICE. Runs AFTER R_CATALOG because
|
|
2102
|
+
# R_CATALOG's prompt covers the generic inventory case; this one
|
|
2103
|
+
# adds the specific primitive-decision reminder when a brand-new
|
|
2104
|
+
# artefact file is being materialised via Edit/Write.
|
|
2105
|
+
self._check_r_primitive_choice(name, files)
|
|
1938
2106
|
|
|
1939
2107
|
# R18 — retroactive followup-complete suggestion on closure actions.
|
|
1940
2108
|
self._check_r18(name, tool_input)
|
|
@@ -1942,12 +2110,175 @@ class HeadlessEnforcer:
|
|
|
1942
2110
|
# R24 — stale memory window decay.
|
|
1943
2111
|
self._advance_r24_window(name)
|
|
1944
2112
|
|
|
2113
|
+
# v7.6 per-instance satisfaction. The legacy check "target not in
|
|
2114
|
+
# tools_called" silently satisfied a dependency forever after the
|
|
2115
|
+
# first target call in the session. Now each trigger call opens a
|
|
2116
|
+
# fresh dependency; satisfaction is marked only when the target is
|
|
2117
|
+
# called AFTER this specific trigger instance.
|
|
2118
|
+
current_instance = self._tool_last_instance.get(name, self._tool_instance_counter)
|
|
1945
2119
|
for entry in self._after_tool.get(name, []):
|
|
1946
2120
|
target = entry["tool"]
|
|
1947
|
-
|
|
2121
|
+
target_last = self._tool_last_instance.get(target, -1)
|
|
2122
|
+
if target_last < current_instance:
|
|
1948
2123
|
prompt = entry["enf"].get("inject_prompt", "")
|
|
1949
2124
|
if prompt:
|
|
1950
|
-
self._enqueue(
|
|
2125
|
+
self._enqueue(
|
|
2126
|
+
prompt,
|
|
2127
|
+
f"after:{name}:{current_instance}->{target}",
|
|
2128
|
+
rule_id="after_tool_dependency",
|
|
2129
|
+
)
|
|
2130
|
+
self._after_tool_open_deps.append((name, current_instance, target))
|
|
2131
|
+
|
|
2132
|
+
# v7.6 on_event pending resolution. If the target tool was called
|
|
2133
|
+
# within its grace window, clear the pending state.
|
|
2134
|
+
for event_name, pending in list(self._on_event_pending.items()):
|
|
2135
|
+
required_tool = pending.get("tool")
|
|
2136
|
+
if required_tool and self._tool_last_instance.get(required_tool, -1) > pending.get("fired_at_instance", -1):
|
|
2137
|
+
self._on_event_pending.pop(event_name, None)
|
|
2138
|
+
|
|
2139
|
+
def on_tool_call_before(self, raw_name: str, tool_input=None):
|
|
2140
|
+
"""Pre-invocation hook.
|
|
2141
|
+
|
|
2142
|
+
Dispatches `before_tool` rules declared in the map. If the caller
|
|
2143
|
+
routes every tool call through this method, a missing required
|
|
2144
|
+
predecessor (e.g. nexo_guard_check before Edit) produces a visible
|
|
2145
|
+
injection BEFORE the destructive operation lands. The canonical
|
|
2146
|
+
pre-edit guard (R13, Capa 2) already handles Edit/Write defensively
|
|
2147
|
+
elsewhere — this path covers any future `before_tool` wiring the
|
|
2148
|
+
map declares without needing a new custom rule each time.
|
|
2149
|
+
"""
|
|
2150
|
+
name = _normalize(raw_name)
|
|
2151
|
+
entries = self._before_tool.get(name, [])
|
|
2152
|
+
if not entries:
|
|
2153
|
+
return
|
|
2154
|
+
for entry in entries:
|
|
2155
|
+
required_tool = entry["tool"]
|
|
2156
|
+
rule = entry.get("rule", {})
|
|
2157
|
+
# R13 already emits a dedicated, context-aware prompt for the
|
|
2158
|
+
# nexo_guard_check → Edit/Write case. Skip the generic
|
|
2159
|
+
# before_tool injection there to avoid double-firing.
|
|
2160
|
+
if required_tool == "nexo_guard_check" and name in ("Edit", "Write"):
|
|
2161
|
+
continue
|
|
2162
|
+
current_instance = self._tool_instance_counter + 1 # upcoming call
|
|
2163
|
+
last = self._tool_last_instance.get(required_tool, -1)
|
|
2164
|
+
if last < current_instance - 1: # required tool not called for this instance
|
|
2165
|
+
prompt = entry["enf"].get("inject_prompt", "") or rule.get("inject_prompt", "")
|
|
2166
|
+
if prompt:
|
|
2167
|
+
self._enqueue(
|
|
2168
|
+
prompt,
|
|
2169
|
+
f"before:{name}:{current_instance}->{required_tool}",
|
|
2170
|
+
rule_id="before_tool_dependency",
|
|
2171
|
+
)
|
|
2172
|
+
|
|
2173
|
+
def raise_event(self, event_name: str, context: dict | None = None):
|
|
2174
|
+
"""External/hook trigger for `on_event` rules.
|
|
2175
|
+
|
|
2176
|
+
Call this when a semantic event occurs that the map references:
|
|
2177
|
+
|
|
2178
|
+
- `pre_compaction` / `post_compaction` (harness compaction hooks)
|
|
2179
|
+
- `factual_answer_with_high_stakes` (response contract upgrade)
|
|
2180
|
+
- `user_correction_without_learning` (R14-style detection)
|
|
2181
|
+
- `multi_step_task_detected` (3+ related edits or workflow-kind work)
|
|
2182
|
+
- `done_claimed_with_open_task` (R16 trigger on done/sent/fixed/published/deployed/shipped)
|
|
2183
|
+
|
|
2184
|
+
The required tool (declared in the map) must be called within
|
|
2185
|
+
`grace_messages` (0 by default after the v7.6 checklist tightening
|
|
2186
|
+
so corrections land immediately, not 3 messages later). If not,
|
|
2187
|
+
a pending state is recorded and re-evaluated on every subsequent
|
|
2188
|
+
tool call via the same dispatcher as after_tool.
|
|
2189
|
+
"""
|
|
2190
|
+
entries = self._on_event.get(event_name, [])
|
|
2191
|
+
if not entries:
|
|
2192
|
+
return
|
|
2193
|
+
for entry in entries:
|
|
2194
|
+
required_tool = entry["tool"]
|
|
2195
|
+
rule = entry.get("rule", {})
|
|
2196
|
+
grace = int(rule.get("grace_messages", 0))
|
|
2197
|
+
# If already called after the event fired, nothing to do.
|
|
2198
|
+
last = self._tool_last_instance.get(required_tool, -1)
|
|
2199
|
+
if last >= self._tool_instance_counter and grace == 0:
|
|
2200
|
+
continue
|
|
2201
|
+
prompt = entry["enf"].get("inject_prompt", "") or rule.get("inject_prompt", "")
|
|
2202
|
+
if not prompt:
|
|
2203
|
+
continue
|
|
2204
|
+
# Record pending so check_periodic and next on_tool_call can
|
|
2205
|
+
# clear it when the required tool actually fires.
|
|
2206
|
+
self._on_event_pending[event_name] = {
|
|
2207
|
+
"tool": required_tool,
|
|
2208
|
+
"fired_at_instance": self._tool_instance_counter,
|
|
2209
|
+
"grace": grace,
|
|
2210
|
+
"messages_since": 0,
|
|
2211
|
+
}
|
|
2212
|
+
# For grace=0 the injection is immediate. For grace>0 the
|
|
2213
|
+
# injection is deferred to check_periodic. The checklist set
|
|
2214
|
+
# learning_add to grace=0, so the typical path is immediate.
|
|
2215
|
+
if grace == 0:
|
|
2216
|
+
self._enqueue(
|
|
2217
|
+
prompt,
|
|
2218
|
+
f"on_event:{event_name}->{required_tool}",
|
|
2219
|
+
rule_id=f"on_event:{event_name}",
|
|
2220
|
+
)
|
|
2221
|
+
|
|
2222
|
+
def _check_conditional(self):
|
|
2223
|
+
"""Evaluate conditional rules (e.g. task_open threshold).
|
|
2224
|
+
|
|
2225
|
+
Called from check_periodic on every user turn boundary so the
|
|
2226
|
+
obligation opens per conversation turn rather than requiring a
|
|
2227
|
+
specific trigger event. v7.6 checklist fix: the previous
|
|
2228
|
+
threshold of 10 tool calls was criticized as "tarde" — the map
|
|
2229
|
+
now keeps the declared threshold but the engine halves it when
|
|
2230
|
+
the recent tool mix shows edit/execute/delegate signals (Edit,
|
|
2231
|
+
Write, Bash with mutation commands, Task dispatch).
|
|
2232
|
+
"""
|
|
2233
|
+
for entry in self._conditional:
|
|
2234
|
+
tool = entry["tool"]
|
|
2235
|
+
rule = entry.get("rule", {})
|
|
2236
|
+
base_threshold = int(rule.get("threshold", 10))
|
|
2237
|
+
# Heuristic: if the recent window shows at least one Edit /
|
|
2238
|
+
# Write / Task call, we treat the work as "edit/execute/
|
|
2239
|
+
# delegate" and halve the threshold (rounding up). This is
|
|
2240
|
+
# the checklist-driven early trigger without changing the
|
|
2241
|
+
# declared contract for non-edit flows.
|
|
2242
|
+
recent_names = {getattr(r, "tool", "") for r in self.recent_tool_records[-10:]}
|
|
2243
|
+
is_edit_flow = bool(recent_names & {"Edit", "Write", "Task"})
|
|
2244
|
+
threshold = max(1, (base_threshold + 1) // 2) if is_edit_flow else base_threshold
|
|
2245
|
+
counter = self._conditional_counters.get(tool, 0)
|
|
2246
|
+
if tool in self.tools_called:
|
|
2247
|
+
# Once task_open has been called at least once, the
|
|
2248
|
+
# conditional rule is satisfied for this task cycle. The
|
|
2249
|
+
# counter is reset on every task_close via reset_task_cycle().
|
|
2250
|
+
continue
|
|
2251
|
+
if counter >= threshold:
|
|
2252
|
+
prompt = entry["enf"].get("inject_prompt", "") or rule.get("inject_prompt", "")
|
|
2253
|
+
if prompt:
|
|
2254
|
+
self._enqueue(
|
|
2255
|
+
prompt,
|
|
2256
|
+
f"conditional:{tool}:{counter}",
|
|
2257
|
+
rule_id="conditional_threshold",
|
|
2258
|
+
)
|
|
2259
|
+
|
|
2260
|
+
def reset_task_cycle(self, tool: str = "nexo_task_open"):
|
|
2261
|
+
"""Called when a task_close lands, so the conditional counter for
|
|
2262
|
+
the matching open-tool rearms for the next task.
|
|
2263
|
+
|
|
2264
|
+
v7.7 Gap 7.1 (checklist pass-2 hotfix): v7.6 only reset the
|
|
2265
|
+
counter but left `tools_called` carrying `nexo_task_open` from
|
|
2266
|
+
the previous cycle. That meant `_check_conditional`'s early
|
|
2267
|
+
`if tool in self.tools_called: continue` short-circuit still
|
|
2268
|
+
blocked the re-nudge forever. We now also drop the open-tool
|
|
2269
|
+
from `tools_called` and clear its per-instance pin so the gate
|
|
2270
|
+
genuinely re-arms for the next task cycle. `_tool_last_instance`
|
|
2271
|
+
stays intact for the OTHER tools (per-instance semantics for
|
|
2272
|
+
after_tool still rely on it).
|
|
2273
|
+
"""
|
|
2274
|
+
self._conditional_counters[tool] = 0
|
|
2275
|
+
if tool in self.tools_called:
|
|
2276
|
+
self.tools_called.discard(tool)
|
|
2277
|
+
# Clearing the per-instance pin lets future after_tool
|
|
2278
|
+
# dependencies on this tool re-open too; the conditional rule is
|
|
2279
|
+
# what the checklist focused on but the same "satisfied-by-once"
|
|
2280
|
+
# defect applied to after_tool gates pointing at task_open.
|
|
2281
|
+
self._tool_last_instance.pop(tool, None)
|
|
1951
2282
|
|
|
1952
2283
|
def check_periodic(self):
|
|
1953
2284
|
for entry in self._on_start:
|
|
@@ -1977,6 +2308,39 @@ class HeadlessEnforcer:
|
|
|
1977
2308
|
if prompt:
|
|
1978
2309
|
self._enqueue(prompt, f"periodic_time:{tool}", rule_id="periodic_by_time")
|
|
1979
2310
|
|
|
2311
|
+
# v7.6 conditional + deferred on_event reminders.
|
|
2312
|
+
self._check_conditional()
|
|
2313
|
+
self._check_on_event_pending()
|
|
2314
|
+
|
|
2315
|
+
def _check_on_event_pending(self):
|
|
2316
|
+
"""Re-evaluate on_event rules with grace > 0 after message ticks.
|
|
2317
|
+
|
|
2318
|
+
Called from check_periodic. If the grace window has expired and
|
|
2319
|
+
the required tool was never called, fire the reminder. Otherwise
|
|
2320
|
+
the pending row stays put until the target fires or grace runs out.
|
|
2321
|
+
"""
|
|
2322
|
+
for event_name, pending in list(self._on_event_pending.items()):
|
|
2323
|
+
required_tool = pending.get("tool")
|
|
2324
|
+
grace = int(pending.get("grace", 0))
|
|
2325
|
+
if required_tool and self._tool_last_instance.get(required_tool, -1) > pending.get("fired_at_instance", -1):
|
|
2326
|
+
self._on_event_pending.pop(event_name, None)
|
|
2327
|
+
continue
|
|
2328
|
+
pending["messages_since"] = int(pending.get("messages_since", 0)) + 1
|
|
2329
|
+
if pending["messages_since"] >= grace:
|
|
2330
|
+
# Locate the matching entry to pull the injection prompt.
|
|
2331
|
+
for entry in self._on_event.get(event_name, []):
|
|
2332
|
+
if entry["tool"] != required_tool:
|
|
2333
|
+
continue
|
|
2334
|
+
rule = entry.get("rule", {})
|
|
2335
|
+
prompt = entry["enf"].get("inject_prompt", "") or rule.get("inject_prompt", "")
|
|
2336
|
+
if prompt:
|
|
2337
|
+
self._enqueue(
|
|
2338
|
+
prompt,
|
|
2339
|
+
f"on_event:{event_name}:{pending['fired_at_instance']}->{required_tool}",
|
|
2340
|
+
rule_id=f"on_event:{event_name}",
|
|
2341
|
+
)
|
|
2342
|
+
break
|
|
2343
|
+
|
|
1980
2344
|
def get_end_prompts(self) -> list[str]:
|
|
1981
2345
|
prompts = []
|
|
1982
2346
|
for entry in self._on_end:
|
|
@@ -2171,10 +2535,17 @@ def run_with_enforcement(
|
|
|
2171
2535
|
if event_type == "assistant" and event.get("message", {}).get("content"):
|
|
2172
2536
|
for block in event["message"]["content"]:
|
|
2173
2537
|
if block.get("type") == "tool_use":
|
|
2538
|
+
# v7.7 Gap 7.3 — wire before_tool in the live
|
|
2539
|
+
# stream. Desktop already calls onBeforeToolCall
|
|
2540
|
+
# before onToolCall; Brain's stream was only
|
|
2541
|
+
# calling on_tool_call, silently skipping every
|
|
2542
|
+
# before_tool rule the map declared.
|
|
2543
|
+
enforcer.on_tool_call_before(block.get("name", ""), block.get("input"))
|
|
2174
2544
|
enforcer.on_tool_call(block.get("name", ""), block.get("input"))
|
|
2175
2545
|
elif event_type == "content_block_start":
|
|
2176
2546
|
cb = event.get("content_block", {})
|
|
2177
2547
|
if cb.get("type") == "tool_use":
|
|
2548
|
+
enforcer.on_tool_call_before(cb.get("name", ""), cb.get("input"))
|
|
2178
2549
|
enforcer.on_tool_call(cb.get("name", ""), cb.get("input"))
|
|
2179
2550
|
|
|
2180
2551
|
if event_type == "assistant" and not waiting_for_injection_response:
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://nexo-brain.com/schemas/guardian-config-v1.json",
|
|
3
|
-
"version": "1.
|
|
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.5.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 pass 1): R15/R17/R22/R_CATALOG upgraded from soft to hard where FP telemetry was low; R34 raised from shadow to soft. v1.5.0 (pass 2): R11_plugin_load_pre_inventory upgraded from soft to hard; new R_PRIMITIVE_CHOICE (SK-CREATE-NEXO-PRIMITIVE gate) added at soft so it can be observed before hardening.",
|
|
5
5
|
"enabled": true,
|
|
6
6
|
"classifier_task_profile": "enforcer_classify",
|
|
7
7
|
"classifier_tier": "muy_bajo",
|
|
@@ -23,18 +23,18 @@
|
|
|
23
23
|
"R08_reminder_recurrence_conflict": "soft",
|
|
24
24
|
"R09_artifact_create_dedup": "soft",
|
|
25
25
|
"R10_workflow_open_without_task": "hard",
|
|
26
|
-
"R11_plugin_load_pre_inventory": "
|
|
26
|
+
"R11_plugin_load_pre_inventory": "hard",
|
|
27
27
|
"R12_cognitive_write_dedup": "soft",
|
|
28
28
|
"R13_pre_edit_guard": "hard",
|
|
29
29
|
"R14_correction_learning": "hard",
|
|
30
|
-
"R15_project_context": "
|
|
30
|
+
"R15_project_context": "hard",
|
|
31
31
|
"R16_declared_done": "hard",
|
|
32
|
-
"R17_promise_debt": "
|
|
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": "
|
|
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,8 @@
|
|
|
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": "
|
|
45
|
+
"R_CATALOG_before_artifact_create": "hard",
|
|
46
|
+
"R_PRIMITIVE_CHOICE": "soft",
|
|
46
47
|
"R26_no_internal_jargon": "hard",
|
|
47
48
|
"R27_concise_responses": "hard",
|
|
48
49
|
"R28_correction_learning_system_prompt": "hard",
|
|
@@ -59,7 +60,7 @@
|
|
|
59
60
|
"R23g_secrets_in_output": "soft",
|
|
60
61
|
"R23m_message_duplicate": "soft",
|
|
61
62
|
"R23h_shebang_mismatch": "shadow",
|
|
62
|
-
"R34_identity_coherence": "
|
|
63
|
+
"R34_identity_coherence": "soft"
|
|
63
64
|
},
|
|
64
65
|
"core_rules": [
|
|
65
66
|
"R13_pre_edit_guard",
|
package/src/r_catalog.py
CHANGED
|
@@ -1,24 +1,30 @@
|
|
|
1
|
-
"""R-CATALOG — Plan Consolidado 0.X.2.
|
|
1
|
+
"""R-CATALOG — Plan Consolidado 0.X.2 + v7.7 Gap 3 expansion.
|
|
2
2
|
|
|
3
|
-
Pre-create discovery probe
|
|
4
|
-
(``nexo_*_create`` family) without having consulted any inventory tool in
|
|
5
|
-
the same turn, R-CATALOG nudges it to search first.
|
|
3
|
+
Pre-create discovery probe. Two trigger families:
|
|
6
4
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
5
|
+
(a) `nexo_*_create` / `_open` / `_add` — the original MCP-tool path.
|
|
6
|
+
(b) v7.7: `Edit` / `Write` writing into artefact-bearing paths
|
|
7
|
+
(skills/, plugins/, scripts/, personal scripts). The checklist
|
|
8
|
+
item "ampliar el ámbito del pre-probe de catálogo para cubrir
|
|
9
|
+
no solo nexo_*_create/_open/_add, sino también writes de
|
|
10
|
+
archivos que materializan skills/plugins/scripts/plantillas/
|
|
11
|
+
artefactos aunque no hayan pasado por un tool MCP de 'create'".
|
|
12
|
+
|
|
13
|
+
Rationale: the Guardian should not re-teach what tools exist; the live
|
|
14
|
+
catalog does that. But if the agent materialises a new skill / plugin /
|
|
15
|
+
script by writing a file directly, without consulting inventory, it
|
|
16
|
+
skips discovery and produces duplicates.
|
|
12
17
|
|
|
13
18
|
Contract:
|
|
14
|
-
- Trigger tools:
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
+
- Trigger tools: `nexo_*_create` / `_open` / `_add` (always), or
|
|
20
|
+
`Edit` / `Write` when the file path lives under a recognised
|
|
21
|
+
artefact root.
|
|
22
|
+
- Discovery tools (any one resets the window): `nexo_system_catalog`,
|
|
23
|
+
`nexo_tool_explain`, `nexo_skill_match`, `nexo_skill_list`,
|
|
24
|
+
`nexo_learning_search`, `nexo_guard_check`, `nexo_personal_scripts_list`,
|
|
25
|
+
`nexo_plugin_list`.
|
|
26
|
+
- Window: 60s. Caller passes `recent_tool_names` already filtered to
|
|
19
27
|
the last 60 seconds so the rule itself is time-agnostic.
|
|
20
|
-
- Dedup tag: the engine's _enqueue keys dedup by rule_id + tag so an
|
|
21
|
-
agent chaining two creates only gets nudged once per 60s.
|
|
22
28
|
"""
|
|
23
29
|
from __future__ import annotations
|
|
24
30
|
|
|
@@ -34,8 +40,30 @@ DISCOVERY_TOOLS = frozenset({
|
|
|
34
40
|
"nexo_skill_list",
|
|
35
41
|
"nexo_learning_search",
|
|
36
42
|
"nexo_guard_check",
|
|
43
|
+
# v7.7 Gap 3: inventory surfaces that count as "I checked what
|
|
44
|
+
# already exists before writing a new artefact".
|
|
45
|
+
"nexo_personal_scripts_list",
|
|
46
|
+
"nexo_plugin_list",
|
|
37
47
|
})
|
|
38
48
|
|
|
49
|
+
# Path fragments that classify a write as an "artefact creation" write,
|
|
50
|
+
# even when it goes through plain Edit / Write instead of a dedicated
|
|
51
|
+
# MCP tool. v7.7 Gap 3 coverage.
|
|
52
|
+
ARTEFACT_PATH_FRAGMENTS = (
|
|
53
|
+
"/skills/",
|
|
54
|
+
"/clawhub-skill/",
|
|
55
|
+
"/.claude-plugin/",
|
|
56
|
+
"/src/plugins/",
|
|
57
|
+
"/personal/scripts/",
|
|
58
|
+
"/personal/skills/",
|
|
59
|
+
"/personal-scripts/",
|
|
60
|
+
"/.nexo/personal/scripts/",
|
|
61
|
+
"/.nexo/skills/",
|
|
62
|
+
"/templates/core-prompts/",
|
|
63
|
+
"/core-prompts/",
|
|
64
|
+
"/src/presets/",
|
|
65
|
+
)
|
|
66
|
+
|
|
39
67
|
INJECTION_PROMPT = render_core_prompt("r-catalog", tool="{tool}")
|
|
40
68
|
|
|
41
69
|
|
|
@@ -45,13 +73,30 @@ def _is_trigger_tool(tool_name) -> bool:
|
|
|
45
73
|
return tool_name.endswith("_create") or tool_name.endswith("_open") or tool_name.endswith("_add")
|
|
46
74
|
|
|
47
75
|
|
|
76
|
+
def _is_artefact_write(tool_name, files: Iterable[str] | None) -> bool:
|
|
77
|
+
"""v7.7 Gap 3: a plain Edit/Write into an artefact-bearing path
|
|
78
|
+
counts as a trigger even without a *_create MCP tool."""
|
|
79
|
+
if tool_name not in ("Edit", "Write"):
|
|
80
|
+
return False
|
|
81
|
+
if not files:
|
|
82
|
+
return False
|
|
83
|
+
for path in files:
|
|
84
|
+
if not isinstance(path, str):
|
|
85
|
+
continue
|
|
86
|
+
for fragment in ARTEFACT_PATH_FRAGMENTS:
|
|
87
|
+
if fragment in path:
|
|
88
|
+
return True
|
|
89
|
+
return False
|
|
90
|
+
|
|
91
|
+
|
|
48
92
|
def should_inject_r_catalog(
|
|
49
93
|
tool_name,
|
|
50
94
|
*,
|
|
51
95
|
recent_tool_names: Iterable[str] | None,
|
|
96
|
+
files: Iterable[str] | None = None,
|
|
52
97
|
) -> tuple[bool, str]:
|
|
53
98
|
"""Return (inject, prompt). Never raises."""
|
|
54
|
-
if not _is_trigger_tool(tool_name):
|
|
99
|
+
if not _is_trigger_tool(tool_name) and not _is_artefact_write(tool_name, files):
|
|
55
100
|
return False, ""
|
|
56
101
|
if tool_name in DISCOVERY_TOOLS:
|
|
57
102
|
return False, ""
|
|
@@ -63,6 +108,7 @@ def should_inject_r_catalog(
|
|
|
63
108
|
|
|
64
109
|
__all__ = [
|
|
65
110
|
"DISCOVERY_TOOLS",
|
|
111
|
+
"ARTEFACT_PATH_FRAGMENTS",
|
|
66
112
|
"INJECTION_PROMPT",
|
|
67
113
|
"should_inject_r_catalog",
|
|
68
114
|
]
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""R_PRIMITIVE_CHOICE — v7.7 Gap 4.
|
|
2
|
+
|
|
3
|
+
Before materialising a NEW artefact (skill / plugin / personal script /
|
|
4
|
+
template) via plain Edit / Write, the agent MUST consult SK-CREATE-
|
|
5
|
+
NEXO-PRIMITIVE (or its equivalent signal — a recent `nexo_skill_match`
|
|
6
|
+
/ `nexo_tool_explain` call) so skill-vs-plugin-vs-script-vs-schedule-
|
|
7
|
+
vs-core-change is not improvised.
|
|
8
|
+
|
|
9
|
+
Distinct from R_CATALOG (v7.7 Gap 3) which fires on EVERY write into
|
|
10
|
+
an artefact-bearing path; R_PRIMITIVE_CHOICE fires only when the file
|
|
11
|
+
is NEW (write without prior matching read/grep) so editing an existing
|
|
12
|
+
skill to tweak a step is not reprimanded.
|
|
13
|
+
|
|
14
|
+
Contract:
|
|
15
|
+
- Trigger: Edit/Write into artefact-bearing paths AND the file did
|
|
16
|
+
NOT appear in the recent tool record as a previous Read/Grep.
|
|
17
|
+
- Relief signal (any one in last 120s): `nexo_skill_match`,
|
|
18
|
+
`nexo_tool_explain`, Read or Grep of the same path, or
|
|
19
|
+
`nexo_skill_apply` referencing SK-CREATE-NEXO-PRIMITIVE.
|
|
20
|
+
- Default mode: soft (so the rule can be observed before hardening).
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from typing import Iterable
|
|
25
|
+
|
|
26
|
+
from core_prompts import render_core_prompt
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
ARTEFACT_PATH_FRAGMENTS = (
|
|
30
|
+
"/skills/",
|
|
31
|
+
"/clawhub-skill/",
|
|
32
|
+
"/.claude-plugin/",
|
|
33
|
+
"/src/plugins/",
|
|
34
|
+
"/personal/scripts/",
|
|
35
|
+
"/personal/skills/",
|
|
36
|
+
"/personal-scripts/",
|
|
37
|
+
"/.nexo/personal/scripts/",
|
|
38
|
+
"/.nexo/skills/",
|
|
39
|
+
"/templates/core-prompts/",
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
RELIEF_TOOLS = frozenset({
|
|
43
|
+
"nexo_skill_match",
|
|
44
|
+
"nexo_tool_explain",
|
|
45
|
+
"nexo_skill_apply",
|
|
46
|
+
"Read",
|
|
47
|
+
"Grep",
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
INJECTION_PROMPT = render_core_prompt("r-primitive-choice", path="{path}")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _is_artefact_path(path: str) -> bool:
|
|
55
|
+
if not isinstance(path, str):
|
|
56
|
+
return False
|
|
57
|
+
return any(fragment in path for fragment in ARTEFACT_PATH_FRAGMENTS)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _file_previously_touched(path: str, recent_records) -> bool:
|
|
61
|
+
"""True iff the same path was seen in a prior Read / Grep / Edit
|
|
62
|
+
call inside the recent window. The engine passes its
|
|
63
|
+
recent_tool_records list (each record has .tool and .files)."""
|
|
64
|
+
if not isinstance(path, str):
|
|
65
|
+
return False
|
|
66
|
+
for record in recent_records or []:
|
|
67
|
+
tool = getattr(record, "tool", "")
|
|
68
|
+
if tool not in ("Read", "Grep", "Edit"):
|
|
69
|
+
continue
|
|
70
|
+
files = getattr(record, "files", ())
|
|
71
|
+
for f in files or ():
|
|
72
|
+
if f == path:
|
|
73
|
+
return True
|
|
74
|
+
return False
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def should_inject_r_primitive(
|
|
78
|
+
tool_name,
|
|
79
|
+
*,
|
|
80
|
+
files: Iterable[str] | None,
|
|
81
|
+
recent_tool_names: Iterable[str] | None,
|
|
82
|
+
recent_tool_records,
|
|
83
|
+
) -> tuple[bool, str]:
|
|
84
|
+
"""Return (inject, prompt). Fail-closed on bad input → (False, "")."""
|
|
85
|
+
if tool_name not in ("Edit", "Write"):
|
|
86
|
+
return False, ""
|
|
87
|
+
file_list = [f for f in (files or []) if isinstance(f, str)]
|
|
88
|
+
if not file_list:
|
|
89
|
+
return False, ""
|
|
90
|
+
artefact_files = [f for f in file_list if _is_artefact_path(f)]
|
|
91
|
+
if not artefact_files:
|
|
92
|
+
return False, ""
|
|
93
|
+
# If any relief tool fired recently, rule is satisfied.
|
|
94
|
+
recent = set(recent_tool_names or [])
|
|
95
|
+
if recent & RELIEF_TOOLS:
|
|
96
|
+
return False, ""
|
|
97
|
+
# If the file appears to be an edit of an existing artefact (Read
|
|
98
|
+
# or Grep already touched the same path), treat as relief too —
|
|
99
|
+
# we only want to flag NEW artefact creation.
|
|
100
|
+
for f in artefact_files:
|
|
101
|
+
if _file_previously_touched(f, recent_tool_records):
|
|
102
|
+
return False, ""
|
|
103
|
+
return True, INJECTION_PROMPT.format(path=artefact_files[0])
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
__all__ = [
|
|
107
|
+
"ARTEFACT_PATH_FRAGMENTS",
|
|
108
|
+
"RELIEF_TOOLS",
|
|
109
|
+
"INJECTION_PROMPT",
|
|
110
|
+
"should_inject_r_primitive",
|
|
111
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
[NEXO Protocol Enforcer] R_PRIMITIVE_CHOICE: you are about to write a new artefact under `{path}` without consulting SK-CREATE-NEXO-PRIMITIVE or an equivalent primitive-choice probe. Stop. Run `nexo_skill_match` with the phrase "create a new primitive" (or the equivalent in your language) and follow the skill's decision table before creating. skill vs plugin vs personal-script vs schedule vs core-change is a durable decision — do not improvise. Do not produce visible text for this reminder; act on it silently.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
Does the assistant message below declare that a task is finished, completed, shipped, or
|
|
1
|
+
Does the assistant message below declare that a task is finished, completed, shipped, sent, delivered, published, deployed, released, fixed, resolved, merged, pushed, handed off, or otherwise closed on any plane (deploy, release, publish, email/message sent, PR merged, fix applied, task resolved)? Answer yes only if the assistant is claiming real completion of the current work (in any language — Spanish "listo/hecho/terminado/enviado/arreglado/desplegado/publicado/lanzado/resuelto/mergeado" counts equally). Answer no for status updates, mid-task progress reports, questions, partial summaries, or "I will now..." promises without execution in the same turn.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://nexo-brain.com/schemas/tool-enforcement-map-v2.1.json",
|
|
3
|
-
"version": "2.
|
|
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":
|
|
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": "
|
|
3444
|
+
"level": "must",
|
|
3445
3445
|
"rules": [
|
|
3446
3446
|
{
|
|
3447
3447
|
"type": "conditional",
|
|
3448
|
-
"threshold":
|
|
3449
|
-
"condition": "
|
|
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.
|
|
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
|
]
|