nexo-brain 2.6.21 → 3.0.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.
Files changed (49) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +72 -20
  3. package/hooks/hooks.json +79 -0
  4. package/package.json +1 -1
  5. package/src/agent_runner.py +296 -8
  6. package/src/cli.py +209 -4
  7. package/src/client_preferences.py +115 -0
  8. package/src/client_sync.py +202 -2
  9. package/src/cognitive/__init__.py +1 -1
  10. package/src/cognitive/_search.py +39 -19
  11. package/src/dashboard/app.py +264 -0
  12. package/src/dashboard/templates/base.html +4 -0
  13. package/src/dashboard/templates/dashboard.html +59 -1
  14. package/src/dashboard/templates/protocol.html +199 -0
  15. package/src/db/__init__.py +23 -1
  16. package/src/db/_learnings.py +31 -4
  17. package/src/db/_personal_scripts.py +12 -0
  18. package/src/db/_protocol.py +303 -0
  19. package/src/db/_schema.py +248 -0
  20. package/src/db/_watchers.py +173 -0
  21. package/src/db/_workflow.py +952 -0
  22. package/src/doctor/providers/runtime.py +1095 -3
  23. package/src/evolution_cycle.py +62 -0
  24. package/src/hook_guardrails.py +308 -0
  25. package/src/hooks/protocol-guardrail.sh +10 -0
  26. package/src/nexo_sdk.py +103 -0
  27. package/src/plugins/cognitive_memory.py +18 -0
  28. package/src/plugins/cortex.py +55 -35
  29. package/src/plugins/guard.py +132 -16
  30. package/src/plugins/protocol.py +911 -0
  31. package/src/plugins/schedule.py +40 -6
  32. package/src/plugins/simple_api.py +103 -0
  33. package/src/plugins/skills.py +67 -0
  34. package/src/plugins/state_watchers.py +79 -0
  35. package/src/plugins/workflow.py +588 -0
  36. package/src/public_contribution.py +86 -12
  37. package/src/script_registry.py +142 -0
  38. package/src/scripts/deep-sleep/apply_findings.py +482 -2
  39. package/src/scripts/deep-sleep/collect.py +49 -4
  40. package/src/scripts/nexo-agent-run.py +2 -0
  41. package/src/scripts/nexo-daily-self-audit.py +843 -5
  42. package/src/scripts/nexo-evolution-run.py +343 -1
  43. package/src/server.py +92 -6
  44. package/src/skills_runtime.py +151 -0
  45. package/src/state_watchers_runtime.py +334 -0
  46. package/src/tools_learnings.py +345 -7
  47. package/src/tools_sessions.py +183 -0
  48. package/templates/CLAUDE.md.template +9 -1
  49. package/templates/CODEX.AGENTS.md.template +10 -2
@@ -138,6 +138,30 @@
138
138
  </div>
139
139
  </div>
140
140
  </div>
141
+
142
+ <!-- Row 3: engineering loop narrative -->
143
+ <div class="grid grid-cols-3 gap-4">
144
+ <div class="bg-slate-900/50 border border-slate-800/50 rounded-xl p-5 card">
145
+ <div class="text-xs uppercase tracking-wider text-slate-400 font-medium mb-3">What Matters Now</div>
146
+ <ul id="matters-now-list" class="space-y-2">
147
+ <li class="text-xs text-slate-600 py-1">loading...</li>
148
+ </ul>
149
+ </div>
150
+
151
+ <div class="bg-slate-900/50 border border-slate-800/50 rounded-xl p-5 card">
152
+ <div class="text-xs uppercase tracking-wider text-slate-400 font-medium mb-3">What Is Drifting</div>
153
+ <ul id="drifting-list" class="space-y-2">
154
+ <li class="text-xs text-slate-600 py-1">loading...</li>
155
+ </ul>
156
+ </div>
157
+
158
+ <div class="bg-slate-900/50 border border-slate-800/50 rounded-xl p-5 card">
159
+ <div class="text-xs uppercase tracking-wider text-slate-400 font-medium mb-3">What Is Improving</div>
160
+ <ul id="improving-list" class="space-y-2">
161
+ <li class="text-xs text-slate-600 py-1">loading...</li>
162
+ </ul>
163
+ </div>
164
+ </div>
141
165
  </div>
