nexo-brain 7.4.1 → 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.4.1",
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,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.4.1` is the current packaged-runtime line. Hotfix minor release correcting three critical bugs that surfaced right after v7.2.0. B11 Guardian wire: new `src/hooks/pre_tool_use.py` entrypoint is now registered in `src/hooks/manifest.json` and `hooks/hooks.json` so the Claude Code PreToolUse event actually reaches Guardian before this, `guardian-runtime-overrides.json` in hard mode was silently inert for G3-destructive, G3-SSH, and G4-guard_check gates. A parallel SSH prescreen in `hook_guardrails.process_pre_tool_event` forces `op=write` on remote-write patterns the local shell classifier could not see. B10 post-sync: `_run_post_install_hooks_fresh(dest, env)` invokes each whitelisted hook (`_persist_guardian_hard_defaults`, `_maybe_promote_adaptive_weights_empirically`) in a clean subprocess against the newly copied tree, so the first `nexo update` that introduces a new post-install hook actually runs it previously the hook dispatch used the pre-upgrade module held in memory and silently no-op'd. B12 map distribution: `tool-enforcement-map.json` is now part of the npm `files` whitelist so fresh `npm install -g nexo-brain` ships it, closing the three-way gap (Brain npm + Desktop bundle + runtime sync) that prevented Desktop from ever discovering the map on new installs. Also ships the PE1 rapid items promised for this milestone: 5 additional `destructive_command` entries in `src/presets/entities_universal.json` (`curl_pipe_shell`, `dd_to_device`, `chmod_recursive_wide_open`, `ssh_remote_overwrite`, `scp_rsync_upload`; coverage floor raised from 7 to 12) and the `guardian-metrics` daily cron at 02:15 that feeds Fase C gate and the Guardian Proposals panel.
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 types before 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.
24
+
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.
22
26
 
23
27
  Previously in `7.2.0`: minor release consolidating three parallel workstreams into a single Guardian-active-by-default train. Block K roadmap closure (G1 enforcer active, G3 SSH remote-write detector, `src/guardian_runtime_config.py` resolver, `_persist_guardian_hard_defaults` during `nexo update`). F0.6 hardening wave (`nexo rollback f06` CLI, `src/scripts/prune_runtime_backups.py` promoted to core, `docs/f06-layout-contract.md`, three new doctor boot-tier checks, `scripts/nexo-migrate-nora.sh` + `scripts/f0-safe-apply-remote.sh` idempotent migration). Adaptive weights flipped from "14-day calendar wait" to "14 days OR (≥200 samples AND ≥2 days)" with auto-promotion during `nexo update`. Small-fixes batch: R34 `bool("unknown")==True` fix, `classify_scripts_dir` dedup, B10 module-level path constants lazy-evaluated, schedule override audit log, `scripts/pre-release-verify.sh` + `docs/release-discipline.md`, pre-commit hook that blocks commits when `tool-enforcement-map.json` drifts from `src/plugins/`.
24
28
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "7.4.1",
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",
package/src/cli.py CHANGED
@@ -3092,6 +3092,18 @@ def main():
3092
3092
  lstat_p = lifecycle_sub.add_parser("status", help="Read the current delivery_status of an event")
3093
3093
  lstat_p.add_argument("--event-id", required=True)
3094
3094
 
3095
+ lcomp_p = lifecycle_sub.add_parser(
3096
+ "complete-canonical",
3097
+ help="v7.5: confirm Desktop finished executing the canonical_actions a prior record returned",
3098
+ )
3099
+ lcomp_p.add_argument("--event-id", required=True)
3100
+ lcomp_p.add_argument("--plan-id", required=True, help="canonical_plan_id returned by the original record call")
3101
+ lcomp_p.add_argument(
3102
+ "--results",
3103
+ default="",
3104
+ help="JSON array of per-action outcomes, e.g. '[{\"action_id\":\"a1\",\"status\":\"ok\"}]'",
3105
+ )
3106
+
3095
3107
  # Fase E.5 — quarantine ops surfaced via Desktop Guardian Proposals panel.
3096
3108
  quarantine_parser = sub.add_parser("quarantine", help="Quarantine proposals (Fase E.5 Desktop UI)")
3097
3109
  quarantine_sub = quarantine_parser.add_subparsers(dest="quarantine_command")
@@ -3327,10 +3339,12 @@ def main():
3327
3339
  status = str(parsed.get("status", ""))
3328
3340
  except Exception:
3329
3341
  status = ""
3330
- # Exit code 0 for terminal ok states, 2 for retryable_error so
3331
- # Desktop can distinguish "persisted + processed" from "try
3332
- # again on boot reconciliation". Rejected is exit 3 (bad input).
3333
- if status in ("processed", "already_processed", "accepted"):
3342
+ # Exit code 0 for terminal ok states AND for canonical_pending
3343
+ # (v7.5: plan handed to Desktop, awaiting complete_canonical).
3344
+ # 2 for retryable_error so Desktop can distinguish "persisted +
3345
+ # processed" from "try again on boot reconciliation".
3346
+ # Rejected is exit 3 (bad input).
3347
+ if status in ("processed", "already_processed", "accepted", "canonical_pending"):
3334
3348
  return 0
3335
3349
  if status == "retryable_error":
3336
3350
  return 2
@@ -3339,6 +3353,23 @@ def main():
3339
3353
  out = _lifecycle_plugin.handle_nexo_lifecycle_status(args.event_id)
3340
3354
  print(out)
3341
3355
  return 0
3356
+ if args.lifecycle_command == "complete-canonical":
3357
+ out = _lifecycle_plugin.handle_nexo_lifecycle_complete_canonical(
3358
+ event_id=args.event_id,
3359
+ canonical_plan_id=args.plan_id,
3360
+ results=args.results or "",
3361
+ )
3362
+ print(out)
3363
+ try:
3364
+ parsed = _json.loads(out)
3365
+ status = str(parsed.get("status", ""))
3366
+ except Exception:
3367
+ status = ""
3368
+ if status in ("canonical_done", "already_processed"):
3369
+ return 0
3370
+ if status == "retryable_error":
3371
+ return 2
3372
+ return 3
3342
3373
  lifecycle_parser.print_help()
3343
3374
  return 1
3344
3375
  elif args.command in ("schema", "identity", "onboard", "scan-profile"):
package/src/db/_schema.py CHANGED
@@ -1351,6 +1351,48 @@ def _m51_lifecycle_events(conn):
1351
1351
  _migrate_add_index(conn, "idx_lifecycle_events_action", "lifecycle_events", "action")
1352
1352
 
1353
1353
 
1354
+ def _m52_lifecycle_canonical_plan(conn):
1355
+ """v7.5.0 — Brain promoted to canonical authority for session-end.
1356
+
1357
+ When the Desktop pipeline posts a close / delete / archive / app-exit
1358
+ lifecycle event with a live session_id, Brain now decides the exact
1359
+ prompt + sequence to run against the live Claude process and hands
1360
+ that plan back to Desktop in the same MCP call. Desktop executes
1361
+ the plan inline and confirms via a second call
1362
+ (``nexo_lifecycle_complete_canonical``).
1363
+
1364
+ New columns on ``lifecycle_events``:
1365
+
1366
+ - ``canonical_plan_id`` — deterministic id hash(event_id+plan_version).
1367
+ Used for idempotent retries: Desktop can ask "did you already
1368
+ finish this plan_id" and Brain can dedupe without re-running any
1369
+ diary write.
1370
+ - ``canonical_plan_version`` — schema version of the plan payload
1371
+ (INTEGER, default 1). Lets us evolve the action shape without
1372
+ breaking older Desktop builds.
1373
+ - ``canonical_actions_json`` — the actions array Brain returned,
1374
+ verbatim. Persisted so boot reconciliation can re-send the exact
1375
+ same plan on a crash between dispatch and confirm.
1376
+ - ``canonical_dispatched_at`` — first time Brain returned the plan.
1377
+ Used as the "since" cursor for the session_diary dedup query.
1378
+ - ``canonical_done_at`` — set only when Desktop calls
1379
+ ``nexo_lifecycle_complete_canonical``. Absence + presence of
1380
+ ``canonical_dispatched_at`` == "dispatched but not confirmed".
1381
+ - ``canonical_done_results`` — JSON array of per-action results
1382
+ reported by Desktop, used for telemetry and retry classification.
1383
+
1384
+ Idempotent. Fresh installs already created the table in m51; this
1385
+ migration only ADDs the new columns.
1386
+ """
1387
+ _migrate_add_column(conn, "lifecycle_events", "canonical_plan_id", "TEXT DEFAULT NULL")
1388
+ _migrate_add_column(conn, "lifecycle_events", "canonical_plan_version", "INTEGER DEFAULT NULL")
1389
+ _migrate_add_column(conn, "lifecycle_events", "canonical_actions_json", "TEXT DEFAULT NULL")
1390
+ _migrate_add_column(conn, "lifecycle_events", "canonical_dispatched_at", "TEXT DEFAULT NULL")
1391
+ _migrate_add_column(conn, "lifecycle_events", "canonical_done_at", "TEXT DEFAULT NULL")
1392
+ _migrate_add_column(conn, "lifecycle_events", "canonical_done_results", "TEXT DEFAULT NULL")
1393
+ _migrate_add_index(conn, "idx_lifecycle_events_plan_id", "lifecycle_events", "canonical_plan_id")
1394
+
1395
+
1354
1396
  MIGRATIONS = [
1355
1397
  (1, "learnings_columns", _m1_learnings_columns),
1356
1398
  (2, "followups_reasoning", _m2_followups_reasoning),
@@ -1403,6 +1445,7 @@ MIGRATIONS = [
1403
1445
  (49, "protocol_guard_ack_backfill", _m49_protocol_guard_ack_backfill),
1404
1446
  (50, "dedupe_nexo_product_learning_pair", _m50_dedupe_nexo_product_learning_pair),
1405
1447
  (51, "lifecycle_events", _m51_lifecycle_events),
1448
+ (52, "lifecycle_canonical_plan", _m52_lifecycle_canonical_plan),
1406
1449
  ]
1407
1450
 
1408
1451
 
@@ -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,36 +1,52 @@
1
- """NEXO Brain — canonical lifecycle event handler (v7.4.0).
2
-
3
- Companion to nexo-desktop's ConversationLifecycleService. Desktop
4
- persists every conversation/app transition (close / delete / archive /
5
- switch / window-close / app-exit) to an append-only NDJSON queue BEFORE
6
- any UI mutation becomes visible, then calls this handler via the
7
- ``nexo_lifecycle_event`` MCP tool. The handler is strictly idempotent:
8
- re-delivery of the same ``event_id`` returns ``already_processed``
9
- without replaying any canonical side effect.
10
-
11
- Canonical side effects are intentionally minimal in this first slice:
12
-
13
- - ``close`` / ``delete`` / ``archive`` / ``app-exit`` / ``window-close``
14
- mark processed. Diary / stop inside the conversation are driven by
15
- Desktop's graceful-close flow (``conv-close`` IPC → nexo CLI) and
16
- remain the authority for that per-conversation payload. This table
17
- is the durable ledger so the next boot can reconcile.
18
- - ``switch`` → mark processed. No canonical side effect beyond the
19
- audit trail; the ledger still matters for telemetry and guard
20
- invariants ("operator switched away from a conversation that still
21
- had uncommitted claims").
22
-
23
- Return shape matches the plan (lines 94-100):
24
-
25
- - ``processed`` first delivery, side effects (if any) done
26
- - ``already_processed`` duplicate delivery, no re-run
27
- - ``accepted`` persisted, side effect deferred (not used yet)
28
- - ``rejected`` malformed input, no persistence
29
- - ``retryable_error`` transient failure, Desktop should retry
30
-
31
- Any row keeps ``delivery_status`` as the latest terminal or retryable
32
- status so ``nexo_lifecycle_status`` / future reconciliation queries
33
- can read it directly.
1
+ """NEXO Brain — canonical lifecycle event handler (v7.5).
2
+
3
+ v7.4.x shipped this tool as a pure ledger + reconciliation surface:
4
+ Desktop persisted every conversation lifecycle transition locally,
5
+ called ``nexo_lifecycle_event`` for book-keeping, and ran its own
6
+ hardcoded ``diary + stop`` prompts against the live Claude process
7
+ during ``closeConversationGraceful``.
8
+
9
+ v7.5 promotes this handler to the **canonical authority** for
10
+ session-end. For every ``close`` / ``delete`` / ``archive`` /
11
+ ``app-exit`` event with a live ``session_id``, Brain now generates a
12
+ deterministic **canonical plan** (``canonical_plan_id``, versioned
13
+ action list) and hands it back in the same MCP call. Desktop executes
14
+ the plan inline (Desktop is still the only process that can reach the
15
+ Claude proc's stdin) and then calls
16
+ ``nexo_lifecycle_complete_canonical`` with per-action results. Brain
17
+ records ``canonical_done_at`` only on that second call no polling.
18
+
19
+ Idempotency is real, not cosmetic:
20
+
21
+ 1. ``canonical_plan_id`` is deterministic: ``sha256(event_id + version)``.
22
+ A retry of the same event returns the same plan id, which Desktop
23
+ can use to skip actions it already completed locally.
24
+ 2. Before regenerating a plan for a previously dispatched event, Brain
25
+ checks whether the session already wrote a ``session_diary`` row
26
+ after the original ``canonical_dispatched_at``. If it did → the
27
+ answer is ``already_processed``; no re-dispatch, no duplicate diary.
28
+
29
+ Status values:
30
+
31
+ - ``processed`` first delivery, no canonical plan applicable
32
+ (e.g. switch / window-close / missing
33
+ session_id).
34
+ - ``canonical_pending`` plan generated and returned. Desktop is
35
+ expected to execute + confirm.
36
+ - ``canonical_dispatched`` alias for ``canonical_pending`` on a row
37
+ that already has ``canonical_dispatched_at``
38
+ set (re-delivery case).
39
+ - ``canonical_done`` Desktop confirmed via complete_canonical.
40
+ - ``already_processed`` idempotent duplicate, no re-run.
41
+ - ``accepted`` persisted, no canonical side effect required.
42
+ - ``rejected`` malformed input.
43
+ - ``retryable_error`` a canonical action failed (inject timeout,
44
+ stdin closed, etc). Reconciler can retry
45
+ with the same plan_id.
46
+
47
+ Actions that carry a canonical plan: ``close``, ``delete``, ``archive``,
48
+ ``app-exit``. ``switch`` and ``window-close`` still return
49
+ ``accepted`` (no live-session work to do).
34
50
  """
35
51
  from __future__ import annotations
36
52
 
@@ -38,6 +54,7 @@ import json
38
54
  from typing import Any, Dict, Optional
39
55
 
40
56
  from db import get_db
57
+ import lifecycle_prompts
41
58
 
42
59
 
43
60
  VALID_ACTIONS = {
@@ -49,8 +66,14 @@ VALID_ACTIONS = {
49
66
  "window-close",
50
67
  }
51
68
 
52
- TERMINAL_STATUSES = {"processed", "already_processed", "rejected"}
53
- _DIARY_TRIGGERING = {"close", "delete", "archive", "app-exit"}
69
+ # Terminal for the user of the ledger (no further action expected).
70
+ TERMINAL_STATUSES = {
71
+ "processed",
72
+ "canonical_done",
73
+ "already_processed",
74
+ "rejected",
75
+ }
76
+ _DIARY_TRIGGERING = lifecycle_prompts.DIARY_TRIGGERING_ACTIONS
54
77
 
55
78
 
56
79
  def _normalise_payload(obj: Any) -> str:
@@ -60,6 +83,26 @@ def _normalise_payload(obj: Any) -> str:
60
83
  return "{}"
61
84
 
62
85
 
86
+ def _session_diary_since(conn, session_id: str, dispatched_at: Optional[str]) -> bool:
87
+ """True if session_diary has a row for ``session_id`` created after
88
+ ``dispatched_at``. Used by the canonical-authority idempotency
89
+ guard: if the live session already produced a diary since the
90
+ plan was handed out, we must NOT re-dispatch it.
91
+ """
92
+ if not session_id or not dispatched_at:
93
+ return False
94
+ try:
95
+ row = conn.execute(
96
+ "SELECT 1 FROM session_diary "
97
+ "WHERE session_id = ? AND created_at > ? LIMIT 1",
98
+ (str(session_id), str(dispatched_at)),
99
+ ).fetchone()
100
+ except Exception:
101
+ # Missing table on a minimal test harness — treat as "no diary".
102
+ return False
103
+ return row is not None
104
+
105
+
63
106
  def record_lifecycle_event(
64
107
  event_id: str,
65
108
  action: str,
@@ -70,9 +113,15 @@ def record_lifecycle_event(
70
113
  source: str = "desktop",
71
114
  schema_version: int = 1,
72
115
  ) -> Dict[str, Any]:
73
- """Idempotent upsert + process.
116
+ """Idempotent upsert + canonical plan generation (v7.5).
74
117
 
75
- Returns ``{status, event_id, diary_triggered, duplicate}``.
118
+ Returns ``{status, event_id, ...}`` where ``status`` is one of:
119
+ ``rejected`` | ``already_processed`` | ``processed`` |
120
+ ``canonical_pending`` | ``accepted``. When the answer is
121
+ ``canonical_pending``, the response also carries
122
+ ``canonical_plan_id``, ``canonical_plan_version`` and
123
+ ``canonical_actions[]`` — Desktop must execute those actions and
124
+ confirm via ``record_complete_canonical``.
76
125
  """
77
126
  if not event_id or not str(event_id).strip():
78
127
  return {"status": "rejected", "reason": "missing-event-id"}
@@ -83,12 +132,32 @@ def record_lifecycle_event(
83
132
 
84
133
  conn = get_db()
85
134
  existing = conn.execute(
86
- "SELECT delivery_status FROM lifecycle_events WHERE event_id = ?",
135
+ "SELECT delivery_status, canonical_plan_id, canonical_plan_version, "
136
+ "canonical_actions_json, canonical_dispatched_at, canonical_done_at "
137
+ "FROM lifecycle_events WHERE event_id = ?",
87
138
  (str(event_id),),
88
139
  ).fetchone()
89
140
 
141
+ plan = lifecycle_prompts.build_canonical_plan(
142
+ event_id=str(event_id),
143
+ action=str(action),
144
+ conversation_id=str(conversation_id),
145
+ session_id=str(session_id) if session_id else None,
146
+ payload_snapshot=payload_snapshot or {},
147
+ )
148
+
90
149
  if existing is not None:
91
150
  status = str(existing[0] or "")
151
+ prior_plan_id = existing[1]
152
+ prior_actions_json = existing[3]
153
+ prior_dispatched_at = existing[5] # column 5 is canonical_done_at — reuse?
154
+ # column indices (0-5): delivery_status, canonical_plan_id,
155
+ # canonical_plan_version, canonical_actions_json,
156
+ # canonical_dispatched_at, canonical_done_at.
157
+ prior_dispatched_at = existing[4]
158
+ prior_done_at = existing[5]
159
+
160
+ # Case A: terminal status already recorded — hard idempotency.
92
161
  if status in TERMINAL_STATUSES:
93
162
  return {
94
163
  "status": "already_processed",
@@ -96,8 +165,73 @@ def record_lifecycle_event(
96
165
  "duplicate": True,
97
166
  "prior_status": status,
98
167
  }
99
- # Non-terminal row (accepted / retryable_error) — flip to processed
100
- # now and record the transition.
168
+
169
+ # Case B: canonical was dispatched but never confirmed. Check
170
+ # whether the live session wrote a diary after dispatch; if so
171
+ # the intent has already been satisfied by the model and we
172
+ # must NOT ask Desktop to re-run the plan.
173
+ if prior_plan_id and prior_dispatched_at and not prior_done_at:
174
+ if session_id and _session_diary_since(conn, str(session_id), str(prior_dispatched_at)):
175
+ conn.execute(
176
+ "UPDATE lifecycle_events "
177
+ "SET delivery_status = 'already_processed', "
178
+ " canonical_done_at = datetime('now'), "
179
+ " last_error = NULL "
180
+ "WHERE event_id = ?",
181
+ (str(event_id),),
182
+ )
183
+ conn.commit()
184
+ return {
185
+ "status": "already_processed",
186
+ "event_id": event_id,
187
+ "duplicate": True,
188
+ "prior_status": status,
189
+ "reason": "session_diary-already-written",
190
+ }
191
+ # Re-hand the exact same plan so Desktop can resume / finish
192
+ # any actions it didn't complete before the crash.
193
+ try:
194
+ actions = json.loads(prior_actions_json) if prior_actions_json else []
195
+ except Exception:
196
+ actions = []
197
+ return {
198
+ "status": "canonical_pending",
199
+ "event_id": event_id,
200
+ "canonical_plan_id": prior_plan_id,
201
+ "canonical_plan_version": int(existing[2] or lifecycle_prompts.PLAN_VERSION),
202
+ "canonical_actions": actions,
203
+ "resumed_from_dispatch": True,
204
+ }
205
+
206
+ # Case C: non-terminal, no canonical plan yet — flip to processed
207
+ # (legacy ledger semantics) OR upgrade to canonical_pending if a
208
+ # plan applies.
209
+ if plan is not None:
210
+ conn.execute(
211
+ "UPDATE lifecycle_events "
212
+ "SET delivery_status = 'canonical_pending', "
213
+ " canonical_plan_id = ?, "
214
+ " canonical_plan_version = ?, "
215
+ " canonical_actions_json = ?, "
216
+ " canonical_dispatched_at = datetime('now'), "
217
+ " last_error = NULL "
218
+ "WHERE event_id = ?",
219
+ (
220
+ plan["canonical_plan_id"],
221
+ int(plan["canonical_plan_version"]),
222
+ json.dumps(plan["canonical_actions"], ensure_ascii=False),
223
+ str(event_id),
224
+ ),
225
+ )
226
+ conn.commit()
227
+ return {
228
+ "status": "canonical_pending",
229
+ "event_id": event_id,
230
+ "canonical_plan_id": plan["canonical_plan_id"],
231
+ "canonical_plan_version": plan["canonical_plan_version"],
232
+ "canonical_actions": plan["canonical_actions"],
233
+ "reopened": True,
234
+ }
101
235
  conn.execute(
102
236
  "UPDATE lifecycle_events SET delivery_status = 'processed', "
103
237
  "processed_at = datetime('now'), last_error = NULL "
@@ -113,6 +247,45 @@ def record_lifecycle_event(
113
247
  "reopened": True,
114
248
  }
115
249
 
250
+ # Brand new event.
251
+ if plan is not None:
252
+ conn.execute(
253
+ """
254
+ INSERT INTO lifecycle_events (
255
+ event_id, schema_version, source, action, conversation_id,
256
+ session_id, reason, payload_snapshot, delivery_status,
257
+ retry_count, canonical_plan_id, canonical_plan_version,
258
+ canonical_actions_json, canonical_dispatched_at
259
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'canonical_pending', 0,
260
+ ?, ?, ?, datetime('now'))
261
+ """,
262
+ (
263
+ str(event_id),
264
+ int(schema_version or 1),
265
+ str(source or "desktop"),
266
+ str(action),
267
+ str(conversation_id),
268
+ str(session_id) if session_id else None,
269
+ str(reason or "user_action"),
270
+ _normalise_payload(payload_snapshot),
271
+ plan["canonical_plan_id"],
272
+ int(plan["canonical_plan_version"]),
273
+ json.dumps(plan["canonical_actions"], ensure_ascii=False),
274
+ ),
275
+ )
276
+ conn.commit()
277
+ return {
278
+ "status": "canonical_pending",
279
+ "event_id": event_id,
280
+ "canonical_plan_id": plan["canonical_plan_id"],
281
+ "canonical_plan_version": plan["canonical_plan_version"],
282
+ "canonical_actions": plan["canonical_actions"],
283
+ "duplicate": False,
284
+ }
285
+
286
+ # No plan: ledger-only record (switch/window-close or missing
287
+ # session_id on a diary-triggering action). Mark processed
288
+ # immediately so callers get the v7.4.x contract back.
116
289
  conn.execute(
117
290
  """
118
291
  INSERT INTO lifecycle_events (
@@ -133,7 +306,6 @@ def record_lifecycle_event(
133
306
  ),
134
307
  )
135
308
  conn.commit()
136
-
137
309
  return {
138
310
  "status": "processed",
139
311
  "event_id": event_id,
@@ -142,13 +314,94 @@ def record_lifecycle_event(
142
314
  }
143
315
 
144
316
 
317
+ def record_complete_canonical(
318
+ event_id: str,
319
+ canonical_plan_id: str,
320
+ results: Optional[list] = None,
321
+ ) -> Dict[str, Any]:
322
+ """Close the 2-call contract: Desktop confirms it executed the plan.
323
+
324
+ Inputs:
325
+ - ``event_id``: the original event id.
326
+ - ``canonical_plan_id``: must match the one Brain handed out. A
327
+ mismatch means Desktop is confirming a stale plan — we ignore
328
+ it and answer ``rejected``.
329
+ - ``results``: list of ``{action_id, status, ...}``. If any
330
+ ``status != 'ok'`` we flip the row to ``retryable_error`` and
331
+ keep ``canonical_dispatched_at`` intact so reconciliation can
332
+ re-ask.
333
+
334
+ Returns the effective row status after the call.
335
+ """
336
+ if not event_id:
337
+ return {"status": "rejected", "reason": "missing-event-id"}
338
+ if not canonical_plan_id:
339
+ return {"status": "rejected", "reason": "missing-canonical-plan-id"}
340
+
341
+ conn = get_db()
342
+ row = conn.execute(
343
+ "SELECT delivery_status, canonical_plan_id, canonical_done_at "
344
+ "FROM lifecycle_events WHERE event_id = ?",
345
+ (str(event_id),),
346
+ ).fetchone()
347
+ if row is None:
348
+ return {"status": "rejected", "reason": "unknown-event-id"}
349
+ current_status = str(row[0] or "")
350
+ expected_plan = row[1]
351
+ already_done_at = row[2]
352
+
353
+ if expected_plan and canonical_plan_id != expected_plan:
354
+ return {
355
+ "status": "rejected",
356
+ "reason": "canonical_plan_id-mismatch",
357
+ "expected": expected_plan,
358
+ "received": canonical_plan_id,
359
+ }
360
+ if already_done_at and current_status == "canonical_done":
361
+ return {
362
+ "status": "already_processed",
363
+ "event_id": event_id,
364
+ "duplicate": True,
365
+ }
366
+
367
+ results_list = list(results or [])
368
+ any_failure = any(
369
+ str((r or {}).get("status", "")).lower() not in {"ok", "success", "already_processed"}
370
+ for r in results_list
371
+ )
372
+ effective = "retryable_error" if any_failure else "canonical_done"
373
+ conn.execute(
374
+ "UPDATE lifecycle_events "
375
+ "SET delivery_status = ?, "
376
+ " canonical_done_at = datetime('now'), "
377
+ " canonical_done_results = ?, "
378
+ " last_error = ? "
379
+ "WHERE event_id = ?",
380
+ (
381
+ effective,
382
+ json.dumps(results_list, ensure_ascii=False),
383
+ "one-or-more-actions-failed" if any_failure else None,
384
+ str(event_id),
385
+ ),
386
+ )
387
+ conn.commit()
388
+ return {
389
+ "status": effective,
390
+ "event_id": event_id,
391
+ "canonical_plan_id": canonical_plan_id,
392
+ "failed_actions": any_failure,
393
+ }
394
+
395
+
145
396
  def get_lifecycle_event(event_id: str) -> Optional[Dict[str, Any]]:
146
397
  if not event_id:
147
398
  return None
148
399
  row = get_db().execute(
149
400
  "SELECT event_id, schema_version, source, action, conversation_id, "
150
401
  "session_id, reason, payload_snapshot, delivery_status, retry_count, "
151
- "created_at, processed_at, last_error "
402
+ "created_at, processed_at, last_error, "
403
+ "canonical_plan_id, canonical_plan_version, canonical_actions_json, "
404
+ "canonical_dispatched_at, canonical_done_at, canonical_done_results "
152
405
  "FROM lifecycle_events WHERE event_id = ?",
153
406
  (str(event_id),),
154
407
  ).fetchone()
@@ -158,6 +411,14 @@ def get_lifecycle_event(event_id: str) -> Optional[Dict[str, Any]]:
158
411
  payload = json.loads(row[7] or "{}")
159
412
  except Exception:
160
413
  payload = {}
414
+ try:
415
+ actions = json.loads(row[15]) if row[15] else None
416
+ except Exception:
417
+ actions = None
418
+ try:
419
+ results = json.loads(row[18]) if row[18] else None
420
+ except Exception:
421
+ results = None
161
422
  return {
162
423
  "event_id": row[0],
163
424
  "schema_version": row[1],
@@ -172,6 +433,12 @@ def get_lifecycle_event(event_id: str) -> Optional[Dict[str, Any]]:
172
433
  "created_at": row[10],
173
434
  "processed_at": row[11],
174
435
  "last_error": row[12],
436
+ "canonical_plan_id": row[13],
437
+ "canonical_plan_version": row[14],
438
+ "canonical_actions": actions,
439
+ "canonical_dispatched_at": row[16],
440
+ "canonical_done_at": row[17],
441
+ "canonical_done_results": results,
175
442
  }
176
443
 
177
444
 
@@ -0,0 +1,138 @@
1
+ """NEXO Brain — canonical lifecycle action templates (v7.5).
2
+
3
+ Brain owns the prompt that Desktop injects into a live Claude session
4
+ at close / delete / archive / app-exit time. Desktop never hardcodes
5
+ the wording; it just receives a list of ``canonical_actions`` and
6
+ executes them against the conversation's stdin / lifecycle.
7
+
8
+ The template version is bumped whenever the prompt or the action
9
+ schema changes. The version is part of ``canonical_plan_id`` so two
10
+ dispatches of the same event produced by two different Brain versions
11
+ do NOT collide (a retry from an older Desktop hitting a newer Brain
12
+ will get the newer plan; a retry of a previous plan reuses the same
13
+ id because the event_id hasn't changed).
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import hashlib
18
+ import json
19
+ from typing import Any, Dict, List, Optional
20
+
21
+
22
+ PLAN_VERSION = 1
23
+
24
+
25
+ # Actions that trigger a canonical diary+stop plan. `switch` and
26
+ # `window-close` never do: they're observational transitions that Brain
27
+ # records in the ledger but doesn't orchestrate anything against the
28
+ # live session (the session keeps running after a switch, and a window
29
+ # close to the tray doesn't end the conversation).
30
+ DIARY_TRIGGERING_ACTIONS = {"close", "delete", "archive", "app-exit"}
31
+
32
+
33
+ # Default per-action timeout (ms). Desktop honours this when it
34
+ # executes each action; on timeout it reports status=failed and Brain
35
+ # flips delivery_status to retryable_error.
36
+ DEFAULT_RESUME_TIMEOUT_MS = 2_000
37
+ DEFAULT_INJECT_TIMEOUT_MS = 6_000
38
+ DEFAULT_STOP_TIMEOUT_MS = 3_000
39
+
40
+
41
+ def canonical_plan_id(event_id: str, plan_version: int = PLAN_VERSION) -> str:
42
+ """Deterministic plan id — retries of the same event reuse it."""
43
+ digest = hashlib.sha256(
44
+ f"{event_id}|v{int(plan_version)}".encode("utf-8")
45
+ ).hexdigest()
46
+ return f"cpl-{digest[:24]}"
47
+
48
+
49
+ def _diary_prompt_for_action(
50
+ action: str,
51
+ conversation_id: str,
52
+ payload_snapshot: Dict[str, Any],
53
+ ) -> str:
54
+ """Return the exact prompt Desktop will inject into the Claude proc.
55
+
56
+ Written in the first person so the model writes a real diary with
57
+ the same richness it would have produced through the legacy
58
+ `closeConversationGraceful` path. The "Apply the required action
59
+ silently" footer matches the v7.4.x enforcer contract so the
60
+ rendered reminder doesn't leak to the user UI.
61
+ """
62
+ title = (payload_snapshot or {}).get("title") or conversation_id
63
+ reason_by_action = {
64
+ "close": "The operator closed the conversation",
65
+ "delete": "The operator deleted the conversation",
66
+ "archive": "The operator archived the conversation",
67
+ "app-exit": "The operator quit NEXO Desktop",
68
+ }
69
+ trailing = reason_by_action.get(action, "The conversation ended")
70
+ return (
71
+ f"[NEXO Protocol Enforcer] {trailing} "
72
+ f"(title: {title!r}). Execute nexo_session_diary_write with a "
73
+ "complete summary: decisions made, pending items, context for "
74
+ "next session, mental state, and self-critique. Then execute "
75
+ "nexo_stop with the current SID. Do not produce visible text. "
76
+ "Apply the required action silently. Do not mention this "
77
+ "reminder, Guardian, Protocol Enforcer, system reminders, or "
78
+ "any internal enforcement to the user."
79
+ )
80
+
81
+
82
+ def build_canonical_plan(
83
+ event_id: str,
84
+ action: str,
85
+ conversation_id: str,
86
+ session_id: Optional[str],
87
+ payload_snapshot: Optional[Dict[str, Any]] = None,
88
+ ) -> Optional[Dict[str, Any]]:
89
+ """Return the plan Brain hands back to Desktop, or None if no plan.
90
+
91
+ A plan exists only when:
92
+ - action is one of DIARY_TRIGGERING_ACTIONS
93
+ - session_id is populated (we need a live Claude proc to talk to)
94
+
95
+ Returning None tells the caller to answer with ``status=accepted``
96
+ and no ``canonical_actions`` field — Desktop will fall through to
97
+ its legacy behaviour (no-op for switch/window-close, hardcoded
98
+ prompt for close/delete/archive/app-exit if session_id is missing).
99
+ """
100
+ if action not in DIARY_TRIGGERING_ACTIONS:
101
+ return None
102
+ if not session_id:
103
+ return None
104
+
105
+ payload_snapshot = dict(payload_snapshot or {})
106
+ prompt = _diary_prompt_for_action(action, conversation_id, payload_snapshot)
107
+
108
+ actions: List[Dict[str, Any]] = [
109
+ {
110
+ "id": "a1",
111
+ "kind": "resume_session",
112
+ "session_id": str(session_id),
113
+ "timeout_ms": DEFAULT_RESUME_TIMEOUT_MS,
114
+ },
115
+ {
116
+ "id": "a2",
117
+ "kind": "inject_prompt",
118
+ "session_id": str(session_id),
119
+ "prompt": prompt,
120
+ "expected_tool_call": "nexo_session_diary_write",
121
+ "timeout_ms": DEFAULT_INJECT_TIMEOUT_MS,
122
+ },
123
+ {
124
+ "id": "a3",
125
+ "kind": "stop_session",
126
+ "session_id": str(session_id),
127
+ "timeout_ms": DEFAULT_STOP_TIMEOUT_MS,
128
+ },
129
+ ]
130
+ return {
131
+ "canonical_plan_id": canonical_plan_id(event_id, PLAN_VERSION),
132
+ "canonical_plan_version": PLAN_VERSION,
133
+ "canonical_actions": actions,
134
+ }
135
+
136
+
137
+ def canonical_plan_as_json(plan: Dict[str, Any]) -> str:
138
+ return json.dumps(plan, ensure_ascii=False, sort_keys=True)
@@ -99,15 +99,68 @@ def handle_nexo_lifecycle_status(event_id: str) -> str:
99
99
  return json.dumps(row, ensure_ascii=False)
100
100
 
101
101
 
102
+ def handle_nexo_lifecycle_complete_canonical(
103
+ event_id: str,
104
+ canonical_plan_id: str,
105
+ results: str = "",
106
+ ) -> str:
107
+ """Close the v7.5 canonical-authority 2-call contract.
108
+
109
+ Desktop calls this tool after executing every action Brain returned
110
+ in the ``canonical_actions`` array from ``nexo_lifecycle_event``.
111
+ Brain then records ``canonical_done_at`` and flips the row status
112
+ to ``canonical_done`` (or ``retryable_error`` if any action
113
+ failed).
114
+
115
+ Args:
116
+ event_id: UUID of the original lifecycle event.
117
+ canonical_plan_id: The plan id Brain returned. Mismatch → rejected.
118
+ results: JSON-encoded array of per-action outcomes:
119
+ ``[{"action_id": "a1", "status": "ok"|"failed", ...}, ...]``
120
+
121
+ Returns:
122
+ JSON ack with the effective row status.
123
+ """
124
+ parsed_results: list = []
125
+ if results:
126
+ try:
127
+ parsed = json.loads(results)
128
+ if isinstance(parsed, list):
129
+ parsed_results = parsed
130
+ else:
131
+ parsed_results = [{"status": "failed", "reason": "results-not-array", "raw": str(results)[:200]}]
132
+ except Exception as exc:
133
+ parsed_results = [{"status": "failed", "reason": f"malformed-results:{exc}"}]
134
+
135
+ try:
136
+ ack = lifecycle_events.record_complete_canonical(
137
+ event_id=str(event_id or ""),
138
+ canonical_plan_id=str(canonical_plan_id or ""),
139
+ results=parsed_results,
140
+ )
141
+ except Exception as exc:
142
+ return json.dumps({
143
+ "status": "retryable_error",
144
+ "reason": f"{type(exc).__name__}: {exc}",
145
+ "handler_threw": True,
146
+ }, ensure_ascii=False)
147
+ return json.dumps(ack, ensure_ascii=False)
148
+
149
+
102
150
  TOOLS = [
103
151
  (
104
152
  handle_nexo_lifecycle_event,
105
153
  "nexo_lifecycle_event",
106
- "Record a durable lifecycle event (close/delete/archive/switch/app-exit/window-close) and return a canonical ack.",
154
+ "Record a durable lifecycle event (close/delete/archive/switch/app-exit/window-close). In v7.5 Brain returns a canonical_actions plan for diary-triggering actions with a live session_id.",
107
155
  ),
108
156
  (
109
157
  handle_nexo_lifecycle_status,
110
158
  "nexo_lifecycle_status",
111
159
  "Read the current delivery_status of a lifecycle event. Used by Desktop boot reconciliation.",
112
160
  ),
161
+ (
162
+ handle_nexo_lifecycle_complete_canonical,
163
+ "nexo_lifecycle_complete_canonical",
164
+ "Confirm that Desktop finished executing the canonical_actions Brain handed out in a prior nexo_lifecycle_event call. Brain marks canonical_done_at only on this confirmation.",
165
+ ),
113
166
  ]
@@ -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.",
@@ -1869,6 +1869,19 @@
1869
1869
  },
1870
1870
  "triggers_after": []
1871
1871
  },
1872
+ "nexo_lifecycle_complete_canonical": {
1873
+ "description": "Close the v7.5 canonical-authority 2-call contract: confirm that Desktop finished executing the canonical_actions Brain handed out.",
1874
+ "category": "lifecycle",
1875
+ "source": "plugin:lifecycle_events",
1876
+ "requires": [],
1877
+ "provides": [],
1878
+ "internal_calls": [],
1879
+ "enforcement": {
1880
+ "level": "none",
1881
+ "rules": []
1882
+ },
1883
+ "triggers_after": []
1884
+ },
1872
1885
  "nexo_media_memory_add": {
1873
1886
  "description": "Store non-text artifact metadata",
1874
1887
  "category": "media",
@@ -3428,15 +3441,16 @@
3428
3441
  "_attention_snapshot"
3429
3442
  ],
3430
3443
  "enforcement": {
3431
- "level": "should",
3444
+ "level": "must",
3432
3445
  "rules": [
3433
3446
  {
3434
3447
  "type": "conditional",
3435
- "threshold": 10,
3436
- "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."
3437
3451
  }
3438
3452
  ],
3439
- "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.",
3440
3454
  "triggers_after": [
3441
3455
  "nexo_task_close"
3442
3456
  ]