nexo-brain 5.3.11 → 5.3.13

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.
Files changed (35) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +3 -5
  3. package/package.json +1 -1
  4. package/src/agent_runner.py +2 -0
  5. package/src/auto_update.py +116 -4
  6. package/src/cli.py +8 -2
  7. package/src/client_preferences.py +26 -7
  8. package/src/client_sync.py +3 -2
  9. package/src/doctor/orchestrator.py +10 -1
  10. package/src/doctor/planes.py +87 -0
  11. package/src/hook_guardrails.py +147 -11
  12. package/src/plugins/doctor.py +3 -2
  13. package/src/plugins/schedule.py +119 -1
  14. package/src/runtime_power.py +3 -2
  15. package/src/scripts/check-context.py +7 -1
  16. package/src/scripts/deep-sleep/extract.py +8 -2
  17. package/src/scripts/deep-sleep/synthesize.py +8 -1
  18. package/src/scripts/nexo-catchup.py +8 -2
  19. package/src/scripts/nexo-cortex-cycle.py +48 -21
  20. package/src/scripts/nexo-daily-self-audit.py +56 -23
  21. package/src/scripts/nexo-evolution-run.py +10 -2
  22. package/src/scripts/nexo-immune.py +8 -1
  23. package/src/scripts/nexo-learning-validator.py +9 -1
  24. package/src/scripts/nexo-postmortem-consolidator.py +9 -1
  25. package/src/scripts/nexo-sleep.py +7 -1
  26. package/src/scripts/nexo-synthesis.py +8 -1
  27. package/src/scripts/rehydrate_learnings_from_archive.py +245 -0
  28. package/src/server.py +2 -0
  29. package/src/skills/run-nexo-core-fix-cycle/guide.md +17 -0
  30. package/src/skills/run-nexo-core-fix-cycle/script.py +276 -0
  31. package/src/skills/run-nexo-core-fix-cycle/skill.json +58 -0
  32. package/src/skills/run-release-final-audit/guide.md +5 -3
  33. package/src/skills/run-release-final-audit/script.py +17 -8
  34. package/src/skills/run-release-final-audit/skill.json +15 -2
  35. package/src/skills/run-runtime-doctor/script.py +1 -1
@@ -11,13 +11,14 @@ if str(SRC_DIR) not in sys.path:
11
11
  sys.path.insert(0, str(SRC_DIR))
12
12
 
13
13
 
14
- def handle_doctor(tier: str = "boot", fix: bool = False, output: str = "text") -> str:
14
+ def handle_doctor(tier: str = "boot", fix: bool = False, output: str = "text", plane: str = "") -> str:
15
15
  """Unified diagnostic report for boot/runtime/deep health.
16
16
 
17
17
  Args:
18
18
  tier: Diagnostic tier — boot, runtime, deep, or all (default: boot)
19
19
  fix: Apply deterministic fixes (default: False)
20
20
  output: Output format — text or json (default: text)
21
+ plane: Diagnostic plane — runtime_personal, installation_live, or database_real
21
22
  """
22
23
  from doctor.orchestrator import run_doctor
23
24
  from doctor.formatters import format_report
@@ -27,7 +28,7 @@ def handle_doctor(tier: str = "boot", fix: bool = False, output: str = "text") -
27
28
  if output not in ("text", "json"):
28
29
  return f"Invalid output '{output}'. Use: text, json"
29
30
 
30
- report = run_doctor(tier=tier, fix=fix)
31
+ report = run_doctor(tier=tier, fix=fix, plane=plane)
31
32
  return format_report(report, fmt=output)
32
33
 
33
34
 
@@ -20,6 +20,9 @@ from script_registry import (
20
20
  get_declared_schedule,
21
21
  )
22
22
 
23
+ LEGACY_BACKUP_CRON_ID = "backup"
24
+ LEGACY_BACKUP_SUMMARY = "legacy backup file evidence"
25
+
23
26
 
24
27
  def handle_schedule_status(hours: int = 24, cron_id: str = '') -> str:
25
28
  """Show cron execution status — what ran, what failed, durations.
@@ -30,6 +33,8 @@ def handle_schedule_status(hours: int = 24, cron_id: str = '') -> str:
30
33
  """
31
34
  if cron_id:
32
35
  runs = cron_runs_recent(hours, cron_id)
