nexo-brain 7.1.8 → 7.2.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.1.8",
3
+ "version": "7.2.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.1.8` is the current packaged-runtime line. It batches the Block K Guardian/Enforcer roadmap (auto-drain of stale `protocol_debt` rows, destructive-command pre-tool gate, `guard_check`-required gate, inline guard ack on `nexo_task_open`, Guardian Health in the morning briefing) with Block D hardcode cleanup (classifier-backed `backfill_task_owner`, migration v50 supersedes the duplicate NEXO-product learning pair, new semantic-hardcodes audit) and Block E product guards (LaunchAgent plist protection, agent-name fallbacks no longer leak the product identity, `francisco_emails` removed from the email-config dict export, `runner-health-check.py` + `nexo_personal_automation.py` promoted from personal to core). All new gates ship in shadow mode with env-flag rollout to `hard`. No coordinated Desktop release is required for this patch; Desktop consumers continue against the same CLI / MCP contract.
21
+ Version `7.2.0` is the current packaged-runtime line. It is a minor release that consolidates three parallel workstreams into a single Guardian-active-by-default train. Block K roadmap closure: G1 enforcer active (PostToolUse nudge when the latest `nexo_task_open` returned `mode {defer, ask, verify}` and the operator has not executed the paired `next_action`), G3 SSH remote-write detector (catches `ssh host "cat > ..."`, `scp upload`, `rsync upload`, `sftp -b batch` and 10+ other patterns the local destructive gate never saw), new `src/guardian_runtime_config.py` resolver (env > `~/.nexo/personal/config/guardian-runtime-overrides.json` > default), and `_persist_guardian_hard_defaults` during `nexo update` so every non-ephemeral install gets Guardian in `hard` without manual env vars. F0.6 hardening wave: `nexo rollback f06` CLI with two-stage safe swap, `src/scripts/prune_runtime_backups.py` promoted from personal to core, the authoritative `docs/f06-layout-contract.md`, three new doctor boot-tier checks (`check_core_dev_packaged_install`, `check_dashboard_desktop_contract`, `check_f06_migration_consistency`), `scripts/nexo-migrate-nora.sh` + `scripts/f0-safe-apply-remote.sh` idempotent migration workflow, and v6.x→F0.6 rollback hardening tests. Adaptive weights: flips from "14-day calendar wait" to "14 days OR (≥200 samples AND ≥2 days)" + `_maybe_promote_adaptive_weights_empirically` auto-promotes shadow→learned during `nexo update` so mature installs feel the Cortex calibration immediately. Small-fixes batch: R34 `bool("unknown")==True` fix, `classify_scripts_dir` dedup by realpath, B10 module-level path constants lazy-evaluated in `public_contribution.py` / `tools_sessions.py` / `plugins/recover.py` / `plugins/update.py`, schedule override audit log at `runtime/logs/core-schedule-overrides.log`, `scripts/pre-release-verify.sh` + `docs/release-discipline.md`, and a pre-commit hook that blocks commits when `tool-enforcement-map.json` drifts from `src/plugins/`.
22
+
23
+ Previously in `7.1.10`: follow-up over v7.1.8 that shipped two rescue batches of WIP stashed aside during the v7.1.8 release window. First rescue: `src/autonomy_mandate.py` expanded the mandate-detection vocabulary (hazlo todo / no pares / estás al mando / te dejo al mando / sigue sin parar / haz el plan completo), added three honest flags on `MandateState` (`execute_until_blocker`, `suppress_mid_task_menus`, `revalidate_after_compaction`) with session filtering, wired post/pre-compact hooks that read those flags, surfaced them through protocol/workflow handlers and session payload, and introduced the new `src/checkpoint_policy.py` module with tests. Second rescue: `scripts/verify_release_readiness.py` gained a smoke-artifact contract pass that validates `release-contracts/smoke/v<version>.json` before any tag push, the release-final audit skill references the new contract, `src/hook_guardrails.py` + `src/hooks/post_tool_use.py` refine the post-tool protocol reminder path with a new contract test, and a couple of core prompts (task-close evidence, r14 correction learning) got wording polish.
24
+
25
+ Previously in `7.1.8`: batch release over v7.1.7 consolidating the Block K Guardian/Enforcer roadmap (auto-drain of stale `protocol_debt` rows, destructive-command pre-tool gate, `guard_check`-required gate, inline guard ack on `nexo_task_open`, Guardian Health in the morning briefing) with Block D hardcode cleanup (classifier-backed `backfill_task_owner`, migration v50 supersedes the duplicate NEXO-product learning pair, new semantic-hardcodes audit) and Block E product guards (LaunchAgent plist protection, agent-name fallbacks no longer leak the product identity, `francisco_emails` removed from the email-config dict export, `runner-health-check.py` + `nexo_personal_automation.py` promoted from personal to core).
22
26
 
