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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +5 -1
- package/package.json +2 -2
- package/src/auto_update.py +239 -8
- package/src/autonomy_mandate.py +62 -0
- package/src/checkpoint_policy.py +302 -0
- package/src/cli.py +229 -0
- package/src/core_schedule_controls.py +66 -0
- package/src/doctor/providers/boot.py +190 -0
- package/src/evolution_cycle.py +4 -0
- package/src/guardian_runtime_config.py +98 -0
- package/src/hook_guardrails.py +148 -2
- package/src/hooks/g1_enforcer.py +305 -0
- package/src/hooks/post-compact.sh +34 -0
- package/src/hooks/post_tool_use.py +32 -3
- package/src/hooks/pre-compact.sh +14 -0
- package/src/paths.py +10 -0
- package/src/plugins/adaptive_mode.py +26 -2
- package/src/plugins/protocol.py +24 -0
- package/src/plugins/recover.py +42 -10
- package/src/plugins/update.py +47 -17
- package/src/plugins/workflow.py +65 -0
- package/src/public_contribution.py +51 -5
- package/src/r34_identity_coherence.py +31 -8
- package/src/script_registry.py +14 -6
- package/src/scripts/nexo-watchdog.sh +7 -1
- package/src/scripts/prune_runtime_backups.py +376 -0
- package/src/skills/run-release-final-audit/guide.md +3 -1
- package/src/skills/run-release-final-audit/script.py +2 -0
- package/src/tools_sessions.py +64 -3
- package/templates/core-prompts/hook-protocol-warning-task-close-evidence.md +1 -1
- package/templates/core-prompts/r14-correction-learning-injection.md +1 -1
package/src/plugins/update.py
CHANGED
|
@@ -84,37 +84,67 @@ _THIS_DIR = Path(__file__).resolve().parent
|
|
|
84
84
|
CODE_ROOT = _THIS_DIR.parent
|
|
85
85
|
_REPO_CANDIDATE = CODE_ROOT.parent
|
|
86
86
|
|
|
87
|
+
# B10 mini-refactor (AUDITOR-V700-PASS2 §11): the pieces below that look up
|
|
88
|
+
# env-dependent or filesystem-dependent state now go through lazy helpers so
|
|
89
|
+
# tests can monkeypatch NEXO_HOME / the .git location after the module was
|
|
90
|
+
# imported without snapshotting a stale value. The module-level constants
|
|
91
|
+
# (NEXO_HOME, DATA_DIR, BACKUP_BASE, REPO_DIR, SRC_DIR, PACKAGE_JSON) are
|
|
92
|
+
# kept for the existing 77 callsites; they will migrate to the helpers in
|
|
93
|
+
# followup NF-B10-UPDATE-PY-LAZY-CALLSITES-COSMETIC post-release.
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _nexo_home() -> Path:
|
|
97
|
+
"""Resolve the current NEXO_HOME on every call.
|
|
98
|
+
|
|
99
|
+
export_resolved_nexo_home() reads the env var + runtime config each time,
|
|
100
|
+
so fixtures that monkeypatch NEXO_HOME mid-test pick up the new path.
|
|
101
|
+
"""
|
|
102
|
+
return export_resolved_nexo_home()
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _is_packaged_install() -> bool:
|
|
106
|
+
"""Return True iff we are running inside a packaged NEXO install.
|
|
107
|
+
|
|
108
|
+
Evaluated on every call because tests may stage a fake repo (or drop a
|
|
109
|
+
synthetic .git marker) after the module has already been imported.
|
|
110
|
+
"""
|
|
111
|
+
return not (_REPO_CANDIDATE / ".git").exists() and not (_REPO_CANDIDATE / ".git").is_file()
|
|
112
|
+
|
|
113
|
+
|
|
87
114
|
NEXO_HOME = export_resolved_nexo_home()
|
|
88
115
|
DATA_DIR = paths.data_dir()
|
|
89
116
|
BACKUP_BASE = paths.backups_dir()
|
|
90
117
|
|
|
91
118
|
# In packaged installs, update.py lives at <NEXO_HOME>/plugins/update.py.
|
|
92
|
-
_PACKAGED_INSTALL =
|
|
119
|
+
_PACKAGED_INSTALL = _is_packaged_install()
|
|
93
120
|
REPO_DIR = CODE_ROOT if _PACKAGED_INSTALL else _REPO_CANDIDATE
|
|
94
121
|
SRC_DIR = CODE_ROOT
|
|
95
122
|
PACKAGE_JSON = REPO_DIR / "package.json"
|
|
96
123
|
|
|
97
124
|
|
|
98
|
-
def _venv_python_path(runtime_root: Path =
|
|
125
|
+
def _venv_python_path(runtime_root: Path | None = None) -> Path:
|
|
126
|
+
root = runtime_root or _nexo_home()
|
|
99
127
|
if sys.platform == "win32":
|
|
100
|
-
return
|
|
101
|
-
return
|
|
128
|
+
return root / ".venv" / "Scripts" / "python.exe"
|
|
129
|
+
return root / ".venv" / "bin" / "python3"
|
|
102
130
|
|
|
103
131
|
|
|
104
|
-
def _venv_pip_path(runtime_root: Path =
|
|
132
|
+
def _venv_pip_path(runtime_root: Path | None = None) -> Path:
|
|
133
|
+
root = runtime_root or _nexo_home()
|
|
105
134
|
if sys.platform == "win32":
|
|
106
|
-
return
|
|
107
|
-
return
|
|
135
|
+
return root / ".venv" / "Scripts" / "pip.exe"
|
|
136
|
+
return root / ".venv" / "bin" / "pip"
|
|
108
137
|
|
|
109
138
|
|
|
110
|
-
def _ensure_managed_venv(runtime_root: Path =
|
|
111
|
-
|
|
139
|
+
def _ensure_managed_venv(runtime_root: Path | None = None) -> str | None:
|
|
140
|
+
root = runtime_root or _nexo_home()
|
|
141
|
+
venv_python = _venv_python_path(root)
|
|
112
142
|
if venv_python.exists():
|
|
113
143
|
return None
|
|
114
144
|
try:
|
|
115
|
-
|
|
145
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
116
146
|
result = subprocess.run(
|
|
117
|
-
[sys.executable, "-m", "venv", str(
|
|
147
|
+
[sys.executable, "-m", "venv", str(root / ".venv")],
|
|
118
148
|
capture_output=True,
|
|
119
149
|
text=True,
|
|
120
150
|
timeout=120,
|
|
@@ -173,7 +203,7 @@ def _runtime_code_root(runtime_root: Path | None = None) -> Path:
|
|
|
173
203
|
|
|
174
204
|
def _core_artifact_source_dir() -> Path | None:
|
|
175
205
|
"""Return the canonical source directory for packaged core artifacts."""
|
|
176
|
-
if
|
|
206
|
+
if _is_packaged_install():
|
|
177
207
|
return _find_npm_pkg_src()
|
|
178
208
|
return SRC_DIR
|
|
179
209
|
|
|
@@ -241,7 +271,7 @@ def _cleanup_retired_runtime_files() -> list[str]:
|
|
|
241
271
|
|
|
242
272
|
def _read_version() -> str:
|
|
243
273
|
"""Read the installed/runtime version."""
|
|
244
|
-
if
|
|
274
|
+
if _is_packaged_install():
|
|
245
275
|
# version.json is the runtime truth for packaged installs.
|
|
246
276
|
try:
|
|
247
277
|
version_file = NEXO_HOME / "version.json"
|
|
@@ -286,7 +316,7 @@ def _requirements_hash() -> str:
|
|
|
286
316
|
"""Return a content hash of requirements.txt, or empty string if missing."""
|
|
287
317
|
import hashlib
|
|
288
318
|
req_file = SRC_DIR / "requirements.txt"
|
|
289
|
-
if not req_file.exists() and
|
|
319
|
+
if not req_file.exists() and _is_packaged_install():
|
|
290
320
|
npm_src = _find_npm_pkg_src()
|
|
291
321
|
if npm_src:
|
|
292
322
|
req_file = npm_src / "requirements.txt"
|
|
@@ -441,7 +471,7 @@ def _restore_databases(backup_dir: str):
|
|
|
441
471
|
def _reinstall_pip_deps() -> str | None:
|
|
442
472
|
"""Reinstall Python dependencies from requirements.txt into the managed venv."""
|
|
443
473
|
req_file = SRC_DIR / "requirements.txt"
|
|
444
|
-
if not req_file.exists() and
|
|
474
|
+
if not req_file.exists() and _is_packaged_install():
|
|
445
475
|
# In packaged mode, requirements.txt lives in the npm package's src/ dir
|
|
446
476
|
npm_src = _find_npm_pkg_src()
|
|
447
477
|
if npm_src:
|
|
@@ -714,7 +744,7 @@ def _format_dep_results(dep_results: list[dict]) -> list[str]:
|
|
|
714
744
|
def _run_migrations() -> str | None:
|
|
715
745
|
"""Run init_db() to apply pending migrations. Returns error or None."""
|
|
716
746
|
# In packaged mode, db/ lives in NEXO_HOME; in dev mode, in SRC_DIR
|
|
717
|
-
cwd = str(NEXO_HOME) if
|
|
747
|
+
cwd = str(NEXO_HOME) if _is_packaged_install() else str(SRC_DIR)
|
|
718
748
|
try:
|
|
719
749
|
result = subprocess.run(
|
|
720
750
|
[sys.executable, "-c", "import db; db.init_db()"],
|
|
@@ -733,7 +763,7 @@ def _run_migrations() -> str | None:
|
|
|
733
763
|
def _verify_import() -> str | None:
|
|
734
764
|
"""Verify server.py can be imported successfully."""
|
|
735
765
|
# In packaged mode, server.py lives in NEXO_HOME; in dev mode, in SRC_DIR
|
|
736
|
-
cwd = str(NEXO_HOME) if
|
|
766
|
+
cwd = str(NEXO_HOME) if _is_packaged_install() else str(SRC_DIR)
|
|
737
767
|
try:
|
|
738
768
|
result = subprocess.run(
|
|
739
769
|
[sys.executable, "-c", "import server"],
|
package/src/plugins/workflow.py
CHANGED
|
@@ -71,6 +71,43 @@ def _parse_json_object(value: str) -> dict | None:
|
|
|
71
71
|
return parsed if isinstance(parsed, dict) else None
|
|
72
72
|
|
|
73
73
|
|
|
74
|
+
def _checkpoint_active_files(*payloads: dict | None) -> list[str]:
|
|
75
|
+
seen: list[str] = []
|
|
76
|
+
for payload in payloads:
|
|
77
|
+
if not isinstance(payload, dict):
|
|
78
|
+
continue
|
|
79
|
+
for key in ("active_files", "files", "tracked_files"):
|
|
80
|
+
raw = payload.get(key)
|
|
81
|
+
if raw is None:
|
|
82
|
+
continue
|
|
83
|
+
if isinstance(raw, str):
|
|
84
|
+
items = [item.strip() for item in raw.split(",") if item.strip()]
|
|
85
|
+
elif isinstance(raw, (list, tuple, set)):
|
|
86
|
+
items = [str(item).strip() for item in raw if str(item).strip()]
|
|
87
|
+
else:
|
|
88
|
+
items = [str(raw).strip()] if str(raw).strip() else []
|
|
89
|
+
for item in items:
|
|
90
|
+
if item and item not in seen:
|
|
91
|
+
seen.append(item)
|
|
92
|
+
return seen
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _workflow_blocker_text(
|
|
96
|
+
*,
|
|
97
|
+
step_status: str,
|
|
98
|
+
run_status: str,
|
|
99
|
+
summary: str,
|
|
100
|
+
next_action: str,
|
|
101
|
+
requires_approval: bool,
|
|
102
|
+
) -> str:
|
|
103
|
+
status_tokens = {str(step_status or "").strip().lower(), str(run_status or "").strip().lower()}
|
|
104
|
+
if "blocked" in status_tokens:
|
|
105
|
+
return summary or next_action or "Workflow blocked."
|
|
106
|
+
if requires_approval or "waiting_approval" in status_tokens:
|
|
107
|
+
return summary or next_action or "Workflow waiting for approval."
|
|
108
|
+
return ""
|
|
109
|
+
|
|
110
|
+
|
|
74
111
|
def handle_workflow_open(
|
|
75
112
|
sid: str,
|
|
76
113
|
goal: str,
|
|
@@ -387,6 +424,34 @@ def handle_workflow_update(
|
|
|
387
424
|
"attempt_count": resume["next_step"].get("attempt_count", 0),
|
|
388
425
|
"max_retries": resume["next_step"].get("max_retries", 0),
|
|
389
426
|
}
|
|
427
|
+
try:
|
|
428
|
+
from checkpoint_policy import record_milestone
|
|
429
|
+
|
|
430
|
+
durable_checkpoint = record_milestone(
|
|
431
|
+
run.get("session_id", ""),
|
|
432
|
+
reason=f"workflow:{(step_key or run.get('current_step_key') or 'update')}",
|
|
433
|
+
task=run.get("goal", ""),
|
|
434
|
+
task_status=("blocked" if run["status"] in {"blocked", "waiting_approval"} else "active"),
|
|
435
|
+
active_files=_checkpoint_active_files(
|
|
436
|
+
_parse_json_object(shared_state) if str(shared_state).strip() else None,
|
|
437
|
+
_parse_json_object(state_patch) if str(state_patch).strip() else None,
|
|
438
|
+
run.get("shared_state") if isinstance(run.get("shared_state"), dict) else None,
|
|
439
|
+
),
|
|
440
|
+
current_goal=run.get("goal", ""),
|
|
441
|
+
decisions_summary=(summary or checkpoint_label or step_title or step_key or run.get("last_checkpoint_label", "")),
|
|
442
|
+
blockers=_workflow_blocker_text(
|
|
443
|
+
step_status=step_status,
|
|
444
|
+
run_status=run["status"],
|
|
445
|
+
summary=summary,
|
|
446
|
+
next_action=next_action or run.get("next_action", ""),
|
|
447
|
+
requires_approval=requires_approval,
|
|
448
|
+
),
|
|
449
|
+
reasoning_thread=(evidence or compensation or "").strip(),
|
|
450
|
+
next_step=(next_action or run.get("next_action", "")),
|
|
451
|
+
)
|
|
452
|
+
response["durable_checkpoint"] = durable_checkpoint
|
|
453
|
+
except Exception:
|
|
454
|
+
pass
|
|
390
455
|
return json.dumps(response, ensure_ascii=False, indent=2)
|
|
391
456
|
|
|
392
457
|
|
|
@@ -42,11 +42,57 @@ VALID_STATUSES = {
|
|
|
42
42
|
STATUS_OFF,
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
45
|
+
# Path resolution moved to lazy functions (AUDITOR-V700-PASS2 §11, B10 item
|
|
46
|
+
# 3). The previous module-level constants were evaluated at import time, so
|
|
47
|
+
# any caller that monkeypatched NEXO_HOME or ``paths.operations_dir()`` after
|
|
48
|
+
# import kept seeing the stale values. PEP 562 ``__getattr__`` below still
|
|
49
|
+
# exposes ``public_contribution.NEXO_HOME`` / ``CONTRIB_ROOT`` / etc. for
|
|
50
|
+
# legacy callers that access them as module attributes — re-evaluated on
|
|
51
|
+
# every access. A call like ``from public_contribution import CONTRIB_ROOT``
|
|
52
|
+
# still snapshots at import, which is the intended behaviour for scripts
|
|
53
|
+
# whose NEXO_HOME is fixed before they start.
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _nexo_home() -> Path:
|
|
57
|
+
return Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# Public-contribution staging lives under ``NEXO_HOME / contrib`` (the mirror
|
|
61
|
+
# clone of the public repo plus per-proposal worktrees). It is intentionally
|
|
62
|
+
# outside ``paths.operations_dir()`` because it holds an actual git clone, not
|
|
63
|
+
# operational artifacts. Artifacts (logs, proposal payloads) live under
|
|
64
|
+
# ``_contrib_artifacts_dir()`` below. If this ever relocates, migrate the
|
|
65
|
+
# existing clone + open worktrees — do NOT delete and reclone blindly.
|
|
66
|
+
def _contrib_root() -> Path:
|
|
67
|
+
return _nexo_home() / "contrib" / "public-core"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _contrib_repo_dir() -> Path:
|
|
71
|
+
return _contrib_root() / "repo"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _contrib_worktrees_dir() -> Path:
|
|
75
|
+
return _contrib_root() / "worktrees"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _contrib_artifacts_dir() -> Path:
|
|
79
|
+
return paths.operations_dir() / "public-contrib"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
_LAZY_PATHS = {
|
|
83
|
+
"NEXO_HOME": _nexo_home,
|
|
84
|
+
"CONTRIB_ROOT": _contrib_root,
|
|
85
|
+
"CONTRIB_REPO_DIR": _contrib_repo_dir,
|
|
86
|
+
"CONTRIB_WORKTREES_DIR": _contrib_worktrees_dir,
|
|
87
|
+
"CONTRIB_ARTIFACTS_DIR": _contrib_artifacts_dir,
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def __getattr__(name: str):
|
|
92
|
+
resolver = _LAZY_PATHS.get(name)
|
|
93
|
+
if resolver is None:
|
|
94
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
95
|
+
return resolver()
|
|
50
96
|
|
|
51
97
|
|
|
52
98
|
def _utcnow() -> datetime:
|
|
@@ -26,11 +26,29 @@ and this module decides whether to inject.
|
|
|
26
26
|
from __future__ import annotations
|
|
27
27
|
|
|
28
28
|
import re
|
|
29
|
-
from typing import Callable, Iterable, Optional
|
|
29
|
+
from typing import Any, Callable, Iterable, Optional
|
|
30
30
|
|
|
31
31
|
from core_prompts import render_core_prompt
|
|
32
32
|
|
|
33
33
|
|
|
34
|
+
def _verdict_to_bool(verdict: Any) -> bool:
|
|
35
|
+
"""Normalize a classifier verdict to an inject/no-inject decision.
|
|
36
|
+
|
|
37
|
+
The shared ``enforcement_classifier.classify`` exposes a ``tristate``
|
|
38
|
+
mode that returns ``"yes"``/``"no"``/``"unknown"`` strings. A naive
|
|
39
|
+
``bool(verdict)`` wrap treats ``"unknown"`` (and any non-empty string
|
|
40
|
+
the model may produce) as truthy, which is fail-OPEN for R34. Only
|
|
41
|
+
a real ``True`` or an explicit ``"yes"`` should trigger an injection;
|
|
42
|
+
every other shape — ``False``, ``None``, ``"no"``, ``"unknown"``,
|
|
43
|
+
arbitrary strings — is conservative no-inject.
|
|
44
|
+
"""
|
|
45
|
+
if isinstance(verdict, bool):
|
|
46
|
+
return verdict
|
|
47
|
+
if isinstance(verdict, str):
|
|
48
|
+
return verdict.strip().lower() == "yes"
|
|
49
|
+
return False
|
|
50
|
+
|
|
51
|
+
|
|
34
52
|
# Shared-brain tools that count as "you checked before speaking".
|
|
35
53
|
SHARED_BRAIN_TOOLS = frozenset({
|
|
36
54
|
"nexo_recent_context",
|
|
@@ -79,7 +97,7 @@ def should_inject_r34(
|
|
|
79
97
|
message: str,
|
|
80
98
|
*,
|
|
81
99
|
recent_tool_names: Iterable[str] | None,
|
|
82
|
-
classifier: Callable[[str, str],
|
|
100
|
+
classifier: Callable[[str, str], Any] | None = None,
|
|
83
101
|
) -> tuple[bool, str, str]:
|
|
84
102
|
"""Return ``(inject, prompt, matched_text)``.
|
|
85
103
|
|
|
@@ -88,9 +106,11 @@ def should_inject_r34(
|
|
|
88
106
|
recent_tool_names: tool names seen in this turn; any match in
|
|
89
107
|
SHARED_BRAIN_TOOLS suppresses the rule.
|
|
90
108
|
classifier: optional LLM classifier. Signature
|
|
91
|
-
``classifier(question, context) -> bool``.
|
|
92
|
-
|
|
93
|
-
|
|
109
|
+
``classifier(question, context) -> bool | str``. Return ``True``
|
|
110
|
+
or the string ``"yes"`` to trigger injection; any other value
|
|
111
|
+
(``False``, ``None``, ``"no"``, ``"unknown"``, arbitrary
|
|
112
|
+
strings) is treated as no-inject. If ``None`` is passed for
|
|
113
|
+
the argument, the regex match alone fires (test path).
|
|
94
114
|
"""
|
|
95
115
|
if not isinstance(message, str) or not message:
|
|
96
116
|
return False, "", ""
|
|
@@ -103,15 +123,17 @@ def should_inject_r34(
|
|
|
103
123
|
if classifier is None:
|
|
104
124
|
return True, INJECTION_PROMPT, matched
|
|
105
125
|
# LLM disambiguation — reuses T4 infra. The engine passes a lambda
|
|
106
|
-
# that calls enforcement_classifier.classify under the hood.
|
|
126
|
+
# that calls enforcement_classifier.classify under the hood. Parse
|
|
127
|
+
# the verdict via _verdict_to_bool so tristate "unknown" does not
|
|
128
|
+
# coerce to True.
|
|
107
129
|
try:
|
|
108
|
-
|
|
130
|
+
raw_verdict = classifier(CLASSIFIER_QUESTION, message)
|
|
109
131
|
except Exception:
|
|
110
132
|
# Fail-closed: if the classifier errors, do not inject (avoids
|
|
111
133
|
# noisy false positives on regex-only matches when the LLM is
|
|
112
134
|
# unavailable).
|
|
113
135
|
return False, "", matched
|
|
114
|
-
if not
|
|
136
|
+
if not _verdict_to_bool(raw_verdict):
|
|
115
137
|
return False, "", matched
|
|
116
138
|
return True, INJECTION_PROMPT, matched
|
|
117
139
|
|
|
@@ -121,5 +143,6 @@ __all__ = [
|
|
|
121
143
|
"CLASSIFIER_QUESTION",
|
|
122
144
|
"INJECTION_PROMPT",
|
|
123
145
|
"SHARED_BRAIN_TOOLS",
|
|
146
|
+
"_verdict_to_bool",
|
|
124
147
|
"should_inject_r34",
|
|
125
148
|
]
|
package/src/script_registry.py
CHANGED
|
@@ -707,7 +707,14 @@ def classify_scripts_dir() -> dict:
|
|
|
707
707
|
core_names = load_core_script_names()
|
|
708
708
|
core_identities = load_core_script_identities()
|
|
709
709
|
entries: list[dict] = []
|
|
710
|
-
|
|
710
|
+
# Dedup by resolved real path so the same physical file surfaced
|
|
711
|
+
# from two candidate dirs (e.g. core/scripts/foo.sh + the F0.6
|
|
712
|
+
# legacy fallback ~/.nexo/scripts/foo.sh pointing at the same inode
|
|
713
|
+
# via symlink) appears once. Two distinct files that happen to share
|
|
714
|
+
# a filename resolve to different paths and both survive — preserves
|
|
715
|
+
# the D2 audit fix without the cosmetic duplicate the AUDITOR-V700-
|
|
716
|
+
# PASS2 §5 transitional window flagged.
|
|
717
|
+
seen_real_paths: set[str] = set()
|
|
711
718
|
for sdir in candidate_dirs:
|
|
712
719
|
if not sdir.is_dir():
|
|
713
720
|
continue
|
|
@@ -725,12 +732,13 @@ def classify_scripts_dir() -> dict:
|
|
|
725
732
|
for f in sorted(sdir.iterdir()):
|
|
726
733
|
if not f.is_file():
|
|
727
734
|
continue
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
735
|
+
try:
|
|
736
|
+
real_path = str(f.resolve(strict=False))
|
|
737
|
+
except OSError:
|
|
738
|
+
real_path = str(f)
|
|
739
|
+
if real_path in seen_real_paths:
|
|
732
740
|
continue
|
|
733
|
-
|
|
741
|
+
seen_real_paths.add(real_path)
|
|
734
742
|
meta = parse_inline_metadata(f)
|
|
735
743
|
if _is_ignored(f):
|
|
736
744
|
entries.append(_script_entry(f, meta, is_core=False, classification="ignored", reason="internal or hidden artifact"))
|
|
@@ -55,9 +55,15 @@ log() { echo "[$TS] $1" >> "$LOG"; }
|
|
|
55
55
|
# The NEXO_CODE env var must point to the repo src/ directory.
|
|
56
56
|
# Add personal (non-manifest) monitors to PERSONAL_MONITORS below.
|
|
57
57
|
NEXO_CODE="${NEXO_CODE:-$(cd "$(dirname "$0")/.." 2>/dev/null && pwd)}"
|
|
58
|
-
#
|
|
58
|
+
# Manifest resolution priority:
|
|
59
|
+
# 1. $NEXO_HOME/runtime/crons/manifest.json — F0.6 canonical location
|
|
60
|
+
# 2. $NEXO_HOME/crons/manifest.json — pre-F0.6 legacy (kept so
|
|
61
|
+
# half-migrated installs don't silently lose all core monitors)
|
|
62
|
+
# 3. $NEXO_CODE/crons/manifest.json — dev/repo checkout
|
|
59
63
|
if [ -f "$NEXO_HOME/runtime/crons/manifest.json" ]; then
|
|
60
64
|
MANIFEST_FILE="$NEXO_HOME/runtime/crons/manifest.json"
|
|
65
|
+
elif [ -f "$NEXO_HOME/crons/manifest.json" ]; then
|
|
66
|
+
MANIFEST_FILE="$NEXO_HOME/crons/manifest.json"
|
|
61
67
|
else
|
|
62
68
|
MANIFEST_FILE="$NEXO_CODE/crons/manifest.json"
|
|
63
69
|
fi
|