36
+ if cron_id == LEGACY_BACKUP_CRON_ID:
37
+ runs = _select_backup_runs(runs, hours)
33
38
  if not runs:
34
39
  return f"No runs for '{cron_id}' in the last {hours}h."
35
40
  schedule_meta = get_personal_script_schedule(cron_id) or {}
@@ -53,7 +58,7 @@ def handle_schedule_status(hours: int = 24, cron_id: str = '') -> str:
53
58
  return "\n".join(lines)
54
59
 
55
60
  # Summary view — one line per cron
56
- summary = cron_runs_summary(hours)
61
+ summary = _merge_legacy_summaries(cron_runs_summary(hours), hours)
57
62
  if not summary:
58
63
  return f"No cron executions recorded in the last {hours}h."
59
64
 
@@ -82,6 +87,119 @@ def handle_schedule_status(hours: int = 24, cron_id: str = '') -> str:
82
87
  return "\n".join(lines)
83
88
 
84
89
 
90
+ def _select_backup_runs(db_runs: list[dict], hours: int) -> list[dict]:
91
+ legacy_runs = _legacy_backup_runs(hours)
92
+ if _prefer_legacy_over_db(db_runs, legacy_runs):
93
+ return legacy_runs
94
+ return db_runs
95
+
96
+
97
+ def _merge_legacy_summaries(summary_rows: list[dict], hours: int) -> list[dict]:
98
+ rows = [dict(row) for row in (summary_rows or [])]
99
+ legacy_summary = _legacy_backup_summary(hours)
100
+ if not legacy_summary:
101
+ return rows
102
+ by_cron_id = {row["cron_id"]: row for row in rows}
103
+ existing = by_cron_id.get(LEGACY_BACKUP_CRON_ID)
104
+ if _prefer_legacy_summary(existing, legacy_summary):
105
+ by_cron_id[LEGACY_BACKUP_CRON_ID] = legacy_summary
106
+ return sorted(
107
+ by_cron_id.values(),
108
+ key=lambda row: row.get("last_run") or "",
109
+ reverse=True,
110
+ )
111
+
112
+
113
+ def _prefer_legacy_over_db(db_runs: list[dict], legacy_runs: list[dict]) -> bool:
114
+ if not legacy_runs:
115
+ return False
116
+ if not db_runs:
117
+ return True
118
+ latest_db = _parse_db_timestamp(db_runs[0].get("started_at"))
119
+ latest_legacy = _parse_db_timestamp(legacy_runs[0].get("started_at"))
120
+ if latest_legacy is None:
121
+ return False
122
+ if latest_db is None:
123
+ return True
124
+ return latest_legacy > latest_db
125
+
126
+
127
+ def _prefer_legacy_summary(existing: dict | None, legacy: dict) -> bool:
128
+ if not legacy:
129
+ return False
130
+ if not existing:
131
+ return True
132
+ latest_existing = _parse_db_timestamp(existing.get("last_run"))
133
+ latest_legacy = _parse_db_timestamp(legacy.get("last_run"))
134
+ if latest_legacy is None:
135
+ return False
136
+ if latest_existing is None:
137
+ return True
138
+ return latest_legacy > latest_existing
139
+
140
+
141
+ def _legacy_backup_summary(hours: int) -> dict | None:
142
+ runs = _legacy_backup_runs(hours)
143
+ if not runs:
144
+ return None
145
+ return _build_summary_from_runs(LEGACY_BACKUP_CRON_ID, runs)
146
+
147
+
148
+ def _legacy_backup_runs(hours: int) -> list[dict]:
149
+ nexo_home = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
150
+ backup_dir = nexo_home / "backups"
151
+ if not backup_dir.exists():
152
+ return []
153
+ cutoff = _now_utc().timestamp() - (hours * 3600)
154
+ runs: list[dict] = []
155
+ for backup_file in backup_dir.glob("nexo-*.db"):
156
+ try:
157
+ stat = backup_file.stat()
158
+ except OSError:
159
+ continue
160
+ if stat.st_mtime < cutoff:
161
+ continue
162
+ started = datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).replace(microsecond=0)
163
+ started_at = started.strftime("%Y-%m-%d %H:%M:%S")
164
+ runs.append(
165
+ {
166
+ "cron_id": LEGACY_BACKUP_CRON_ID,
167
+ "started_at": started_at,
168
+ "ended_at": started_at,
169
+ "exit_code": 0,
170
+ "summary": LEGACY_BACKUP_SUMMARY,
171
+ "error": "",
172
+ "duration_secs": 1.0,
173
+ }
174
+ )
175
+ runs.sort(key=lambda row: row["started_at"], reverse=True)
176
+ return runs
177
+
178
+
179
+ def _build_summary_from_runs(cron_id: str, runs: list[dict]) -> dict:
180
+ completed_runs = [
181
+ row for row in runs if row.get("exit_code") is not None and row.get("ended_at")
182
+ ]
183
+ duration_values = [
184
+ float(row["duration_secs"])
185
+ for row in completed_runs
186
+ if row.get("duration_secs") is not None
187
+ ]
188
+ return {
189
+ "cron_id": cron_id,
190
+ "total_runs": len(runs),
191
+ "succeeded": sum(1 for row in completed_runs if row.get("exit_code") == 0),
192
+ "completed_runs": len(completed_runs),
193
+ "failed": sum(1 for row in completed_runs if row.get("exit_code") not in (None, 0)),
194
+ "open_runs": len(runs) - len(completed_runs),
195
+ "avg_duration": round(sum(duration_values) / len(duration_values), 1) if duration_values else None,
196
+ "last_run": runs[0].get("started_at"),
197
+ "last_exit_code": runs[0].get("exit_code"),
198
+ "last_ended_at": runs[0].get("ended_at"),
199
+ "last_summary": next((row.get("summary") for row in runs if row.get("summary")), ""),
200
+ }
201
+
202
+
85
203
  def _summary_has_warning(summary: str = "") -> bool:
