nexo-brain 7.1.10 → 7.3.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.10",
3
+ "version": "7.3.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.10` is the current packaged-runtime line. It is a follow-up over v7.1.8 that ships two rescue batches of WIP that were stashed aside during the v7.1.8 release window. First rescue: `src/autonomy_mandate.py` expands the mandate-detection vocabulary (hazlo todo / no pares / estás al mando / te dejo al mando / sigue sin parar / haz el plan completo), adds three honest flags on `MandateState` (`execute_until_blocker`, `suppress_mid_task_menus`, `revalidate_after_compaction`) with session filtering, wires post/pre-compact hooks that read those flags, surfaces them through protocol/workflow handlers and session payload, and introduces the new `src/checkpoint_policy.py` module with tests. Second rescue: `scripts/verify_release_readiness.py` gains 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) get wording polish. Both rescues are orthogonal to v7.1.8 and fully covered by tests.
21
+ Version `7.3.0` 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.
22
+
23
+ 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
+
25
+ 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.
22
26
 
23
27
  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).
24
28
 
package/hooks/hooks.json CHANGED
@@ -40,6 +40,18 @@
40
40
  ]
41
41
  }
42
42
  ],
43
+ "PreToolUse": [
44
+ {
45
+ "matcher": "*",
46
+ "hooks": [
47
+ {
48
+ "type": "command",
49
+ "command": "NEXO_HOME=\"${CLAUDE_PLUGIN_DATA}\" NEXO_CODE=\"${CLAUDE_PLUGIN_ROOT}/src\" python3 \"${CLAUDE_PLUGIN_ROOT}/src/hooks/pre_tool_use.py\"",
50
+ "timeout": 8
51
+ }
52
+ ]
53
+ }
54
+ ],
43
55
  "PostToolUse": [
44
56
  {
45
57
  "matcher": "*",
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "7.1.10",
3
+ "version": "7.3.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",
@@ -89,6 +89,7 @@
89
89
  ".claude-plugin/",
90
90
  ".mcp.json",
91
91
  "hooks/hooks.json",
92
+ "tool-enforcement-map.json",
92
93
  "README.md",
93
94
  "LICENSE"
94
95
  ]
@@ -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,28 @@ 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
+ # v7.3.0 fix for the post-v7.2.0 bug: ``_run_runtime_post_sync`` was
4652
+ # invoking post-install hooks directly against the ``auto_update``
4653
+ # module already loaded in this process (the OLD code). Any function
4654
+ # added to auto_update in the CURRENT release therefore never ran on
4655
+ # the first ``nexo update`` that introduced it — only on the next
4656
+ # one. Exhibit A: v7.2.0 shipped ``_persist_guardian_hard_defaults``
4657
+ # and ``_maybe_promote_adaptive_weights_empirically`` but
4658
+ # ``guardian-runtime-overrides.json`` was not written on the operator's
4659
+ # first upgrade until the functions were invoked manually.
4660
+ #
4661
+ # The fix runs those post-install hooks inside a clean Python
4662
+ # subprocess that imports from the freshly-copied tree at
4663
+ # ``dest/core`` (packaged) or ``dest`` (dev/legacy layout). The
4664
+ # subprocess emits a single JSON line so the parent process can
4665
+ # record each hook's outcome in ``actions``.
4666
+ try:
4667
+ _emit_progress(progress_fn, "Running post-install hooks from fresh code...")
4668
+ fresh_actions = _run_post_install_hooks_fresh(dest, env=env)
4669
+ actions.extend(fresh_actions)
4670
+ except Exception as exc:
4671
+ actions.append(f"post-install-hooks-fresh-warning:{exc.__class__.__name__}")
4672
+
4611
4673
  _emit_progress(progress_fn, "Verifying runtime imports...")
4612
4674
  verify = subprocess.run(
4613
4675
  [sys.executable, "-c", "import server"],
@@ -4655,6 +4717,286 @@ def _emit_progress(progress_fn, message: str) -> None:
4655
4717
  pass
4656
4718
 
4657
4719
 
4720
+ _GUARDIAN_PERSIST_HARD_DEFAULTS = {
4721
+ "G1_ENFORCER_ACTIVE": "hard",
4722
+ "G3_ENFORCE_DESTRUCTIVE": "hard",
4723
+ "G3_SSH_ENFORCE_REMOTE_WRITE": "hard",
4724
+ "G4_ENFORCE_GUARD_CHECK": "hard",
4725
+ }
4726
+
4727
+
4728
+ def _persist_guardian_hard_defaults(dest: Path) -> tuple[bool, str | None]:
4729
+ """Write ``~/.nexo/config/guardian-runtime-overrides.json`` with the
4730
+ v7.2.0 "Guardian-on by default" matrix so operators get the active
4731
+ enforcer experience without setting env vars every shell.
4732
+
4733
+ Returns (persisted, message). ``persisted`` is True when the file was
4734
+ written or updated during this call; False when we left it alone
4735
+ (operator opt-out, or file already matches the defaults).
4736
+
4737
+ Contract:
4738
+ - Operator opt-out with ``NEXO_GUARDIAN_PERSIST_HARD=off`` in env
4739
+ at update time: leave the file untouched.
4740
+ - Ephemeral runtimes: skip.
4741
+ - Never overwrite a key the operator already customized to a
4742
+ non-default value (``shadow`` / ``off`` / anything else). Only
4743
+ fills missing keys or upgrades explicit defaults.
4744
+ """
4745
+ if _is_ephemeral_runtime_install(dest):
4746
+ return False, "guardian-hard-persist-skipped:ephemeral"
4747
+ if os.environ.get("NEXO_GUARDIAN_PERSIST_HARD", "").strip().lower() == "off":
4748
+ return False, "guardian-hard-persist-skipped:operator-opt-out"
4749
+
4750
+ config_path = dest / "personal" / "config" / "guardian-runtime-overrides.json"
4751
+ config_path.parent.mkdir(parents=True, exist_ok=True)
4752
+
4753
+ current: dict[str, str] = {}
4754
+ if config_path.is_file():
4755
+ try:
4756
+ raw = json.loads(config_path.read_text())
4757
+ if isinstance(raw, dict):
4758
+ current = {str(k): str(v) for k, v in raw.items()}
4759
+ except Exception:
4760
+ # Malformed file — treat as empty. We write the fresh defaults
4761
+ # and keep the malformed copy for the operator to inspect.
4762
+ try:
4763
+ config_path.rename(
4764
+ config_path.with_suffix(
4765
+ config_path.suffix + f".broken-{int(time.time())}"
4766
+ )
4767
+ )
4768
+ except Exception:
4769
+ pass
4770
+ current = {}
4771
+
4772
+ original = dict(current)
4773
+ for key, default in _GUARDIAN_PERSIST_HARD_DEFAULTS.items():
4774
+ if key not in current:
4775
+ current[key] = default
4776
+
4777
+ if current == original and config_path.is_file():
4778
+ return False, None
4779
+
4780
+ try:
4781
+ tmp_path = config_path.with_suffix(config_path.suffix + ".tmp")
4782
+ tmp_path.write_text(json.dumps(current, indent=2, ensure_ascii=False) + "\n")
4783
+ tmp_path.replace(config_path)
4784
+ except Exception as exc: # pragma: no cover - filesystem failures
4785
+ return False, f"guardian-hard-persist-error:{exc.__class__.__name__}"
4786
+
4787
+ return True, None
4788
+
4789
+
4790
+ from datetime import datetime as _adaptive_datetime # isolated alias; other modules use ``time.time()``
4791
+
4792
+ _ADAPTIVE_PROMOTE_MIN_SAMPLES = 200
4793
+ _ADAPTIVE_PROMOTE_MIN_DAYS = 2
4794
+
4795
+
4796
+ def _maybe_promote_adaptive_weights_empirically(dest: Path) -> tuple[bool, str | None]:
4797
+ """Promote ``shadow_weights`` to ``learned_weights`` during ``nexo update``
4798
+ when the install already has enough empirical signal.
4799
+
4800
+ Mirrors the empirical path in ``adaptive_mode.learn_weights`` (the
4801
+ background learner cron). Without this, operators upgrading to v7.2.0
4802
+ with a mature shadow dataset would still have to wait for the next
4803
+ learner tick to feel the calibration — which can be hours or days.
4804
+
4805
+ Conditions to promote:
4806
+ - adaptive_state.json exists and parses.
4807
+ - ``shadow_weights`` dict is present.
4808
+ - ``shadow_weights_samples >= 200`` AND the learner has been
4809
+ running for at least 2 calendar days
4810
+ (``learned_weights_first_date`` ≥ 2 days ago).
4811
+ - ``learned_weights`` is absent or empty (never overwrite an
4812
+ already-active set of weights).
4813
+ - Operator opt-out flag ``NEXO_ADAPTIVE_EMPIRICAL_PROMOTION=off``
4814
+ is NOT set. Same flag the learner already honours, so
4815
+ operators who opted out stay opted out everywhere.
4816
+
4817
+ Returns ``(promoted, message)``:
4818
+ - ``(True, None)``: weights were promoted, audit trail written.
4819
+ - ``(False, None)``: no-op (no shadow data, not enough samples,
4820
+ already promoted, or ephemeral install).
4821
+ - ``(False, "adaptive-promote-skipped:<reason>")``: explicit skip
4822
+ for the install log.
4823
+ """
4824
+ if _is_ephemeral_runtime_install(dest):
4825
+ return False, "adaptive-promote-skipped:ephemeral"
4826
+ if os.environ.get("NEXO_ADAPTIVE_EMPIRICAL_PROMOTION", "").strip().lower() == "off":
4827
+ return False, "adaptive-promote-skipped:operator-opt-out"
4828
+
4829
+ state_path = dest / "personal" / "brain" / "adaptive_state.json"
4830
+ if not state_path.is_file():
4831
+ # Fall back to the legacy pre-F0.6 location for half-migrated
4832
+ # installs; the migrator will eventually move it.
4833
+ legacy = dest / "brain" / "adaptive_state.json"
4834
+ if legacy.is_file():
4835
+ state_path = legacy
4836
+ else:
4837
+ return False, None # no data yet — nothing to promote
4838
+
4839
+ try:
4840
+ raw = json.loads(state_path.read_text())
4841
+ except Exception:
4842
+ return False, "adaptive-promote-skipped:state-parse-error"
4843
+ if not isinstance(raw, dict):
4844
+ return False, "adaptive-promote-skipped:state-not-dict"
4845
+
4846
+ learned = raw.get("learned_weights")
4847
+ if isinstance(learned, dict) and learned:
4848
+ return False, None # already active — nothing to do
4849
+
4850
+ shadow = raw.get("shadow_weights")
4851
+ if not isinstance(shadow, dict) or not shadow:
4852
+ return False, None # no shadow data yet
4853
+
4854
+ samples = 0
4855
+ try:
4856
+ samples = int(raw.get("shadow_weights_samples", 0) or 0)
4857
+ except (TypeError, ValueError):
4858
+ samples = 0
4859
+ if samples < _ADAPTIVE_PROMOTE_MIN_SAMPLES:
4860
+ return False, None
4861
+
4862
+ first_date_raw = str(raw.get("learned_weights_first_date") or "").strip()
4863
+ if not first_date_raw:
4864
+ return False, None
4865
+ try:
4866
+ first_dt = _adaptive_datetime.strptime(first_date_raw[:19], "%Y-%m-%dT%H:%M:%S")
4867
+ except ValueError:
4868
+ return False, "adaptive-promote-skipped:first-date-parse-error"
4869
+ days_since_first = (_adaptive_datetime.utcnow() - first_dt).days
4870
+ if days_since_first < _ADAPTIVE_PROMOTE_MIN_DAYS:
4871
+ return False, None
4872
+
4873
+ # Promote — deep-copy so callers holding references to the original
4874
+ # dict don't see phantom mutations mid-update.
4875
+ raw["learned_weights"] = {str(k): v for k, v in shadow.items()}
4876
+ raw["learned_weights_date"] = _adaptive_datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%S")
4877
+ raw["learned_weights_samples"] = samples
4878
+ raw["learned_weights_promoted_by"] = "nexo_update_empirical_v7_2_0"
4879
+ raw["learned_weights_promoted_at"] = _adaptive_datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%S")
4880
+
4881
+ try:
4882
+ tmp_path = state_path.with_suffix(state_path.suffix + ".tmp")
4883
+ tmp_path.write_text(json.dumps(raw, indent=2, ensure_ascii=False) + "\n")
4884
+ tmp_path.replace(state_path)
4885
+ except Exception as exc: # pragma: no cover - filesystem failures
4886
+ return False, f"adaptive-promote-error:{exc.__class__.__name__}"
4887
+
4888
+ return True, None
4889
+
4890
+
4891
+ # Whitelist of post-install hooks to invoke from the fresh tree. Each entry
4892
+ # is the function name inside ``auto_update.py`` of the freshly-copied
4893
+ # code. The subprocess resolves them on the NEW module and calls
4894
+ # ``fn(dest)`` returning ``(bool, str | None)``. New hooks added in
4895
+ # future releases only need an entry here — no extra wiring.
4896
+ _POST_INSTALL_FRESH_HOOKS = (
4897
+ ("guardian-hard-persisted", "_persist_guardian_hard_defaults"),
4898
+ ("adaptive-weights-promoted", "_maybe_promote_adaptive_weights_empirically"),
4899
+ )
4900
+
4901
+
4902
+ def _run_post_install_hooks_fresh(dest: Path, *, env: dict | None = None) -> list[str]:
4903
+ """Execute post-install hooks from the freshly-copied code, not the old
4904
+ module already loaded in this process.
4905
+
4906
+ Without this, any function added to ``auto_update.py`` in the current
4907
+ release never runs on the first ``nexo update`` — only on the next
4908
+ one. See the post-v7.2.0 bug: ``_persist_guardian_hard_defaults`` and
4909
+ ``_maybe_promote_adaptive_weights_empirically`` both shipped in
4910
+ v7.2.0 but neither fired until the operator ran the functions
4911
+ manually.
4912
+
4913
+ Resolution order for the fresh code root:
4914
+ 1. ``<dest>/core`` (packaged/F0.6 layout).
4915
+ 2. ``<dest>`` (legacy/dev layout).
4916
+
4917
+ Subprocess contract: emits a single JSON line on stdout with per-hook
4918
+ ``{"action": ..., "ok": bool, "changed": bool, "message": str|null}``.
4919
+ Returns a list of action strings mirroring the shape the old in-process
4920
+ path used, so callers that read ``actions`` keep working unchanged.
4921
+ """
4922
+ code_root = dest / "core"
4923
+ if not code_root.is_dir():
4924
+ code_root = dest
4925
+ hook_specs = list(_POST_INSTALL_FRESH_HOOKS)
4926
+ hook_specs_json = json.dumps(hook_specs)
4927
+ dest_str = str(dest)
4928
+ script = (
4929
+ "import json, sys\n"
4930
+ "from pathlib import Path\n"
4931
+ f"sys.path.insert(0, {repr(str(code_root))})\n"
4932
+ "hooks = json.loads(" + repr(hook_specs_json) + ")\n"
4933
+ f"dest = Path({repr(dest_str)})\n"
4934
+ "results = []\n"
4935
+ "try:\n"
4936
+ " import auto_update as fresh\n"
4937
+ "except Exception as exc:\n"
4938
+ " print(json.dumps({'error': 'import_auto_update_failed', 'detail': repr(exc)}))\n"
4939
+ " sys.exit(0)\n"
4940
+ "for tag, fn_name in hooks:\n"
4941
+ " fn = getattr(fresh, fn_name, None)\n"
4942
+ " if fn is None:\n"
4943
+ " results.append({'action': tag, 'ok': False, 'changed': False, 'message': 'fn_missing:' + fn_name})\n"
4944
+ " continue\n"
4945
+ " try:\n"
4946
+ " changed, message = fn(dest)\n"
4947
+ " results.append({'action': tag, 'ok': True, 'changed': bool(changed), 'message': message})\n"
4948
+ " except Exception as exc:\n"
4949
+ " results.append({'action': tag, 'ok': False, 'changed': False, 'message': 'error:' + exc.__class__.__name__})\n"
4950
+ "print(json.dumps({'hooks': results}))\n"
4951
+ )
4952
+ try:
4953
+ proc = subprocess.run(
4954
+ [sys.executable, "-c", script],
4955
+ capture_output=True,
4956
+ text=True,
4957
+ timeout=60,
4958
+ env=env or os.environ.copy(),
4959
+ )
4960
+ except subprocess.TimeoutExpired:
4961
+ return ["post-install-hooks-fresh-warning:timeout"]
4962
+ except Exception as exc:
4963
+ return [f"post-install-hooks-fresh-warning:{exc.__class__.__name__}"]
4964
+
4965
+ if proc.returncode != 0:
4966
+ return [f"post-install-hooks-fresh-warning:exit-{proc.returncode}"]
4967
+
4968
+ stdout = (proc.stdout or "").strip()
4969
+ payload = None
4970
+ for line in reversed(stdout.splitlines()):
4971
+ line = line.strip()
4972
+ if not line:
4973
+ continue
4974
+ try:
4975
+ payload = json.loads(line)
4976
+ break
4977
+ except Exception:
4978
+ continue
4979
+ if not isinstance(payload, dict):
4980
+ return ["post-install-hooks-fresh-warning:no-json-line"]
4981
+ if "error" in payload:
4982
+ return [f"post-install-hooks-fresh-warning:{payload['error']}"]
4983
+
4984
+ actions: list[str] = []
4985
+ hooks_out = payload.get("hooks")
4986
+ if not isinstance(hooks_out, list):
4987
+ return ["post-install-hooks-fresh-warning:bad-shape"]
4988
+ for entry in hooks_out:
4989
+ if not isinstance(entry, dict):
4990
+ continue
4991
+ tag = str(entry.get("action") or "")
4992
+ if entry.get("ok") and entry.get("changed"):
4993
+ actions.append(tag)
4994
+ message = entry.get("message")
4995
+ if message:
4996
+ actions.append(str(message))
4997
+ return actions
4998
+
4999
+
4658
5000
  def _parse_runtime_init_payload(stdout: str) -> dict:
4659
5001
  """Extract the JSON payload emitted by the runtime init helper."""
4660
5002
  lines = [line.strip() for line in stdout.splitlines() if line.strip()]