23
27
  Previously in `7.0.1`: hotfix over v7.0.0 (db._core.DB_PATH was only caller still hardcoded to legacy ~/.nexo/data/nexo.db; every shared-DB command silently returned empty results post-migration). Previously in `7.0.0`: **BREAKING — Plan Consolidado fase F0.6**: physical separation of the runtime tree into `~/.nexo/{core,personal,runtime}/`. The flat layout (`~/.nexo/scripts/`, `brain/`, `data/`, `operations/`, ...) is gone. Operators on v6.x are auto-migrated on first `nexo update`; fresh installs land directly in the new tree. New `paths.py` helpers are transition-aware.
24
28
 
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "7.1.8",
3
+ "version": "7.2.0",
4
4
  "mcpName": "io.github.wazionapps/nexo",
5
- "description": "NEXO Brain 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.",
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",
7
7
  "bin": {
8
8
  "nexo-brain": "./bin/nexo-brain.js",
@@ -389,25 +389,65 @@ def _write_last_check(data: dict):
389
389
 
390
390
 
391
391
  def _sync_watchdog_hash_registry():
392
- """Keep the immutable-hash registry aligned with the installed watchdog script."""
392
+ """Keep the immutable-hash registry aligned with the installed watchdog script.
393
+
394
+ Folds any pre-F0.6 legacy registry at ``paths.legacy_watchdog_hashes_path()``
395
+ into the canonical F0.6 file and then drops the legacy artifact so a later
396
+ ``ls ~/.nexo/scripts/.watchdog-hashes`` is empty (unless ``~/.nexo/scripts``
397
+ is itself a symlink to the canonical dir, in which case both paths already
398
+ point at the same inode).
399
+ """
393
400
  try:
394
401
  watchdog_file = paths.core_scripts_dir() / "nexo-watchdog.sh"
395
402
  if not watchdog_file.exists():
396
403
  return
397
404
  registry_file = paths.core_scripts_dir() / ".watchdog-hashes"
405
+ legacy_registry_file = paths.legacy_watchdog_hashes_path()
398
406
  entries: dict[str, str] = {}
399
- if registry_file.exists():
400
- for line in registry_file.read_text().splitlines():
401
- if "|" not in line:
402
- continue
403
- filepath, expected = line.split("|", 1)
404
- if filepath:
405
- entries[filepath] = expected
407
+
408
+ def _load(path: Path) -> None:
409
+ if not path.exists():
410
+ return
411
+ try:
412
+ for line in path.read_text().splitlines():
413
+ if "|" not in line:
414
+ continue
415
+ filepath, expected = line.split("|", 1)
416
+ if filepath:
417
+ entries[filepath] = expected
418
+ except Exception as load_error:
419
+ _log(f"watchdog hash registry load error at {path}: {load_error}")
420
+
421
+ _load(registry_file)
422
+
423
+ legacy_is_canonical_alias = False
424
+ if legacy_registry_file.exists():
425
+ try:
426
+ legacy_is_canonical_alias = (
427
+ registry_file.exists()
428
+ and legacy_registry_file.resolve() == registry_file.resolve()
429
+ )
430
+ except Exception:
431
+ legacy_is_canonical_alias = False
432
+ if not legacy_is_canonical_alias:
433
+ _load(legacy_registry_file)
434
+
406
435
  actual_hash = hashlib.sha256(watchdog_file.read_bytes()).hexdigest()
407
436
  entries[str(watchdog_file)] = actual_hash
408
437
  registry_file.write_text(
409
438
  "\n".join(f"{filepath}|{digest}" for filepath, digest in sorted(entries.items())) + "\n"
410
439
  )
440
+
441
+ if (
442
+ legacy_registry_file.exists()
443
+ and not legacy_is_canonical_alias
444
+ and legacy_registry_file != registry_file
445
+ ):
446
+ try:
447
+ legacy_registry_file.unlink()
448
+ _log(f"watchdog hash registry migrated from legacy path: {legacy_registry_file}")
449
+ except Exception as unlink_error:
450
+ _log(f"watchdog hash registry legacy cleanup skipped: {unlink_error}")
411
451
  except Exception as e:
412
452
  _log(f"watchdog hash registry sync error: {e}")
413
453
 
@@ -4608,6 +4648,26 @@ def _run_runtime_post_sync(dest: Path = NEXO_HOME, progress_fn=None) -> tuple[bo
4608
4648
  except Exception as exc:
4609
4649
  actions.append(f"classifier-install-warning:{exc.__class__.__name__}")
4610
4650
 
4651
+ try:
4652
+ _emit_progress(progress_fn, "Persisting Guardian enforcement defaults...")
4653
+ persisted, persist_message = _persist_guardian_hard_defaults(dest)
4654
+ if persisted:
4655
+ actions.append("guardian-hard-persisted")
4656
+ if persist_message:
4657
+ actions.append(persist_message)
4658
+ except Exception as exc:
4659
+ actions.append(f"guardian-hard-persist-warning:{exc.__class__.__name__}")
4660
+
4661
+ try:
4662
+ _emit_progress(progress_fn, "Promoting adaptive weights if enough data...")
4663
+ promoted, promote_message = _maybe_promote_adaptive_weights_empirically(dest)
4664
+ if promoted:
4665
+ actions.append("adaptive-weights-promoted")
4666
+ if promote_message:
4667
+ actions.append(promote_message)
4668
+ except Exception as exc:
4669
+ actions.append(f"adaptive-weights-promote-warning:{exc.__class__.__name__}")
4670
+
4611
4671
  _emit_progress(progress_fn, "Verifying runtime imports...")
4612
4672
  verify = subprocess.run(
4613
4673
  [sys.executable, "-c", "import server"],
@@ -4655,6 +4715,177 @@ def _emit_progress(progress_fn, message: str) -> None:
4655
4715
  pass
4656
4716
 
4657
4717
 
4718
+ _GUARDIAN_PERSIST_HARD_DEFAULTS = {
4719
+ "G1_ENFORCER_ACTIVE": "hard",
4720
+ "G3_ENFORCE_DESTRUCTIVE": "hard",
4721
+ "G3_SSH_ENFORCE_REMOTE_WRITE": "hard",
4722
+ "G4_ENFORCE_GUARD_CHECK": "hard",
4723
+ }
4724
+
4725
+
4726
+ def _persist_guardian_hard_defaults(dest: Path) -> tuple[bool, str | None]:
4727
+ """Write ``~/.nexo/config/guardian-runtime-overrides.json`` with the
4728
+ v7.2.0 "Guardian-on by default" matrix so operators get the active
4729
+ enforcer experience without setting env vars every shell.
4730
+
4731
+ Returns (persisted, message). ``persisted`` is True when the file was
4732
+ written or updated during this call; False when we left it alone
4733
+ (operator opt-out, or file already matches the defaults).
4734
+
4735
+ Contract:
4736
+ - Operator opt-out with ``NEXO_GUARDIAN_PERSIST_HARD=off`` in env
4737
+ at update time: leave the file untouched.
4738
+ - Ephemeral runtimes: skip.
4739
+ - Never overwrite a key the operator already customized to a
4740
+ non-default value (``shadow`` / ``off`` / anything else). Only
4741
+ fills missing keys or upgrades explicit defaults.
4742
+ """
4743
+ if _is_ephemeral_runtime_install(dest):
4744
+ return False, "guardian-hard-persist-skipped:ephemeral"
4745
+ if os.environ.get("NEXO_GUARDIAN_PERSIST_HARD", "").strip().lower() == "off":
4746
+ return False, "guardian-hard-persist-skipped:operator-opt-out"
4747
+
4748
+ config_path = dest / "personal" / "config" / "guardian-runtime-overrides.json"
4749
+ config_path.parent.mkdir(parents=True, exist_ok=True)
4750
+
4751
+ current: dict[str, str] = {}
4752
+ if config_path.is_file():
4753
+ try:
4754
+ raw = json.loads(config_path.read_text())
4755
+ if isinstance(raw, dict):
4756
+ current = {str(k): str(v) for k, v in raw.items()}
4757
+ except Exception:
4758
+ # Malformed file — treat as empty. We write the fresh defaults
4759
+ # and keep the malformed copy for the operator to inspect.
4760
+ try:
4761
+ config_path.rename(
4762
+ config_path.with_suffix(
4763
+ config_path.suffix + f".broken-{int(time.time())}"
4764
+ )
4765
+ )
4766
+ except Exception:
4767
+ pass
4768
+ current = {}
4769
+
4770
+ original = dict(current)
4771
+ for key, default in _GUARDIAN_PERSIST_HARD_DEFAULTS.items():
4772
+ if key not in current:
4773
+ current[key] = default
4774
+
4775
+ if current == original and config_path.is_file():
4776
+ return False, None
4777
+
4778
+ try:
4779
+ tmp_path = config_path.with_suffix(config_path.suffix + ".tmp")
4780
+ tmp_path.write_text(json.dumps(current, indent=2, ensure_ascii=False) + "\n")
4781
+ tmp_path.replace(config_path)
4782
+ except Exception as exc: # pragma: no cover - filesystem failures
4783
+ return False, f"guardian-hard-persist-error:{exc.__class__.__name__}"
4784
+
4785
+ return True, None
4786
+
4787
+
4788
+ from datetime import datetime as _adaptive_datetime # isolated alias; other modules use ``time.time()``
4789
+
4790
+ _ADAPTIVE_PROMOTE_MIN_SAMPLES = 200
4791
+ _ADAPTIVE_PROMOTE_MIN_DAYS = 2
4792
+
4793
+
4794
+ def _maybe_promote_adaptive_weights_empirically(dest: Path) -> tuple[bool, str | None]:
4795
+ """Promote ``shadow_weights`` to ``learned_weights`` during ``nexo update``
4796
+ when the install already has enough empirical signal.
4797
+
4798
+ Mirrors the empirical path in ``adaptive_mode.learn_weights`` (the
4799
+ background learner cron). Without this, operators upgrading to v7.2.0
4800
+ with a mature shadow dataset would still have to wait for the next
4801
+ learner tick to feel the calibration — which can be hours or days.
4802
+
4803
+ Conditions to promote:
4804
+ - adaptive_state.json exists and parses.
4805
+ - ``shadow_weights`` dict is present.
4806
+ - ``shadow_weights_samples >= 200`` AND the learner has been
4807
+ running for at least 2 calendar days
4808
+ (``learned_weights_first_date`` ≥ 2 days ago).
4809
+ - ``learned_weights`` is absent or empty (never overwrite an
4810
+ already-active set of weights).
4811
+ - Operator opt-out flag ``NEXO_ADAPTIVE_EMPIRICAL_PROMOTION=off``
4812
+ is NOT set. Same flag the learner already honours, so
4813
+ operators who opted out stay opted out everywhere.
4814
+
4815
+ Returns ``(promoted, message)``:
4816
+ - ``(True, None)``: weights were promoted, audit trail written.
4817
+ - ``(False, None)``: no-op (no shadow data, not enough samples,
4818
+ already promoted, or ephemeral install).
4819
+ - ``(False, "adaptive-promote-skipped:<reason>")``: explicit skip
4820
+ for the install log.
4821
+ """
4822
+ if _is_ephemeral_runtime_install(dest):
4823
+ return False, "adaptive-promote-skipped:ephemeral"
4824
+ if os.environ.get("NEXO_ADAPTIVE_EMPIRICAL_PROMOTION", "").strip().lower() == "off":
4825
+ return False, "adaptive-promote-skipped:operator-opt-out"
4826
+
4827
+ state_path = dest / "personal" / "brain" / "adaptive_state.json"
4828
+ if not state_path.is_file():
4829
+ # Fall back to the legacy pre-F0.6 location for half-migrated
4830
+ # installs; the migrator will eventually move it.
4831
+ legacy = dest / "brain" / "adaptive_state.json"
4832
+ if legacy.is_file():
4833
+ state_path = legacy
4834
+ else:
4835
+ return False, None # no data yet — nothing to promote
4836
+
4837
+ try:
4838
+ raw = json.loads(state_path.read_text())
4839
+ except Exception:
4840
+ return False, "adaptive-promote-skipped:state-parse-error"
4841
+ if not isinstance(raw, dict):
4842
+ return False, "adaptive-promote-skipped:state-not-dict"
4843
+
4844
+ learned = raw.get("learned_weights")
4845
+ if isinstance(learned, dict) and learned:
4846
+ return False, None # already active — nothing to do
4847
+
4848
+ shadow = raw.get("shadow_weights")
4849
+ if not isinstance(shadow, dict) or not shadow:
4850
+ return False, None # no shadow data yet
4851
+
4852
+ samples = 0
4853
+ try:
4854
+ samples = int(raw.get("shadow_weights_samples", 0) or 0)
4855
+ except (TypeError, ValueError):
4856
+ samples = 0
4857
+ if samples < _ADAPTIVE_PROMOTE_MIN_SAMPLES:
4858
+ return False, None
4859
+
4860
+ first_date_raw = str(raw.get("learned_weights_first_date") or "").strip()
4861
+ if not first_date_raw:
4862
+ return False, None
4863
+ try:
4864
+ first_dt = _adaptive_datetime.strptime(first_date_raw[:19], "%Y-%m-%dT%H:%M:%S")
4865
+ except ValueError:
4866
+ return False, "adaptive-promote-skipped:first-date-parse-error"
4867
+ days_since_first = (_adaptive_datetime.utcnow() - first_dt).days
4868
+ if days_since_first < _ADAPTIVE_PROMOTE_MIN_DAYS:
4869
+ return False, None
4870
+
4871
+ # Promote — deep-copy so callers holding references to the original
4872
+ # dict don't see phantom mutations mid-update.
4873
+ raw["learned_weights"] = {str(k): v for k, v in shadow.items()}
4874
+ raw["learned_weights_date"] = _adaptive_datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%S")
4875
+ raw["learned_weights_samples"] = samples
4876
+ raw["learned_weights_promoted_by"] = "nexo_update_empirical_v7_2_0"
4877
+ raw["learned_weights_promoted_at"] = _adaptive_datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%S")
4878
+
4879
+ try:
4880
+ tmp_path = state_path.with_suffix(state_path.suffix + ".tmp")
4881
+ tmp_path.write_text(json.dumps(raw, indent=2, ensure_ascii=False) + "\n")
4882
+ tmp_path.replace(state_path)
4883
+ except Exception as exc: # pragma: no cover - filesystem failures
4884
+ return False, f"adaptive-promote-error:{exc.__class__.__name__}"
4885
+
4886
+ return True, None
4887
+
4888
+
4658
4889
  def _parse_runtime_init_payload(stdout: str) -> dict:
4659
4890
  """Extract the JSON payload emitted by the runtime init helper."""
4660
4891
  lines = [line.strip() for line in stdout.splitlines() if line.strip()]
@@ -45,8 +45,15 @@ MARKERS = (
45
45
  "autonomía total",
46
46
  "autonomia total",
47
47
  "sin esperas",
48
+ "hazlo todo",
48
49
  "todo ya",
50
+ "no pares",
51
+ "estás al mando",
52
+ "estas al mando",
53
+ "te dejo al mando",
49
54
  "no esperes",
55
+ "sigue sin parar",
56
+ "haz el plan completo",
50
57
  "no te escondas",
51
58
  "llevo 3 veces",
52
59
  "lo quiero ya",
@@ -81,6 +88,9 @@ class MandateState:
81
88
  expires_at: float
82
89
  marker: str
83
90
  source: str
91
+ execute_until_blocker: bool = True
92
+ suppress_mid_task_menus: bool = True
93
+ revalidate_after_compaction: bool = True
84
94
 
85
95
  def remaining_seconds(self, now: Optional[float] = None) -> float:
86
96
  now = time.time() if now is None else now
@@ -94,6 +104,9 @@ class MandateState:
94
104
  "expires_at": self.expires_at,
95
105
  "marker": self.marker,
96
106
  "source": self.source,
107
+ "execute_until_blocker": self.execute_until_blocker,
108
+ "suppress_mid_task_menus": self.suppress_mid_task_menus,
109
+ "revalidate_after_compaction": self.revalidate_after_compaction,
97
110
  }
98
111
 
99
112
 
@@ -137,6 +150,9 @@ def load_state() -> Optional[MandateState]:
137
150
  expires_at=float(raw.get("expires_at", 0.0)),
138
151
  marker=str(raw.get("marker", "")),
139
152
  source=str(raw.get("source", "")),
153
+ execute_until_blocker=bool(raw.get("execute_until_blocker", True)),
154
+ suppress_mid_task_menus=bool(raw.get("suppress_mid_task_menus", True)),
155
+ revalidate_after_compaction=bool(raw.get("revalidate_after_compaction", True)),
140
156
  )
141
157
  except (TypeError, ValueError):
142
158
  return None
@@ -150,6 +166,9 @@ def set_mandate(
150
166
  marker: str,
151
167
  source: str = "manual",
152
168
  ttl_seconds: int = DEFAULT_TTL_SECONDS,
169
+ execute_until_blocker: bool = True,
170
+ suppress_mid_task_menus: bool = True,
171
+ revalidate_after_compaction: bool = True,
153
172
  ) -> MandateState:
154
173
  _ensure_dir()
155
174
  now = time.time()
@@ -160,6 +179,9 @@ def set_mandate(
160
179
  expires_at=now + max(60, int(ttl_seconds)),
161
180
  marker=marker,
162
181
  source=source,
182
+ execute_until_blocker=bool(execute_until_blocker),
183
+ suppress_mid_task_menus=bool(suppress_mid_task_menus),
184
+ revalidate_after_compaction=bool(revalidate_after_compaction),
163
185
  )
164
186
  STATE_PATH.write_text(json.dumps(st.to_dict(), ensure_ascii=False, indent=2))
165
187
  return st
@@ -196,6 +218,46 @@ def maybe_ingest_from_text(
196
218
  )
197
219
 
198
220
 
221
+ def _state_applies_to_session(state: MandateState, session_id: str = "") -> bool:
222
+ clean_sid = str(session_id or "").strip()
223
+ if not clean_sid:
224
+ return True
225
+ return not state.session_id or state.session_id == clean_sid
226
+
227
+
228
+ def is_execute_until_blocker_active(
229
+ session_id: str = "",
230
+ state: Optional[MandateState] = None,
231
+ ) -> bool:
232
+ st = state if state is not None else load_state()
233
+ if st is None or not _state_applies_to_session(st, session_id):
234
+ return False
235
+ return bool(st.active and st.execute_until_blocker)
236
+
237
+
238
+ def format_execution_latch_notice(
239
+ session_id: str = "",
240
+ state: Optional[MandateState] = None,
241
+ ) -> str:
242
+ st = state if state is not None else load_state()
243
+ if st is None or not _state_applies_to_session(st, session_id):
244
+ return ""
245
+ if not st.active or not st.execute_until_blocker:
246
+ return ""
247
+
248
+ lines = [
249
+ (
250
+ "EXECUTION MODE: execute-until-blocker active "
251
+ f"(marker='{st.marker}', source={st.source})."
252
+ ),
253
+ "Do not stop for option menus, reprioritization, summaries, or audits.",
254
+ "Pause only for a real blocker, an explicit approval gate, or a requested report.",
255
+ ]
256
+ if st.revalidate_after_compaction:
257
+ lines.append("Re-validate this latch after compaction and continue from the stored next step.")
258
+ return "\n".join(lines)
259
+
260
+
199
261
  def _description_has_exception(description: str, exception: str) -> bool:
200
262
  haystack = f"{description or ''}\n{exception or ''}".lower()
201
263
  return any(kw in haystack for kw in EXCEPTION_KEYWORDS)