142
166
 
143
167
  <!-- Quick Create Modal -->
@@ -244,7 +268,7 @@ async function submitQuickCreate(e) {
244
268
  async function loadDashboardData() {
245
269
  const today = getToday();
246
270
 
247
- const [trustData, statsData, remindersData, followupsData, sessionsData, watchdogData, inboxData] =
271
+ const [trustData, statsData, remindersData, followupsData, sessionsData, watchdogData, inboxData, engineeringData] =
248
272
  await Promise.all([
249
273
  fetchJSON('/api/trust'),
250
274
  fetchJSON('/api/stats'),
@@ -253,6 +277,7 @@ async function loadDashboardData() {
253
277
  fetchJSON('/api/sessions?limit=3'),
254
278
  fetchJSON('/api/watchdog'),
255
279
  fetchJSON('/api/inbox/unread'),
280
+ fetchJSON('/api/engineering-loop'),
256
281
  ]);
257
282
 
258
283
  // --- Trust Score (animated gauge) ---
@@ -426,6 +451,39 @@ async function loadDashboardData() {
426
451
  badge.classList.add('flex');
427
452
  }
428
453
  }
454
+
455
+ // --- Engineering loop narrative ---
456
+ const toneClass = tone => {
457
+ if (tone === 'critical') return 'text-red-400';
458
+ if (tone === 'elevated' || tone === 'watch') return 'text-amber-400';
459
+ return 'text-emerald-400';
460
+ };
461
+ const renderNarrativeList = (id, items, emptyText) => {
462
+ const node = document.getElementById(id);
463
+ if (!node) return;
464
+ if (!items || items.length === 0) {
465
+ node.innerHTML = `<li class="text-xs text-slate-600 py-1">${escapeHtml(emptyText)}</li>`;
466
+ return;
467
+ }
468
+ node.innerHTML = items.map(item => `
469
+ <li class="border-b border-slate-800/30 pb-2 last:border-0 last:pb-0">
470
+ <div class="flex items-center justify-between gap-2">
471
+ <span class="text-xs text-slate-300">${escapeHtml(item.title || '--')}</span>
472
+ <span class="text-[10px] font-mono ${toneClass(item.tone)}">${escapeHtml(item.detail || '')}</span>
473
+ </div>
474
+ ${item.meta ? `<div class="text-[10px] text-slate-600 mt-1">${escapeHtml(item.meta)}</div>` : ''}
475
+ </li>
476
+ `).join('');
477
+ };
478
+ if (engineeringData && !engineeringData.error) {
479
+ renderNarrativeList('matters-now-list', engineeringData.matters_now, 'No active pressure detected');
480
+ renderNarrativeList('drifting-list', engineeringData.drifting, 'No major drift detected');
481
+ renderNarrativeList('improving-list', engineeringData.improving, 'No improvement deltas yet');
482
+ } else {
483
+ renderNarrativeList('matters-now-list', [], 'No periodic summary available');
484
+ renderNarrativeList('drifting-list', [], 'No periodic summary available');
485
+ renderNarrativeList('improving-list', [], 'No periodic summary available');
486
+ }
429
487
  }
430
488
 
431
489
  loadDashboardData();
@@ -0,0 +1,199 @@
1
+ {% extends "base.html" %}
2
+
3
+ {% block title %}Protocol Explainability{% endblock %}
4
+
5
+ {% block page_title %}Protocol Explainability{% endblock %}
6
+ {% block page_subtitle %}
7
+ <span class="text-xs text-slate-500 ml-2">Why the runtime acted, blocked, drifted, or asked for evidence</span>
8
+ {% endblock %}
9
+
10
+ {% block header_actions %}
11
+ <a href="/api/protocol" class="text-xs text-slate-400 hover:text-slate-200 transition-colors px-2 py-1 rounded hover:bg-slate-800 font-mono">
12
+ /api/protocol
13
+ </a>
14
+ {% endblock %}
15
+
16
+ {% block content %}
17
+ {% set compliance = snapshot.protocol_summary or {} %}
18
+ {% set overall = compliance.overall_compliance_pct or 0 %}
19
+ {% set debt = snapshot.debt_summary or {} %}
20
+ {% set workflow = snapshot.workflow_summary or {} %}
21
+ {% set goals = snapshot.goal_summary or {} %}
22
+ {% set conditioned = snapshot.conditioned_learnings or [] %}
23
+
24
+ <div class="grid grid-cols-5 gap-4 mb-6">
25
+ <div class="bg-slate-900/50 border border-slate-800/50 rounded-xl p-5 card">
26
+ <p class="text-xs uppercase tracking-wider text-slate-500 font-semibold mb-1">Compliance 7d</p>
27
+ <p class="text-2xl font-display font-bold {{ 'text-emerald-400' if overall >= 80 else 'text-amber-400' if overall >= 60 else 'text-red-400' }}">{{ "%.1f"|format(overall) }}%</p>
28
+ </div>
29
+ <div class="bg-slate-900/50 border border-slate-800/50 rounded-xl p-5 card">
30
+ <p class="text-xs uppercase tracking-wider text-slate-500 font-semibold mb-1">Open Debt</p>
31
+ <p class="text-2xl font-display font-bold {{ 'text-emerald-400' if (debt.open_total or 0) == 0 else 'text-red-400' }}">{{ debt.open_total or 0 }}</p>
32
+ </div>
33
+ <div class="bg-slate-900/50 border border-slate-800/50 rounded-xl p-5 card">
34
+ <p class="text-xs uppercase tracking-wider text-slate-500 font-semibold mb-1">Open Runs</p>
35
+ <p class="text-2xl font-display font-bold text-slate-100">{{ workflow.open_runs or 0 }}</p>
36
+ </div>
37
+ <div class="bg-slate-900/50 border border-slate-800/50 rounded-xl p-5 card">
38
+ <p class="text-xs uppercase tracking-wider text-slate-500 font-semibold mb-1">Blocked Goals</p>
39
+ <p class="text-2xl font-display font-bold {{ 'text-red-400' if (goals.blocked or 0) else 'text-emerald-400' }}">{{ goals.blocked or 0 }}</p>
40
+ </div>
41
+ <div class="bg-slate-900/50 border border-slate-800/50 rounded-xl p-5 card">
42
+ <p class="text-xs uppercase tracking-wider text-slate-500 font-semibold mb-1">Conditioned Files</p>
43
+ <p class="text-2xl font-display font-bold text-nexo-400">{{ conditioned|length }}</p>
44
+ </div>
45
+ </div>
46
+
47
+ <div class="grid grid-cols-2 gap-5 mb-6">
48
+ <section class="bg-slate-900/50 border border-slate-800/50 rounded-xl p-5 card">
49
+ <h2 class="text-xs uppercase tracking-wider text-slate-500 font-semibold mb-4">Protocol Requirements</h2>
50
+ <div class="space-y-3">
51
+ {% for key, label in [('guard_check', 'Guard before action'), ('heartbeat', 'Heartbeat with context'), ('change_log', 'Change log after edits'), ('learning_capture', 'Learning capture after correction'), ('done_evidence', 'Evidence before done')] %}
52
+ {% set item = compliance.get(key, {}) %}
53
+ <div class="flex items-center justify-between gap-4">
54
+ <div>
55
+ <p class="text-sm text-slate-200">{{ label }}</p>
56
+ <p class="text-[11px] text-slate-500 font-mono">
57
+ {% if item.required is defined %}required {{ item.required }}{% endif %}
58
+ {% if item.total is defined %}total {{ item.total }}{% endif %}
59
+ {% if item.executed is defined %} • executed {{ item.executed }}{% endif %}
60
+ {% if item.logged is defined %} • logged {{ item.logged }}{% endif %}
61
+ {% if item.captured is defined %} • captured {{ item.captured }}{% endif %}
62
+ {% if item.done_tasks is defined %} • done {{ item.done_tasks }}{% endif %}
63
+ </p>
64
+ </div>
65
+ <span class="text-sm font-mono {{ 'text-emerald-400' if (item.compliance_pct or 0) >= 80 else 'text-amber-400' if (item.compliance_pct or 0) >= 60 else 'text-red-400' }}">
66
+ {{ "%.1f"|format(item.compliance_pct or 0) }}%
67
+ </span>
68
+ </div>
69
+ {% endfor %}
70
+ </div>
71
+ </section>
72
+
73
+ <section class="bg-slate-900/50 border border-slate-800/50 rounded-xl p-5 card">
74
+ <h2 class="text-xs uppercase tracking-wider text-slate-500 font-semibold mb-4">Debt Pressure</h2>
75
+ <div class="grid grid-cols-3 gap-3 mb-4">
76
+ {% for severity in ['error', 'warn', 'info'] %}
77
+ <div class="rounded-lg border border-slate-800/60 bg-slate-950/50 p-3">
78
+ <p class="text-[10px] uppercase tracking-wider text-slate-500">{{ severity }}</p>
79
+ <p class="text-xl font-display font-bold text-slate-100">{{ debt.by_severity.get(severity, 0) }}</p>
80
+ </div>
81
+ {% endfor %}
82
+ </div>
83
+ <div class="space-y-2 max-h-56 overflow-y-auto pr-1">
84
+ {% for item in snapshot.recent_debts[:10] %}
85
+ <div class="rounded-lg border border-slate-800/60 bg-slate-950/50 p-3">
86
+ <div class="flex items-center justify-between gap-3 mb-1">
87
+ <span class="text-xs font-semibold {{ 'text-red-400' if item.severity == 'error' else 'text-amber-400' if item.severity == 'warn' else 'text-slate-400' }}">{{ item.debt_type }}</span>
88
+ <span class="text-[10px] text-slate-500 font-mono">{{ item.status }}</span>
89
+ </div>
90
+ <p class="text-xs text-slate-300">{{ item.evidence or 'No evidence summary recorded.' }}</p>
91
+ </div>
92
+ {% else %}
93
+ <p class="text-sm text-slate-500">No protocol debt recorded.</p>
94
+ {% endfor %}
95
+ </div>
96
+ </section>
97
+ </div>
98
+
99
+ <div class="grid grid-cols-2 gap-5 mb-6">
100
+ <section class="bg-slate-900/50 border border-slate-800/50 rounded-xl p-5 card">
101
+ <h2 class="text-xs uppercase tracking-wider text-slate-500 font-semibold mb-4">Recent Protocol Tasks</h2>
102
+ <div class="space-y-3 max-h-[420px] overflow-y-auto pr-1">
103
+ {% for item in snapshot.recent_tasks[:12] %}
104
+ <div class="rounded-lg border border-slate-800/60 bg-slate-950/50 p-3">
105
+ <div class="flex items-center justify-between gap-3 mb-1">
106
+ <p class="text-sm text-slate-100">{{ item.goal }}</p>
107
+ <span class="text-[10px] font-mono px-2 py-0.5 rounded bg-slate-800 text-slate-400">{{ item.status }}</span>
108
+ </div>
109
+ <p class="text-[11px] text-slate-500 font-mono mb-2">{{ item.task_type }} • cortex={{ item.cortex_mode or 'n/a' }} • response={{ item.response_mode or 'n/a' }}</p>
110
+ <div class="flex flex-wrap gap-2 text-[10px]">
111
+ {% if item.guarded_open %}<span class="px-2 py-0.5 rounded bg-nexo-500/15 text-nexo-300">guarded open</span>{% endif %}
112
+ {% if item.must_verify %}<span class="px-2 py-0.5 rounded bg-amber-500/15 text-amber-300">must verify</span>{% endif %}
113
+ {% if item.must_change_log %}<span class="px-2 py-0.5 rounded bg-blue-500/15 text-blue-300">must change-log</span>{% endif %}
114
+ {% if item.has_evidence %}<span class="px-2 py-0.5 rounded bg-emerald-500/15 text-emerald-300">evidence attached</span>{% endif %}
115
+ {% if item.guard_has_blocking %}<span class="px-2 py-0.5 rounded bg-red-500/15 text-red-300">blocking risk</span>{% endif %}
116
+ </div>
117
+ </div>
118
+ {% else %}
119
+ <p class="text-sm text-slate-500">No protocol tasks yet.</p>
120
+ {% endfor %}
121
+ </div>
122
+ </section>
123
+
124
+ <section class="bg-slate-900/50 border border-slate-800/50 rounded-xl p-5 card">
125
+ <h2 class="text-xs uppercase tracking-wider text-slate-500 font-semibold mb-4">Durable Workflow State</h2>
126
+ <div class="grid grid-cols-3 gap-3 mb-4">
127
+ <div class="rounded-lg border border-slate-800/60 bg-slate-950/50 p-3">
128
+ <p class="text-[10px] uppercase tracking-wider text-slate-500">Open Runs</p>
129
+ <p class="text-xl font-display font-bold text-slate-100">{{ workflow.open_runs or 0 }}</p>
130
+ </div>
131
+ <div class="rounded-lg border border-slate-800/60 bg-slate-950/50 p-3">
132
+ <p class="text-[10px] uppercase tracking-wider text-slate-500">Blocked Runs</p>
133
+ <p class="text-xl font-display font-bold {{ 'text-red-400' if (workflow.blocked_runs or 0) else 'text-slate-100' }}">{{ workflow.blocked_runs or 0 }}</p>
134
+ </div>
135
+ <div class="rounded-lg border border-slate-800/60 bg-slate-950/50 p-3">
136
+ <p class="text-[10px] uppercase tracking-wider text-slate-500">Waiting Approval</p>
137
+ <p class="text-xl font-display font-bold {{ 'text-amber-400' if (workflow.waiting_approval or 0) else 'text-slate-100' }}">{{ workflow.waiting_approval or 0 }}</p>
138
+ </div>
139
+ </div>
140
+ <div class="space-y-3 max-h-[420px] overflow-y-auto pr-1">
141
+ {% for item in snapshot.recent_runs[:10] %}
142
+ <div class="rounded-lg border border-slate-800/60 bg-slate-950/50 p-3">
143
+ <div class="flex items-center justify-between gap-3 mb-1">
144
+ <p class="text-sm text-slate-100">{{ item.goal }}</p>
145
+ <span class="text-[10px] font-mono px-2 py-0.5 rounded bg-slate-800 text-slate-400">{{ item.status }}</span>
146
+ </div>
147
+ <p class="text-[11px] text-slate-500 font-mono">{{ item.workflow_kind }}{% if item.current_step_key %} • step={{ item.current_step_key }}{% endif %}{% if item.next_action %} • next={{ item.next_action }}{% endif %}</p>
148
+ </div>
149
+ {% else %}
150
+ <p class="text-sm text-slate-500">No workflow runs recorded.</p>
151
+ {% endfor %}
152
+ </div>
153
+ </section>
154
+ </div>
155
+
156
+ <div class="grid grid-cols-2 gap-5">
157
+ <section class="bg-slate-900/50 border border-slate-800/50 rounded-xl p-5 card">
158
+ <h2 class="text-xs uppercase tracking-wider text-slate-500 font-semibold mb-4">Conditioned Learnings</h2>
159
+ <div class="space-y-3 max-h-[420px] overflow-y-auto pr-1">
160
+ {% for item in conditioned[:12] %}
161
+ <div class="rounded-lg border border-slate-800/60 bg-slate-950/50 p-3">
162
+ <div class="flex items-center justify-between gap-3 mb-1">
163
+ <p class="text-sm text-slate-100">{{ item.title }}</p>
164
+ <span class="text-[10px] font-mono px-2 py-0.5 rounded bg-slate-800 text-slate-400">{{ item.priority or 'normal' }}</span>
165
+ </div>
166
+ <p class="text-xs text-slate-400 break-all">{{ item.applies_to }}</p>
167
+ <p class="text-[11px] text-slate-500 font-mono mt-1">guard_hits={{ item.guard_hits or 0 }} • weight={{ "%.2f"|format(item.weight or 0) }}</p>
168
+ </div>
169
+ {% else %}
170
+ <p class="text-sm text-slate-500">No conditioned learnings active.</p>
171
+ {% endfor %}
172
+ </div>
173
+ </section>
174
+
175
+ <section class="bg-slate-900/50 border border-slate-800/50 rounded-xl p-5 card">
176
+ <h2 class="text-xs uppercase tracking-wider text-slate-500 font-semibold mb-4">Guard Pressure</h2>
177
+ <div class="grid grid-cols-2 gap-3 mb-4">
178
+ <div class="rounded-lg border border-slate-800/60 bg-slate-950/50 p-3">
179
+ <p class="text-[10px] uppercase tracking-wider text-slate-500">Recent Checks</p>
180
+ <p class="text-xl font-display font-bold text-slate-100">{{ snapshot.guard_summary.recent_checks or 0 }}</p>
181
+ </div>
182
+ <div class="rounded-lg border border-slate-800/60 bg-slate-950/50 p-3">
183
+ <p class="text-[10px] uppercase tracking-wider text-slate-500">Blocking Hits</p>
184
+ <p class="text-xl font-display font-bold {{ 'text-red-400' if (snapshot.guard_summary.blocking_hits or 0) else 'text-slate-100' }}">{{ snapshot.guard_summary.blocking_hits or 0 }}</p>
185
+ </div>
186
+ </div>
187
+ <div class="space-y-2">
188
+ {% for area, count in snapshot.guard_summary.areas.items() %}
189
+ <div class="flex items-center justify-between rounded-lg border border-slate-800/60 bg-slate-950/50 px-3 py-2">
190
+ <span class="text-sm text-slate-300">{{ area }}</span>
191
+ <span class="text-xs font-mono text-slate-500">{{ count }} checks</span>
192
+ </div>
193
+ {% else %}
194
+ <p class="text-sm text-slate-500">No guard pressure recorded.</p>
195
+ {% endfor %}
196
+ </div>
197
+ </section>
198
+ </div>
199
+ {% endblock %}
@@ -44,7 +44,7 @@ from db._reminders import (
44
44
 
45
45
  # Learnings
46
46
  from db._learnings import (
47
- create_learning, update_learning, delete_learning,
47
+ create_learning, update_learning, supersede_learning, delete_learning,
48
48
  search_learnings, list_learnings,
49
49
  extract_keywords, find_similar_learnings,
50
50
  )
@@ -93,6 +93,28 @@ from db._cron_runs import (
93
93
  cron_run_start, cron_run_end, cron_runs_recent, cron_runs_summary,
94
94
  )
95
95
 
96
+ # Protocol discipline runtime
97
+ from db._protocol import (
98
+ create_protocol_task, get_protocol_task, close_protocol_task,
99
+ create_protocol_debt, resolve_protocol_debts, list_protocol_debts,
100
+ protocol_compliance_summary,
101
+ )
102
+
103
+ # Durable workflow runtime
104
+ from db._workflow import (
105
+ create_workflow_run, get_workflow_run, list_workflow_runs,
106
+ list_workflow_steps, record_workflow_transition,
107
+ get_workflow_replay, get_workflow_resume_state,
108
+ create_workflow_goal, get_workflow_goal, list_workflow_goals,
109
+ update_workflow_goal,
110
+ )
111
+
112
+ # State watchers
113
+ from db._watchers import (
114
+ create_state_watcher, get_state_watcher, list_state_watchers,
115
+ update_state_watcher, update_state_watcher_result,
116
+ )
117
+
96
118
  # Personal scripts registry
97
119
  from db._personal_scripts import (
98
120
  upsert_personal_script, list_personal_scripts, get_personal_script,
@@ -13,6 +13,7 @@ def create_learning(
13
13
  reasoning: str = '',
14
14
  prevention: str = '',
15
15
  applies_to: str = '',
16
+ supersedes_id: int | None = None,
16
17
  status: str = 'active',
17
18
  review_due_at: float | None = None,
18
19
  last_reviewed_at: float | None = None,
@@ -22,11 +23,11 @@ def create_learning(
22
23
  now = now_epoch()
23
24
  cursor = conn.execute(
24
25
  "INSERT INTO learnings "
25
- "(category, title, content, reasoning, prevention, applies_to, status, review_due_at, last_reviewed_at, created_at, updated_at) "
26
- "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
26
+ "(category, title, content, reasoning, prevention, applies_to, supersedes_id, status, review_due_at, last_reviewed_at, created_at, updated_at) "
27
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
27
28
  (
28
29
  category, title, content, reasoning, prevention, applies_to,
29
- status, review_due_at, last_reviewed_at, now, now,
30
+ supersedes_id, status, review_due_at, last_reviewed_at, now, now,
30
31
  )
31
32
  )
32
33
  conn.commit()
@@ -60,6 +61,33 @@ def update_learning(id: int, **kwargs) -> dict:
60
61
  return r
61
62
 
62
63
 
64
+ def supersede_learning(old_id: int, new_id: int, note: str = '') -> dict:
65
+ """Mark an older learning as superseded by a newer canonical learning."""
66
+ conn = get_db()
67
+ old_row = conn.execute("SELECT * FROM learnings WHERE id = ?", (old_id,)).fetchone()
68
+ new_row = conn.execute("SELECT * FROM learnings WHERE id = ?", (new_id,)).fetchone()
69
+ if not old_row:
70
+ return {"error": f"Learning {old_id} not found"}
71
+ if not new_row:
72
+ return {"error": f"Learning {new_id} not found"}
73
+
74
+ old_reasoning = str(old_row["reasoning"] or "")
75
+ suffix = note.strip() if note.strip() else f"Superseded by learning #{new_id}."
76
+ combined_reasoning = f"{old_reasoning}\n{suffix}".strip() if old_reasoning else suffix
77
+ updated_at = now_epoch()
78
+ conn.execute(
79
+ "UPDATE learnings SET status = 'superseded', updated_at = ?, reasoning = ? WHERE id = ?",
80
+ (updated_at, combined_reasoning, old_id),
81
+ )
82
+ conn.execute(
83
+ "UPDATE learnings SET supersedes_id = ?, updated_at = ? WHERE id = ?",
84
+ (old_id, updated_at, new_id),
85
+ )
86
+ conn.commit()
87
+ row = conn.execute("SELECT * FROM learnings WHERE id = ?", (old_id,)).fetchone()
88
+ return dict(row)
89
+
90
+
63
91
  def delete_learning(id: int) -> bool:
64
92
  """Delete a learning entry."""
65
93
  conn = get_db()
@@ -166,4 +194,3 @@ def find_similar_learnings(new_id: int, title: str, content: str, category: str)
166
194
  results.sort(key=lambda x: x[1], reverse=True)
167
195
  return results[:5]
168
196
 
169
-
@@ -517,6 +517,18 @@ def get_personal_script_health_report(*, fix: bool = False) -> dict:
517
517
 
518
518
  for schedule in audit.get("schedules", []):
519
519
  checked += 1
520
+ if schedule.get("schedule_managed") and schedule.get("schedule_type") == "keep_alive":
521
+ runtime_state = str(schedule.get("runtime_state", "") or "")
522
+ runtime_summary = str(schedule.get("runtime_summary", "") or runtime_state or "runtime issue")
523
+ if runtime_state in {"degraded", "stale", "duplicated"}:
524
+ severity = "error" if runtime_state == "duplicated" else "warn"
525
+ issues.append({
526
+ "script_id": schedule.get("script_name") or schedule.get("script_path") or schedule.get("cron_id"),
527
+ "severity": severity,
528
+ "message": (
529
+ f"keep_alive runtime {schedule['cron_id']}: {runtime_summary}"
530
+ ),
531
+ })
520
532
  if schedule.get("schedule_managed"):
521
533
  continue
522
534