86
204
  lowered = str(summary or "").strip().lower()
87
205
  if not lowered:
@@ -67,8 +67,9 @@ MACOS_FDA_PROBE_PATHS = (
67
67
  )
68
68
  DEFAULT_CLAUDE_CODE_MODEL = "claude-opus-4-6[1m]"
69
69
  DEFAULT_CLAUDE_CODE_REASONING_EFFORT = ""
70
- DEFAULT_CODEX_MODEL = "gpt-5.4"
71
- DEFAULT_CODEX_REASONING_EFFORT = "xhigh"
70
+ # Codex defaults mirror the user's primary model — no hardcoded third-party models.
71
+ DEFAULT_CODEX_MODEL = DEFAULT_CLAUDE_CODE_MODEL
72
+ DEFAULT_CODEX_REASONING_EFFORT = ""
72
73
 
73
74
 
74
75
  def _schedule_defaults() -> dict:
@@ -21,6 +21,12 @@ if str(NEXO_CODE) not in sys.path:
21
21
  sys.path.insert(0, str(NEXO_CODE))
22
22
 
23
23
  from agent_runner import AutomationBackendUnavailableError, run_automation_prompt
24
+ try:
25
+ from client_preferences import resolve_user_model as _resolve_user_model
26
+ _USER_MODEL = _resolve_user_model()
27
+ except Exception:
28
+ _USER_MODEL = ""
29
+
24
30
 
25
31
 
26
32
  class ContextChecker:
@@ -192,7 +198,7 @@ Rules:
192
198
  try:
193
199
  result = run_automation_prompt(
194
200
  prompt,
195
- model="opus",
201
+ model=_USER_MODEL or "opus",
196
202
  timeout=300,
197
203
  output_format="text",
198
204
  append_system_prompt="Return exactly one valid JSON object.",
@@ -26,6 +26,12 @@ if str(NEXO_CODE) not in sys.path:
26
26
  sys.path.insert(0, str(NEXO_CODE))
27
27
 
28
28
  from agent_runner import AutomationBackendUnavailableError, run_automation_prompt
29
+ try:
30
+ from client_preferences import resolve_user_model as _resolve_user_model
31
+ _USER_MODEL = _resolve_user_model()
32
+ except Exception:
33
+ _USER_MODEL = ""
34
+
29
35
 
30
36
  # No timeout -- headless automation can take as long as needed
31
37
  CLAUDE_TIMEOUT = 21600 # 3h safety net (prevents zombie processes)
@@ -128,7 +134,7 @@ def analyze_session(
128
134
 
129
135
  result = run_automation_prompt(
130
136
  prompt,
131
- model="opus",
137
+ model=_USER_MODEL or "opus",
132
138
  timeout=CLAUDE_TIMEOUT,
133
139
  output_format="text",
134
140
  append_system_prompt=JSON_SYSTEM_PROMPT,
@@ -158,7 +164,7 @@ def analyze_session(
158
164
  )
159
165
  convert_result = run_automation_prompt(
160
166
  convert_prompt,
161
- model="sonnet",
167
+ model=_USER_MODEL or "sonnet",
162
168
  timeout=120,
163
169
  output_format="text",
164
170
  append_system_prompt=JSON_SYSTEM_PROMPT,
@@ -18,6 +18,13 @@ import hashlib
18
18
  from datetime import datetime
19
19
  from pathlib import Path
20
20
 
21
+
22
+ try:
23
+ from client_preferences import resolve_user_model as _resolve_user_model
24
+ _USER_MODEL = _resolve_user_model()
25
+ except Exception:
26
+ _USER_MODEL = ""
27
+
21
28
  NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
22
29
  NEXO_CODE = Path(os.environ.get("NEXO_CODE", str(Path(__file__).resolve().parents[2])))
23
30
  DEEP_SLEEP_DIR = NEXO_HOME / "operations" / "deep-sleep"
@@ -233,7 +240,7 @@ def main():
233
240
  try:
234
241
  result = run_automation_prompt(
235
242
  prompt,
236
- model="opus",
243
+ model=_USER_MODEL or "opus",
237
244
  timeout=CLAUDE_TIMEOUT,
238
245
  output_format="text",
239
246
  allowed_tools="Read,Grep,Bash",
@@ -13,6 +13,13 @@ import sys
13
13
  from datetime import datetime
14
14
  from pathlib import Path
15
15
 
16
+
17
+ try:
18
+ from client_preferences import resolve_user_model as _resolve_user_model
19
+ _USER_MODEL = _resolve_user_model()
20
+ except Exception:
21
+ _USER_MODEL = ""
22
+
16
23
  _SCRIPT_DIR = Path(__file__).resolve().parent
17
24
  _DEFAULT_RUNTIME_ROOT = _SCRIPT_DIR.parent
18
25
  _runtime_root = Path(os.environ.get("NEXO_CODE", str(_DEFAULT_RUNTIME_ROOT)))
@@ -126,7 +133,6 @@ def _heal_personal_schedules() -> dict:
126
133
  summary = {"created": 0, "repaired": 0, "invalid": 0, "error": ""}
127
134
  try:
128
135
  from script_registry import reconcile_personal_scripts
129
-
130
136
  result = reconcile_personal_scripts(dry_run=False)
131
137
  ensured = result.get("ensure_schedules", {})
132
138
  summary["created"] = len(ensured.get("created", []))
@@ -273,7 +279,7 @@ Format:
273
279
  try:
274
280
  result = run_automation_prompt(
275
281
  prompt,
276
- model="opus",
282
+ model=_USER_MODEL or "opus",
277
283
  timeout=21600,
278
284
  output_format="text",
279
285
  allowed_tools="Read,Write,Edit,Glob,Grep,Bash,mcp__nexo__*",
@@ -18,9 +18,9 @@ What this script does (idempotent and best-effort):
18
18
  3. Detects degradation signals on the 7-day window. The criteria are
19
19
  intentionally conservative to avoid false alarms on small samples:
20
20
  a. recommendation_accept_rate < 50% AND total_evaluations >= 10
21
- b. linked_outcome_success_rate < 50% AND linked_outcomes_total >= 5
21
+ b. linked_outcome_success_rate < 50% AND linked_outcomes_resolved >= 5
22
22
  c. override_success_rate > recommended_success_rate by >= 20pp
23
- AND linked_outcomes_total >= 5
23
+ AND linked_outcomes_resolved >= 5
24
24
  4. Opens (or refreshes) NF-CORTEX-QUALITY-DROP followup with the offending
25
25
  metrics when degradation is detected. Idempotent: if a non-PENDING /
26
26
  resolved followup of the same id already exists, it is updated in
@@ -96,6 +96,13 @@ def detect_quality_signals(summary: dict) -> list[dict]:
96
96
  total = int(summary.get("total_evaluations") or 0)
97
97
  accept_rate = float(summary.get("recommendation_accept_rate") or 0.0)
98
98
  linked_total = int(summary.get("linked_outcomes_total") or 0)
99
+ linked_met = int(summary.get("linked_outcomes_met") or 0)
100
+ linked_missed = int(summary.get("linked_outcomes_missed") or 0)
101
+ linked_pending = int(summary.get("linked_outcomes_pending") or 0)
102
+ linked_resolved = linked_met + linked_missed
103
+ if linked_resolved <= 0 and linked_total > 0:
104
+ # Older callers may omit the met/missed counters; fall back to total minus pending.
105
+ linked_resolved = max(0, linked_total - linked_pending)
99
106
  linked_success = float(summary.get("linked_outcome_success_rate") or 0.0)
100
107
  recommended_success = float(summary.get("recommended_success_rate") or 0.0)
101
108
  override_success = float(summary.get("override_success_rate") or 0.0)
@@ -114,21 +121,25 @@ def detect_quality_signals(summary: dict) -> list[dict]:
114
121
  ),
115
122
  })
116
123
 
117
- if linked_total >= LINKED_MIN_SAMPLE and linked_success < LINKED_SUCCESS_FLOOR:
124
+ linked_scope = f"{linked_resolved} resolved linked outcomes"
125
+ if linked_pending > 0:
126
+ linked_scope += f" ({linked_total} total, {linked_pending} pending)"
127
+
128
+ if linked_resolved >= LINKED_MIN_SAMPLE and linked_success < LINKED_SUCCESS_FLOOR:
118
129
  signals.append({
119
130
  "kind": "linked_success",
120
131
  "severity": "warn",
121
132
  "metric_value": linked_success,
122
133
  "threshold": LINKED_SUCCESS_FLOOR,
123
- "sample_size": linked_total,
134
+ "sample_size": linked_resolved,
124
135
  "message": (
125
136
  f"Cortex linked-outcome success rate {linked_success:.1f}% on "
126
- f"{linked_total} linked outcomes is below the "
137
+ f"{linked_scope} is below the "
127
138
  f"{LINKED_SUCCESS_FLOOR:.0f}% floor."
128
139
  ),
129
140
  })
130
141
 
131
- if linked_total >= LINKED_MIN_SAMPLE:
142
+ if linked_resolved >= LINKED_MIN_SAMPLE:
132
143
  gap = override_success - recommended_success
133
144
  if gap >= OVERRIDE_GAP_THRESHOLD:
134
145
  signals.append({
@@ -136,12 +147,12 @@ def detect_quality_signals(summary: dict) -> list[dict]:
136
147
  "severity": "error",
137
148
  "metric_value": gap,
138
149
  "threshold": OVERRIDE_GAP_THRESHOLD,
139
- "sample_size": linked_total,
150
+ "sample_size": linked_resolved,
140
151
  "message": (
141
152
  f"Cortex overrides outperform recommendations by {gap:.1f}pp "
142
153
  f"(override {override_success:.1f}% vs recommended "
143
- f"{recommended_success:.1f}% on {linked_total} linked "
144
- "outcomes). The recommender is mis-ranking choices."
154
+ f"{recommended_success:.1f}% on {linked_scope}). The "
155
+ "recommender is mis-ranking choices."
145
156
  ),
146
157
  })
147
158
 
@@ -171,15 +182,37 @@ def _upsert_quality_followup(signals: list[dict]) -> str:
171
182
  resolved, a fresh row is inserted with the same id (REPLACE) so the
172
183
  new degradation pattern is visible.
173
184
  """
174
- if not signals:
175
- return "no_signal"
176
-
177
185
  try:
178
- from db import get_followup, get_db
186
+ from db import complete_followup, get_followup, get_db
179
187
  except Exception as e:
180
188
  _log(f"WARN: cannot import db helpers: {e}")
181
189
  return "skipped_no_db"
182
190
 
191
+ try:
192
+ existing = get_followup(FOLLOWUP_ID)
193
+ except Exception as e:
194
+ _log(f"WARN: get_followup raised: {e}")
195
+ existing = None
196
+
197
+ if not signals:
198
+ if not existing:
199
+ return "no_signal"
200
+ status = str(existing.get("status") or "").upper()
201
+ if status.startswith("COMPLETED") or status in {"DELETED", "ARCHIVED", "BLOCKED", "WAITING", "CANCELLED"}:
202
+ return "no_signal"
203
+ try:
204
+ complete_followup(
205
+ FOLLOWUP_ID,
206
+ result=(
207
+ "Auto-resolved by cortex-cycle: no active degradation signals in the "
208
+ "current 7d window."
209
+ ),
210
+ )
211
+ except Exception as e:
212
+ _log(f"WARN: failed to close followup: {e}")
213
+ return "failed_close"
214
+ return "closed"
215
+
183
216
  summary_lines = ["Cortex continuous validation found quality degradation:"]
184
217
  for sig in signals:
185
218
  summary_lines.append(
@@ -197,12 +230,6 @@ def _upsert_quality_followup(signals: list[dict]) -> str:
197
230
  )
198
231
  now_epoch = datetime.now().timestamp()
199
232
 
200
- try:
201
- existing = get_followup(FOLLOWUP_ID)
202
- except Exception as e:
203
- _log(f"WARN: get_followup raised: {e}")
204
- existing = None
205
-
206
233
  try:
207
234
  conn = get_db()
208
235
  conn.execute(
@@ -255,8 +282,8 @@ def run() -> int:
255
282
  f"signals={len(signals)}"
256
283
  )
257
284
 
258
- if signals:
259
- action = _upsert_quality_followup(signals)
285
+ action = _upsert_quality_followup(signals)
286
+ if signals or action not in {"no_signal"}:
260
287
  _log(f"Cortex cycle: followup {FOLLOWUP_ID} {action} ({len(signals)} signal(s))")
261
288
 
262
289
  return 0
@@ -40,6 +40,12 @@ from agent_runner import AutomationBackendUnavailableError, run_automation_promp
40
40
  import db as nexo_db
41
41
  from public_evolution_queue import queue_public_port_candidate
42
42
 
43
+ try:
44
+ from client_preferences import resolve_user_model as _resolve_user_model
45
+ _USER_MODEL = _resolve_user_model()
46
+ except Exception:
47
+ _USER_MODEL = ""
48
+
43
49
  LOG_DIR = NEXO_HOME / "logs"
44
50
  LOG_DIR.mkdir(parents=True, exist_ok=True)
45
51
  AUDIT_HISTORY_DIR = LOG_DIR / "self-audit"
@@ -479,6 +485,43 @@ def _upsert_workflow_goal_inline(conn: sqlite3.Connection, *, area: str, sample_
479
485
 
480
486
  columns = _table_columns(conn, "workflow_goals")
481
487
  signature = _topic_signature(sample_goal)
488
+ goal_id = f"WG-AUDIT-{hashlib.sha1(f'{area}:{signature or sample_goal}'.encode('utf-8'), usedforsecurity=False).hexdigest()[:8].upper()}"
489
+
490
+ def _write_goal(existing_row: sqlite3.Row, *, reactivated: bool) -> dict:
491
+ updates: dict[str, object] = {}
492
+ if "title" in columns:
493
+ updates["title"] = sample_goal[:140]
494
+ if "objective" in columns:
495
+ updates["objective"] = objective
496
+ if "priority" in columns:
497
+ updates["priority"] = "high"
498
+ if "owner" in columns:
499
+ updates["owner"] = AUDIT_GOAL_OWNER
500
+ if "next_action" in columns:
501
+ updates["next_action"] = next_action
502
+ if "success_signal" in columns:
503
+ updates["success_signal"] = success_signal
504
+ if "shared_state" in columns:
505
+ updates["shared_state"] = json.dumps({"area": area, "signature": signature, "source": "self-audit"})
506
+ if reactivated and "status" in columns:
507
+ updates["status"] = "active"
508
+ if reactivated and "blocker_reason" in columns:
509
+ updates["blocker_reason"] = ""
510
+ if reactivated and "closed_at" in columns:
511
+ updates["closed_at"] = None
512
+ if "updated_at" in columns:
513
+ updates["updated_at"] = now_iso
514
+ assignments = ", ".join(f"{column} = ?" for column in updates)
515
+ conn.execute(
516
+ f"UPDATE workflow_goals SET {assignments} WHERE goal_id = ?",
517
+ [updates[column] for column in updates] + [existing_row["goal_id"]],
518
+ )
519
+ return {
520
+ "ok": True,
521
+ "action": "reactivated" if reactivated else "updated",
522
+ "goal_id": str(existing_row["goal_id"]),
523
+ }
524
+
482
525
  rows = conn.execute(
483
526
  """SELECT * FROM workflow_goals
484
527
  WHERE status NOT IN ('completed', 'cancelled', 'abandoned')
@@ -499,31 +542,21 @@ def _upsert_workflow_goal_inline(conn: sqlite3.Connection, *, area: str, sample_
499
542
  next_action = AUDIT_GOAL_NEXT_ACTION
500
543
  success_signal = "The theme stops resurfacing in unresolved protocol tasks."
501
544
  now_iso = datetime.now().isoformat(timespec="seconds")
502
- if existing:
503
- updates: dict[str, object] = {}
504
- if "title" in columns:
505
- updates["title"] = sample_goal[:140]
506
- if "objective" in columns:
507
- updates["objective"] = objective
508
- if "priority" in columns:
509
- updates["priority"] = "high"
510
- if "owner" in columns:
511
- updates["owner"] = AUDIT_GOAL_OWNER
512
- if "next_action" in columns:
513
- updates["next_action"] = next_action
514
- if "success_signal" in columns:
515
- updates["success_signal"] = success_signal
516
- if "updated_at" in columns:
517
- updates["updated_at"] = now_iso
518
- assignments = ", ".join(f"{column} = ?" for column in updates)
519
- conn.execute(
520
- f"UPDATE workflow_goals SET {assignments} WHERE goal_id = ?",
521
- [updates[column] for column in updates] + [existing["goal_id"]],
545
+ exact = conn.execute(
546
+ "SELECT * FROM workflow_goals WHERE goal_id = ? LIMIT 1",
547
+ (goal_id,),
548
+ ).fetchone()
549
+ if exact is not None:
550
+ exact_status = str(exact["status"] or "").lower()
551
+ return _write_goal(
552
+ exact,
553
+ reactivated=exact_status in {"completed", "cancelled", "abandoned"},
522
554
  )
523
- return {"ok": True, "action": "updated", "goal_id": str(existing["goal_id"])}
555
+
556
+ if existing:
557
+ return _write_goal(existing, reactivated=False)
524
558
 
525
559
  # Content fingerprint, not security-sensitive.
526
- goal_id = f"WG-AUDIT-{hashlib.sha1(f'{area}:{signature or sample_goal}'.encode('utf-8'), usedforsecurity=False).hexdigest()[:8].upper()}"
527
560
  values: dict[str, object] = {"goal_id": goal_id}
528
561
  if "session_id" in columns:
529
562
  values["session_id"] = ""
@@ -2016,7 +2049,7 @@ Also write the machine-readable summary to {LOG_DIR}/self-audit-summary.json.
2016
2049
  try:
2017
2050
  result = run_automation_prompt(
2018
2051
  prompt,
2019
- model="opus",
2052
+ model=_USER_MODEL or "opus",
2020
2053
  timeout=21600,
2021
2054
  output_format="text",
2022
2055
  allowed_tools="Read,Write,Edit,Glob,Grep,Bash,mcp__nexo__*",
@@ -20,6 +20,14 @@ import sys
20
20
  from datetime import datetime, date, timedelta
21
21
  from pathlib import Path
22
22
 
23
+
24
+ try:
25
+ from client_preferences import resolve_user_model as _resolve_user_model
26
+ _USER_MODEL = _resolve_user_model()
27
+ except Exception:
28
+ _USER_MODEL = ""
29
+
30
+
23
31
  NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
24
32
  # Auto-detect: if running from repo (src/scripts/), use src/ as NEXO_CODE
25
33
  _script_dir = Path(__file__).resolve().parent
@@ -218,7 +226,7 @@ def call_claude_cli(prompt: str) -> str:
218
226
  """Call the configured automation backend for the managed evolution prompt."""
219
227
  result = run_automation_prompt(
220
228
  prompt,
221
- model="opus",
229
+ model=_USER_MODEL or "opus",
222
230
  timeout=CLI_TIMEOUT,
223
231
  output_format="text",
224
232
  allowed_tools="Read,Write,Edit,Glob,Grep,Bash,mcp__nexo__*",
@@ -234,7 +242,7 @@ def call_public_claude_cli(prompt: str, *, cwd: Path) -> str:
234
242
  prompt,
235
243
  cwd=cwd,
236
244
  env={"NEXO_PUBLIC_CONTRIBUTION": "1"},
237
- model="opus",
245
+ model=_USER_MODEL or "opus",
238
246
  timeout=CLI_TIMEOUT,
239
247
  output_format="text",
240
248
  allowed_tools="Read,Write,Edit,Glob,Grep,Bash",
@@ -23,6 +23,13 @@ import time
23
23
  from datetime import datetime, date, timedelta
24
24
  from pathlib import Path
25
25
 
26
+
27
+ try:
28
+ from client_preferences import resolve_user_model as _resolve_user_model
29
+ _USER_MODEL = _resolve_user_model()
30
+ except Exception:
31
+ _USER_MODEL = ""
32
+
26
33
  NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
27
34
  _script_dir = Path(__file__).resolve().parent
28
35
  _repo_src = _script_dir.parent
@@ -908,7 +915,7 @@ Write the report. Be concise — max 40 lines."""
908
915
  try:
909
916
  result = run_automation_prompt(
910
917
  prompt,
911
- model="opus",
918
+ model=_USER_MODEL or "opus",
912
919
  timeout=21600,
913
920
  output_format="text",
914
921
  allowed_tools="Read,Write,Edit,Glob,Grep,Bash,mcp__nexo__*",
@@ -37,6 +37,13 @@ if str(NEXO_CODE) not in sys.path:
37
37
 
38
38
  from agent_runner import AutomationBackendUnavailableError, run_automation_prompt
39
39
 
40
+ try:
41
+ from client_preferences import resolve_user_model as _resolve_user_model
42
+ _USER_MODEL = _resolve_user_model()
43
+ except Exception:
44
+ _USER_MODEL = ""
45
+
46
+
40
47
 
41
48
  NEXO_DB = NEXO_HOME / "data" / "nexo.db"
42
49
  JSON_ONLY_SYSTEM_PROMPT = (
@@ -151,7 +158,7 @@ Rules:
151
158
  try:
152
159
  result = run_automation_prompt(
153
160
  prompt,
154
- model="sonnet",
161
+ model=_USER_MODEL or "sonnet",
155
162
  timeout=60,
156
163
  output_format="text",
157
164
  append_system_prompt=JSON_ONLY_SYSTEM_PROMPT,
@@ -231,6 +238,7 @@ def _extract_keywords(text: str) -> set:
231
238
 
232
239
  def main():
233
240
  import argparse
241
+
234
242
  parser = argparse.ArgumentParser(description="Validate findings against existing NEXO learnings")
235
243
  parser.add_argument("finding", help="The finding text to validate")
236
244
  parser.add_argument("--category", "-c", help="Filter learnings by category")
@@ -38,6 +38,13 @@ sys.path.insert(0, str(NEXO_CODE))
38
38
 
39
39
  from agent_runner import AutomationBackendUnavailableError, run_automation_prompt
40
40
 
41
+ try:
42
+ from client_preferences import resolve_user_model as _resolve_user_model
43
+ _USER_MODEL = _resolve_user_model()
44
+ except Exception:
45
+ _USER_MODEL = ""
46
+
47
+
41
48
  NEXO_DB = NEXO_HOME / "data" / "nexo.db"
42
49
  # Memory directory — adjust to match your project's memory location
43
50
  MEMORY_DIR = NEXO_HOME / "memory"
@@ -212,7 +219,7 @@ Execute without asking."""
212
219
  try:
213
220
  result = run_automation_prompt(
214
221
  prompt,
215
- model="opus",
222
+ model=_USER_MODEL or "opus",
216
223
  timeout=21600,
217
224
  output_format="text",
218
225
  allowed_tools="Read,Write,Edit,Glob,Grep,Bash,mcp__nexo__*",
@@ -348,6 +355,7 @@ def analyze_force_events():
348
355
  log(f" {len(today_forces)} --force events")
349
356
 
350
357
  from collections import Counter
358
+
351
359
  memory_counts = Counter(r["memory_id"] for r in today_forces)
352
360
  for mem_id, count in memory_counts.most_common():
353
361
  mem = db.execute(