nexo-brain 3.0.2 → 3.1.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 +62 -0
- package/community/launch/2026-04-v3-0-2/case-study-outreach.md +36 -0
- package/community/launch/2026-04-v3-0-2/devto-v3-0-2.md +91 -0
- package/community/launch/2026-04-v3-0-2/github-discussion-v3-0-2.md +58 -0
- package/community/launch/2026-04-v3-0-2/x-thread-v3-0-2.md +60 -0
- package/hooks/hooks.json +12 -0
- package/package.json +1 -1
- package/src/client_sync.py +6 -0
- package/src/doctor/providers/runtime.py +10 -5
- package/src/evolution_cycle.py +28 -1
- package/src/hook_guardrails.py +182 -0
- package/src/hooks/protocol-guardrail.sh +1 -1
- package/src/hooks/protocol-pretool-guardrail.sh +9 -0
- package/src/plugins/protocol.py +15 -0
- package/src/protocol_settings.py +59 -0
- package/src/public_evolution_queue.py +241 -0
- package/src/scripts/nexo-daily-self-audit.py +665 -27
- package/src/scripts/nexo-evolution-run.py +35 -1
- package/src/server.py +26 -1
|
@@ -19,7 +19,9 @@ Runs via launchd at 7:00 AM daily.
|
|
|
19
19
|
import json
|
|
20
20
|
import hashlib
|
|
21
21
|
import os
|
|
22
|
+
import py_compile
|
|
22
23
|
import re
|
|
24
|
+
import shutil
|
|
23
25
|
import sqlite3
|
|
24
26
|
import subprocess
|
|
25
27
|
import sys
|
|
@@ -35,6 +37,7 @@ if str(NEXO_CODE) not in sys.path:
|
|
|
35
37
|
sys.path.insert(0, str(NEXO_CODE))
|
|
36
38
|
|
|
37
39
|
from agent_runner import AutomationBackendUnavailableError, run_automation_prompt
|
|
40
|
+
from public_evolution_queue import queue_public_port_candidate
|
|
38
41
|
|
|
39
42
|
LOG_DIR = NEXO_HOME / "logs"
|
|
40
43
|
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
|
@@ -226,6 +229,7 @@ def _ensure_followup(conn: sqlite3.Connection, *, prefix: str, description: str,
|
|
|
226
229
|
verification: str, reasoning: str, priority: str = "high") -> str:
|
|
227
230
|
if not _table_exists(conn, "followups"):
|
|
228
231
|
return ""
|
|
232
|
+
followup_id = f"NF-{prefix}-{hashlib.sha1(description.encode('utf-8')).hexdigest()[:8].upper()}"
|
|
229
233
|
existing = conn.execute(
|
|
230
234
|
"""SELECT id FROM followups
|
|
231
235
|
WHERE status NOT LIKE 'COMPLETED%'
|
|
@@ -236,10 +240,33 @@ def _ensure_followup(conn: sqlite3.Connection, *, prefix: str, description: str,
|
|
|
236
240
|
).fetchone()
|
|
237
241
|
if existing:
|
|
238
242
|
return str(existing["id"])
|
|
239
|
-
|
|
240
|
-
followup_id = f"NF-{prefix}-{hashlib.sha1(description.encode('utf-8')).hexdigest()[:8].upper()}"
|
|
241
243
|
now_epoch = int(datetime.now().timestamp())
|
|
242
244
|
columns = {row["name"] for row in conn.execute("PRAGMA table_info(followups)").fetchall()}
|
|
245
|
+
existing_id_row = conn.execute(
|
|
246
|
+
"SELECT id, status FROM followups WHERE id = ? LIMIT 1",
|
|
247
|
+
(followup_id,),
|
|
248
|
+
).fetchone()
|
|
249
|
+
if existing_id_row:
|
|
250
|
+
update_fields = {
|
|
251
|
+
"description": description,
|
|
252
|
+
"verification": verification,
|
|
253
|
+
"reasoning": reasoning,
|
|
254
|
+
"updated_at": now_epoch,
|
|
255
|
+
}
|
|
256
|
+
if "priority" in columns:
|
|
257
|
+
update_fields["priority"] = priority
|
|
258
|
+
closed_status = str(existing_id_row["status"] or "").upper()
|
|
259
|
+
if closed_status.startswith("COMPLETED") or closed_status in {"DELETED", "ARCHIVED", "BLOCKED", "WAITING"}:
|
|
260
|
+
update_fields["status"] = "PENDING"
|
|
261
|
+
ordered_updates = [name for name in update_fields.keys() if name in columns]
|
|
262
|
+
if ordered_updates:
|
|
263
|
+
assignments = ", ".join(f"{name} = ?" for name in ordered_updates)
|
|
264
|
+
conn.execute(
|
|
265
|
+
f"UPDATE followups SET {assignments} WHERE id = ?",
|
|
266
|
+
[update_fields[name] for name in ordered_updates] + [followup_id],
|
|
267
|
+
)
|
|
268
|
+
return followup_id
|
|
269
|
+
|
|
243
270
|
values = {
|
|
244
271
|
"id": followup_id,
|
|
245
272
|
"date": "",
|
|
@@ -263,6 +290,301 @@ def _ensure_followup(conn: sqlite3.Connection, *, prefix: str, description: str,
|
|
|
263
290
|
return followup_id
|
|
264
291
|
|
|
265
292
|
|
|
293
|
+
def _table_columns(conn: sqlite3.Connection, table_name: str) -> set[str]:
|
|
294
|
+
try:
|
|
295
|
+
rows = conn.execute(f"PRAGMA table_info({table_name})").fetchall()
|
|
296
|
+
except Exception:
|
|
297
|
+
return set()
|
|
298
|
+
columns: set[str] = set()
|
|
299
|
+
for row in rows:
|
|
300
|
+
if isinstance(row, sqlite3.Row):
|
|
301
|
+
columns.add(str(row["name"]))
|
|
302
|
+
elif len(row) > 1:
|
|
303
|
+
columns.add(str(row[1]))
|
|
304
|
+
return columns
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _append_note(existing: str, note: str) -> str:
|
|
308
|
+
current = str(existing or "").strip()
|
|
309
|
+
extra = str(note or "").strip()
|
|
310
|
+
if not extra:
|
|
311
|
+
return current
|
|
312
|
+
if not current:
|
|
313
|
+
return extra
|
|
314
|
+
if extra in current:
|
|
315
|
+
return current
|
|
316
|
+
return f"{current}\n{extra}"
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _complete_matching_followup(conn: sqlite3.Connection, description: str, note: str) -> int:
|
|
320
|
+
if not _table_exists(conn, "followups"):
|
|
321
|
+
return 0
|
|
322
|
+
columns = _table_columns(conn, "followups")
|
|
323
|
+
rows = conn.execute(
|
|
324
|
+
"""SELECT id, verification, reasoning
|
|
325
|
+
FROM followups
|
|
326
|
+
WHERE description = ?
|
|
327
|
+
AND status NOT LIKE 'COMPLETED%'
|
|
328
|
+
AND status NOT IN ('DELETED','archived','blocked','waiting')""",
|
|
329
|
+
(description,),
|
|
330
|
+
).fetchall()
|
|
331
|
+
completed = 0
|
|
332
|
+
now_epoch = datetime.now().timestamp()
|
|
333
|
+
for row in rows:
|
|
334
|
+
updates = {"status": "COMPLETED"}
|
|
335
|
+
if "updated_at" in columns:
|
|
336
|
+
updates["updated_at"] = now_epoch
|
|
337
|
+
if "verification" in columns:
|
|
338
|
+
updates["verification"] = _append_note(row["verification"], note)
|
|
339
|
+
if "reasoning" in columns:
|
|
340
|
+
updates["reasoning"] = _append_note(row["reasoning"], note)
|
|
341
|
+
assignments = ", ".join(f"{column} = ?" for column in updates)
|
|
342
|
+
conn.execute(
|
|
343
|
+
f"UPDATE followups SET {assignments} WHERE id = ?",
|
|
344
|
+
[updates[column] for column in updates] + [row["id"]],
|
|
345
|
+
)
|
|
346
|
+
completed += 1
|
|
347
|
+
return completed
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _upsert_inline_learning(
|
|
351
|
+
conn: sqlite3.Connection,
|
|
352
|
+
*,
|
|
353
|
+
category: str,
|
|
354
|
+
title: str,
|
|
355
|
+
content: str,
|
|
356
|
+
reasoning: str = "",
|
|
357
|
+
prevention: str = "",
|
|
358
|
+
applies_to: str = "",
|
|
359
|
+
priority: str = "high",
|
|
360
|
+
) -> dict:
|
|
361
|
+
if not _table_exists(conn, "learnings"):
|
|
362
|
+
return {"ok": False, "reason": "learnings_missing"}
|
|
363
|
+
|
|
364
|
+
columns = _table_columns(conn, "learnings")
|
|
365
|
+
rows = conn.execute(
|
|
366
|
+
"SELECT * FROM learnings WHERE COALESCE(status, 'active') != 'superseded' ORDER BY updated_at DESC, id DESC LIMIT 200"
|
|
367
|
+
).fetchall()
|
|
368
|
+
target_signature = _topic_signature(f"{title} {content}")
|
|
369
|
+
existing = None
|
|
370
|
+
for row in rows:
|
|
371
|
+
row_title = str(row["title"] or "").strip() if "title" in columns else ""
|
|
372
|
+
row_content = str(row["content"] or "").strip() if "content" in columns else ""
|
|
373
|
+
row_applies = str(row["applies_to"] or "").strip() if "applies_to" in columns else ""
|
|
374
|
+
row_category = str(row["category"] or "").strip() if "category" in columns else ""
|
|
375
|
+
if applies_to and row_applies and row_applies == applies_to:
|
|
376
|
+
existing = row
|
|
377
|
+
break
|
|
378
|
+
if row_title == title:
|
|
379
|
+
existing = row
|
|
380
|
+
break
|
|
381
|
+
if target_signature and _topic_signature(f"{row_title} {row_content}") == target_signature:
|
|
382
|
+
if not row_category or row_category == category:
|
|
383
|
+
existing = row
|
|
384
|
+
break
|
|
385
|
+
|
|
386
|
+
now_epoch = datetime.now().timestamp()
|
|
387
|
+
weight_map = {"critical": 0.9, "high": 0.7, "medium": 0.5, "low": 0.3}
|
|
388
|
+
if existing:
|
|
389
|
+
updates: dict[str, object] = {}
|
|
390
|
+
if "category" in columns and category:
|
|
391
|
+
updates["category"] = category
|
|
392
|
+
if "title" in columns:
|
|
393
|
+
updates["title"] = title
|
|
394
|
+
if "content" in columns:
|
|
395
|
+
updates["content"] = content
|
|
396
|
+
if "reasoning" in columns and reasoning:
|
|
397
|
+
updates["reasoning"] = _append_note(existing["reasoning"], reasoning)
|
|
398
|
+
if "prevention" in columns and prevention:
|
|
399
|
+
updates["prevention"] = prevention
|
|
400
|
+
if "applies_to" in columns and applies_to:
|
|
401
|
+
updates["applies_to"] = applies_to
|
|
402
|
+
if "priority" in columns and priority:
|
|
403
|
+
updates["priority"] = priority
|
|
404
|
+
if "weight" in columns and priority:
|
|
405
|
+
updates["weight"] = weight_map.get(priority, 0.5)
|
|
406
|
+
if "status" in columns:
|
|
407
|
+
updates["status"] = "active"
|
|
408
|
+
if "updated_at" in columns:
|
|
409
|
+
updates["updated_at"] = now_epoch
|
|
410
|
+
assignments = ", ".join(f"{column} = ?" for column in updates)
|
|
411
|
+
conn.execute(
|
|
412
|
+
f"UPDATE learnings SET {assignments} WHERE id = ?",
|
|
413
|
+
[updates[column] for column in updates] + [existing["id"]],
|
|
414
|
+
)
|
|
415
|
+
return {"ok": True, "action": "updated", "learning_id": int(existing["id"])}
|
|
416
|
+
|
|
417
|
+
values: dict[str, object] = {}
|
|
418
|
+
if "category" in columns:
|
|
419
|
+
values["category"] = category or "nexo-ops"
|
|
420
|
+
if "title" in columns:
|
|
421
|
+
values["title"] = title
|
|
422
|
+
if "content" in columns:
|
|
423
|
+
values["content"] = content
|
|
424
|
+
if "reasoning" in columns:
|
|
425
|
+
values["reasoning"] = reasoning
|
|
426
|
+
if "prevention" in columns:
|
|
427
|
+
values["prevention"] = prevention
|
|
428
|
+
if "applies_to" in columns and applies_to:
|
|
429
|
+
values["applies_to"] = applies_to
|
|
430
|
+
if "priority" in columns and priority:
|
|
431
|
+
values["priority"] = priority
|
|
432
|
+
if "weight" in columns and priority:
|
|
433
|
+
values["weight"] = weight_map.get(priority, 0.5)
|
|
434
|
+
if "status" in columns:
|
|
435
|
+
values["status"] = "active"
|
|
436
|
+
if "created_at" in columns:
|
|
437
|
+
values["created_at"] = now_epoch
|
|
438
|
+
if "updated_at" in columns:
|
|
439
|
+
values["updated_at"] = now_epoch
|
|
440
|
+
placeholders = ", ".join("?" for _ in values)
|
|
441
|
+
conn.execute(
|
|
442
|
+
f"INSERT INTO learnings ({', '.join(values)}) VALUES ({placeholders})",
|
|
443
|
+
list(values.values()),
|
|
444
|
+
)
|
|
445
|
+
learning_id = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
|
|
446
|
+
return {"ok": True, "action": "created", "learning_id": int(learning_id)}
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def _supersede_learning_inline(conn: sqlite3.Connection, *, keep_id: int, retire_id: int, note: str) -> bool:
|
|
450
|
+
if not _table_exists(conn, "learnings"):
|
|
451
|
+
return False
|
|
452
|
+
columns = _table_columns(conn, "learnings")
|
|
453
|
+
now_epoch = datetime.now().timestamp()
|
|
454
|
+
retire_row = conn.execute("SELECT * FROM learnings WHERE id = ?", (retire_id,)).fetchone()
|
|
455
|
+
keep_row = conn.execute("SELECT * FROM learnings WHERE id = ?", (keep_id,)).fetchone()
|
|
456
|
+
if not retire_row or not keep_row:
|
|
457
|
+
return False
|
|
458
|
+
|
|
459
|
+
retire_updates: dict[str, object] = {}
|
|
460
|
+
if "status" in columns:
|
|
461
|
+
retire_updates["status"] = "superseded"
|
|
462
|
+
if "reasoning" in columns:
|
|
463
|
+
retire_updates["reasoning"] = _append_note(retire_row["reasoning"], note)
|
|
464
|
+
if "updated_at" in columns:
|
|
465
|
+
retire_updates["updated_at"] = now_epoch
|
|
466
|
+
if retire_updates:
|
|
467
|
+
retire_assignments = ", ".join(f"{column} = ?" for column in retire_updates)
|
|
468
|
+
conn.execute(
|
|
469
|
+
f"UPDATE learnings SET {retire_assignments} WHERE id = ?",
|
|
470
|
+
[retire_updates[column] for column in retire_updates] + [retire_id],
|
|
471
|
+
)
|
|
472
|
+
|
|
473
|
+
keep_updates: dict[str, object] = {}
|
|
474
|
+
if "supersedes_id" in columns:
|
|
475
|
+
keep_updates["supersedes_id"] = retire_id
|
|
476
|
+
if "updated_at" in columns:
|
|
477
|
+
keep_updates["updated_at"] = now_epoch
|
|
478
|
+
if keep_updates:
|
|
479
|
+
keep_assignments = ", ".join(f"{column} = ?" for column in keep_updates)
|
|
480
|
+
conn.execute(
|
|
481
|
+
f"UPDATE learnings SET {keep_assignments} WHERE id = ?",
|
|
482
|
+
[keep_updates[column] for column in keep_updates] + [keep_id],
|
|
483
|
+
)
|
|
484
|
+
return True
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def _upsert_workflow_goal_inline(conn: sqlite3.Connection, *, area: str, sample_goal: str, count: int) -> dict:
|
|
488
|
+
if not _table_exists(conn, "workflow_goals"):
|
|
489
|
+
return {"ok": False, "reason": "workflow_goals_missing"}
|
|
490
|
+
|
|
491
|
+
columns = _table_columns(conn, "workflow_goals")
|
|
492
|
+
signature = _topic_signature(sample_goal)
|
|
493
|
+
rows = conn.execute(
|
|
494
|
+
"""SELECT * FROM workflow_goals
|
|
495
|
+
WHERE status NOT IN ('completed', 'cancelled', 'abandoned')
|
|
496
|
+
ORDER BY updated_at DESC"""
|
|
497
|
+
).fetchall()
|
|
498
|
+
existing = None
|
|
499
|
+
for row in rows:
|
|
500
|
+
title = str(row["title"] or "")
|
|
501
|
+
objective = str(row["objective"] or "")
|
|
502
|
+
if signature and signature == _topic_signature(f"{title} {objective}"):
|
|
503
|
+
existing = row
|
|
504
|
+
break
|
|
505
|
+
|
|
506
|
+
objective = (
|
|
507
|
+
f"Recurring {area} theme detected by daily self-audit. "
|
|
508
|
+
f"The theme '{sample_goal}' appeared {count} times without a durable goal, learning, or resolved workflow."
|
|
509
|
+
)
|
|
510
|
+
next_action = "Convert the recurring theme into an explicit workflow or close it as intentional noise."
|
|
511
|
+
success_signal = "The theme stops resurfacing in unresolved protocol tasks."
|
|
512
|
+
now_iso = datetime.now().isoformat(timespec="seconds")
|
|
513
|
+
if existing:
|
|
514
|
+
updates: dict[str, object] = {}
|
|
515
|
+
if "title" in columns:
|
|
516
|
+
updates["title"] = sample_goal[:140]
|
|
517
|
+
if "objective" in columns:
|
|
518
|
+
updates["objective"] = objective
|
|
519
|
+
if "priority" in columns:
|
|
520
|
+
updates["priority"] = "high"
|
|
521
|
+
if "owner" in columns:
|
|
522
|
+
updates["owner"] = "system:self-audit"
|
|
523
|
+
if "next_action" in columns:
|
|
524
|
+
updates["next_action"] = next_action
|
|
525
|
+
if "success_signal" in columns:
|
|
526
|
+
updates["success_signal"] = success_signal
|
|
527
|
+
if "updated_at" in columns:
|
|
528
|
+
updates["updated_at"] = now_iso
|
|
529
|
+
assignments = ", ".join(f"{column} = ?" for column in updates)
|
|
530
|
+
conn.execute(
|
|
531
|
+
f"UPDATE workflow_goals SET {assignments} WHERE goal_id = ?",
|
|
532
|
+
[updates[column] for column in updates] + [existing["goal_id"]],
|
|
533
|
+
)
|
|
534
|
+
return {"ok": True, "action": "updated", "goal_id": str(existing["goal_id"])}
|
|
535
|
+
|
|
536
|
+
goal_id = f"WG-AUDIT-{hashlib.sha1(f'{area}:{signature or sample_goal}'.encode('utf-8')).hexdigest()[:8].upper()}"
|
|
537
|
+
values: dict[str, object] = {"goal_id": goal_id}
|
|
538
|
+
if "session_id" in columns:
|
|
539
|
+
values["session_id"] = ""
|
|
540
|
+
if "title" in columns:
|
|
541
|
+
values["title"] = sample_goal[:140]
|
|
542
|
+
if "objective" in columns:
|
|
543
|
+
values["objective"] = objective
|
|
544
|
+
if "parent_goal_id" in columns:
|
|
545
|
+
values["parent_goal_id"] = ""
|
|
546
|
+
if "status" in columns:
|
|
547
|
+
values["status"] = "active"
|
|
548
|
+
if "priority" in columns:
|
|
549
|
+
values["priority"] = "high"
|
|
550
|
+
if "owner" in columns:
|
|
551
|
+
values["owner"] = "system:self-audit"
|
|
552
|
+
if "next_action" in columns:
|
|
553
|
+
values["next_action"] = next_action
|
|
554
|
+
if "success_signal" in columns:
|
|
555
|
+
values["success_signal"] = success_signal
|
|
556
|
+
if "shared_state" in columns:
|
|
557
|
+
values["shared_state"] = json.dumps({"area": area, "signature": signature, "source": "self-audit"})
|
|
558
|
+
if "opened_at" in columns:
|
|
559
|
+
values["opened_at"] = now_iso
|
|
560
|
+
if "updated_at" in columns:
|
|
561
|
+
values["updated_at"] = now_iso
|
|
562
|
+
placeholders = ", ".join("?" for _ in values)
|
|
563
|
+
conn.execute(
|
|
564
|
+
f"INSERT INTO workflow_goals ({', '.join(values)}) VALUES ({placeholders})",
|
|
565
|
+
list(values.values()),
|
|
566
|
+
)
|
|
567
|
+
return {"ok": True, "action": "created", "goal_id": goal_id}
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def _queue_public_core_handoff(
|
|
571
|
+
conn: sqlite3.Connection,
|
|
572
|
+
*,
|
|
573
|
+
title: str,
|
|
574
|
+
reasoning: str,
|
|
575
|
+
files_changed: list[str],
|
|
576
|
+
metadata: dict | None = None,
|
|
577
|
+
) -> dict:
|
|
578
|
+
return queue_public_port_candidate(
|
|
579
|
+
conn,
|
|
580
|
+
title=title,
|
|
581
|
+
reasoning=reasoning,
|
|
582
|
+
files_changed=files_changed,
|
|
583
|
+
source="self-audit",
|
|
584
|
+
metadata=metadata or {},
|
|
585
|
+
)
|
|
586
|
+
|
|
587
|
+
|
|
266
588
|
TOPIC_STOPWORDS = {
|
|
267
589
|
"the", "and", "for", "with", "from", "that", "this", "into", "about", "after",
|
|
268
590
|
"before", "again", "need", "needs", "task", "tasks", "work", "working",
|
|
@@ -352,7 +674,8 @@ def _learning_matches_change(row: sqlite3.Row, files: list[str], change_text: st
|
|
|
352
674
|
|
|
353
675
|
def _attempt_repair_learning_auto_capture(row: sqlite3.Row) -> dict:
|
|
354
676
|
try:
|
|
355
|
-
from tools_learnings import find_conflicting_active_learning, handle_learning_add
|
|
677
|
+
from tools_learnings import find_conflicting_active_learning, handle_learning_add, handle_learning_update
|
|
678
|
+
from db._learnings import search_learnings
|
|
356
679
|
except Exception as exc:
|
|
357
680
|
return {"ok": False, "error": f"learning runtime unavailable: {exc}"}
|
|
358
681
|
|
|
@@ -369,6 +692,46 @@ def _attempt_repair_learning_auto_capture(row: sqlite3.Row) -> dict:
|
|
|
369
692
|
if not content:
|
|
370
693
|
content = f"Repair-oriented change log entry #{row['id']} required a canonical learning."
|
|
371
694
|
applies_to = ",".join(files)
|
|
695
|
+
|
|
696
|
+
# --- Search-then-supersede: find existing same-topic learnings first ---
|
|
697
|
+
search_query = _topic_signature(f"{title} {content}")
|
|
698
|
+
existing_same_topic = None
|
|
699
|
+
if search_query:
|
|
700
|
+
candidates = search_learnings(search_query, category="nexo-ops")
|
|
701
|
+
for candidate in candidates:
|
|
702
|
+
if candidate.get("status") != "active":
|
|
703
|
+
continue
|
|
704
|
+
# Check if it covers the same files or topic
|
|
705
|
+
candidate_applies = str(candidate.get("applies_to") or "")
|
|
706
|
+
candidate_text = f"{candidate.get('title', '')} {candidate.get('content', '')}"
|
|
707
|
+
candidate_sig = _topic_signature(candidate_text)
|
|
708
|
+
if candidate_sig == search_query:
|
|
709
|
+
existing_same_topic = candidate
|
|
710
|
+
break
|
|
711
|
+
if applies_to and candidate_applies and any(
|
|
712
|
+
f in candidate_applies for f in files
|
|
713
|
+
):
|
|
714
|
+
existing_same_topic = candidate
|
|
715
|
+
break
|
|
716
|
+
|
|
717
|
+
# If a same-topic learning already exists, update it instead of creating a duplicate
|
|
718
|
+
if existing_same_topic:
|
|
719
|
+
existing_id = int(existing_same_topic["id"])
|
|
720
|
+
updated_content = existing_same_topic.get("content", "") + f"\n\n[Audit {datetime.now().strftime('%Y-%m-%d')}] {content}"
|
|
721
|
+
response = handle_learning_update(
|
|
722
|
+
id=existing_id,
|
|
723
|
+
content=updated_content[:2000],
|
|
724
|
+
reasoning=f"Updated by daily self-audit with evidence from repair change #{row['id']}.",
|
|
725
|
+
)
|
|
726
|
+
if "ERROR:" not in response:
|
|
727
|
+
return {
|
|
728
|
+
"ok": True,
|
|
729
|
+
"learning_id": existing_id,
|
|
730
|
+
"response": response,
|
|
731
|
+
"action": "updated_existing",
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
# Fall back to conflict check + new learning only if no same-topic match
|
|
372
735
|
conflicting = find_conflicting_active_learning(
|
|
373
736
|
category="nexo-ops",
|
|
374
737
|
title=title,
|
|
@@ -392,6 +755,7 @@ def _attempt_repair_learning_auto_capture(row: sqlite3.Row) -> dict:
|
|
|
392
755
|
"ok": True,
|
|
393
756
|
"learning_id": int(match.group(1)),
|
|
394
757
|
"response": response,
|
|
758
|
+
"action": "created_new",
|
|
395
759
|
}
|
|
396
760
|
return {"ok": False, "error": response}
|
|
397
761
|
|
|
@@ -595,25 +959,46 @@ def check_learning_contradictions():
|
|
|
595
959
|
contradictions.append((left, right))
|
|
596
960
|
|
|
597
961
|
if contradictions:
|
|
598
|
-
|
|
599
|
-
|
|
962
|
+
resolved = 0
|
|
963
|
+
completed_followups = 0
|
|
964
|
+
retired_ids: set[int] = set()
|
|
965
|
+
for left, right in contradictions:
|
|
966
|
+
keep, retire = left, right
|
|
967
|
+
if int(retire["id"]) in retired_ids or int(keep["id"]) in retired_ids:
|
|
968
|
+
continue
|
|
600
969
|
description = (
|
|
601
970
|
f"Resolve contradictory active learnings #{left['id']} and #{right['id']} "
|
|
602
971
|
f"for {left['applies_to'] or right['applies_to']}"
|
|
603
972
|
)
|
|
604
|
-
|
|
605
|
-
"
|
|
606
|
-
"
|
|
607
|
-
)
|
|
608
|
-
_ensure_followup(
|
|
609
|
-
conn,
|
|
610
|
-
prefix="CONTRADICTION",
|
|
611
|
-
description=description,
|
|
612
|
-
verification="One canonical learning remains active and the conflicting rule is superseded or archived",
|
|
613
|
-
reasoning=reasoning,
|
|
614
|
-
priority="critical",
|
|
973
|
+
note = (
|
|
974
|
+
f"Resolved inline by daily self-audit: learning #{retire['id']} was superseded by "
|
|
975
|
+
f"canonical learning #{keep['id']}."
|
|
615
976
|
)
|
|
977
|
+
if _supersede_learning_inline(conn, keep_id=int(keep["id"]), retire_id=int(retire["id"]), note=note):
|
|
978
|
+
resolved += 1
|
|
979
|
+
retired_ids.add(int(retire["id"]))
|
|
980
|
+
applies_to = str(keep["applies_to"] or retire["applies_to"] or "").strip()
|
|
981
|
+
if applies_to:
|
|
982
|
+
_queue_public_core_handoff(
|
|
983
|
+
conn,
|
|
984
|
+
title=f"Reconcile contradictory rule coverage for {applies_to[:120]}",
|
|
985
|
+
reasoning=note,
|
|
986
|
+
files_changed=_split_changed_files(applies_to),
|
|
987
|
+
metadata={
|
|
988
|
+
"kept_learning_id": int(keep["id"]),
|
|
989
|
+
"retired_learning_id": int(retire["id"]),
|
|
990
|
+
},
|
|
991
|
+
)
|
|
992
|
+
completed_followups += _complete_matching_followup(conn, description, note)
|
|
616
993
|
conn.commit()
|
|
994
|
+
if resolved:
|
|
995
|
+
message = f"{resolved} contradictory active learning pair(s) resolved inline"
|
|
996
|
+
if completed_followups:
|
|
997
|
+
message += f" | completed {completed_followups} legacy followup(s)"
|
|
998
|
+
finding("INFO", "contradictions", message)
|
|
999
|
+
remaining = max(0, len(contradictions) - resolved)
|
|
1000
|
+
if remaining:
|
|
1001
|
+
finding("WARN", "contradictions", f"{remaining} contradictory active learning pair(s) still need manual review")
|
|
617
1002
|
conn.close()
|
|
618
1003
|
|
|
619
1004
|
|
|
@@ -643,7 +1028,8 @@ def check_error_memory_loop():
|
|
|
643
1028
|
|
|
644
1029
|
repeated = {signature: items for signature, items in grouped.items() if len(items) >= 2}
|
|
645
1030
|
if repeated:
|
|
646
|
-
|
|
1031
|
+
resolved = 0
|
|
1032
|
+
completed_followups = 0
|
|
647
1033
|
for signature, items in list(repeated.items())[:5]:
|
|
648
1034
|
description = (
|
|
649
1035
|
f"Mine a canonical prevention learning from repeated failed/blocked protocol tasks around {signature}"
|
|
@@ -652,15 +1038,59 @@ def check_error_memory_loop():
|
|
|
652
1038
|
f"Daily self-audit found {len(items)} failed/blocked protocol tasks without a linked learning. "
|
|
653
1039
|
"Turn the repeated failure into a prevention rule before it repeats again."
|
|
654
1040
|
)
|
|
655
|
-
|
|
1041
|
+
sample = items[0]
|
|
1042
|
+
area = str(sample["area"] or "nexo-ops").strip() or "nexo-ops"
|
|
1043
|
+
applies_to = signature if "/" in signature else ""
|
|
1044
|
+
title = f"Prevention: repeated failures around {signature[:120]}"
|
|
1045
|
+
clustered_tasks = "; ".join(
|
|
1046
|
+
f"{str(item['task_id'])}: {str(item['goal'] or '').strip()[:80]}"
|
|
1047
|
+
for item in items[:5]
|
|
1048
|
+
)
|
|
1049
|
+
content = (
|
|
1050
|
+
f"Repeated failed/blocked protocol tasks detected around {signature}. "
|
|
1051
|
+
f"Examples: {clustered_tasks}."
|
|
1052
|
+
)
|
|
1053
|
+
prevention = (
|
|
1054
|
+
f"Before working around {signature}, review this cluster and capture the prevention rule in the task contract."
|
|
1055
|
+
)
|
|
1056
|
+
result = _upsert_inline_learning(
|
|
656
1057
|
conn,
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
1058
|
+
category=area,
|
|
1059
|
+
title=title,
|
|
1060
|
+
content=content,
|
|
660
1061
|
reasoning=reasoning,
|
|
1062
|
+
prevention=prevention,
|
|
1063
|
+
applies_to=applies_to,
|
|
661
1064
|
priority="high",
|
|
662
1065
|
)
|
|
1066
|
+
if result.get("ok"):
|
|
1067
|
+
resolved += 1
|
|
1068
|
+
if applies_to:
|
|
1069
|
+
_queue_public_core_handoff(
|
|
1070
|
+
conn,
|
|
1071
|
+
title=f"Port prevention guard for {signature[:120]}",
|
|
1072
|
+
reasoning=reasoning,
|
|
1073
|
+
files_changed=_split_changed_files(applies_to),
|
|
1074
|
+
metadata={
|
|
1075
|
+
"learning_id": result.get("learning_id"),
|
|
1076
|
+
"cluster_size": len(items),
|
|
1077
|
+
"signature": signature,
|
|
1078
|
+
},
|
|
1079
|
+
)
|
|
1080
|
+
completed_followups += _complete_matching_followup(
|
|
1081
|
+
conn,
|
|
1082
|
+
description,
|
|
1083
|
+
f"Resolved inline by daily self-audit via learning #{result.get('learning_id')}.",
|
|
1084
|
+
)
|
|
663
1085
|
conn.commit()
|
|
1086
|
+
if resolved:
|
|
1087
|
+
message = f"{resolved} repeated failure cluster(s) converted into canonical prevention learnings inline"
|
|
1088
|
+
if completed_followups:
|
|
1089
|
+
message += f" | completed {completed_followups} legacy followup(s)"
|
|
1090
|
+
finding("INFO", "prevention", message)
|
|
1091
|
+
remaining = max(0, len(repeated) - resolved)
|
|
1092
|
+
if remaining:
|
|
1093
|
+
finding("WARN", "prevention", f"{remaining} repeated failure cluster(s) still lack inline prevention learnings")
|
|
664
1094
|
conn.close()
|
|
665
1095
|
|
|
666
1096
|
|
|
@@ -798,7 +1228,8 @@ def check_unformalized_mentions():
|
|
|
798
1228
|
if len(items) >= 2
|
|
799
1229
|
}
|
|
800
1230
|
if loose_topics:
|
|
801
|
-
|
|
1231
|
+
resolved = 0
|
|
1232
|
+
completed_followups = 0
|
|
802
1233
|
for (area, signature), items in list(loose_topics.items())[:5]:
|
|
803
1234
|
sample_goal = str(items[0]["goal"] or "").strip()[:120]
|
|
804
1235
|
description = (
|
|
@@ -809,15 +1240,48 @@ def check_unformalized_mentions():
|
|
|
809
1240
|
"Daily self-audit found the same theme recurring across protocol tasks without being "
|
|
810
1241
|
"converted into a workflow goal, followup, or learning. Formalize it before it keeps resurfacing."
|
|
811
1242
|
)
|
|
812
|
-
|
|
1243
|
+
goal_result = _upsert_workflow_goal_inline(
|
|
813
1244
|
conn,
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
1245
|
+
area=area,
|
|
1246
|
+
sample_goal=sample_goal,
|
|
1247
|
+
count=len(items),
|
|
1248
|
+
)
|
|
1249
|
+
if goal_result.get("ok"):
|
|
1250
|
+
resolved += 1
|
|
1251
|
+
completed_followups += _complete_matching_followup(
|
|
1252
|
+
conn,
|
|
1253
|
+
description,
|
|
1254
|
+
f"Resolved inline by daily self-audit via workflow goal {goal_result.get('goal_id')}.",
|
|
1255
|
+
)
|
|
1256
|
+
continue
|
|
1257
|
+
learning_result = _upsert_inline_learning(
|
|
1258
|
+
conn,
|
|
1259
|
+
category=area,
|
|
1260
|
+
title=f"Formalized recurring theme: {sample_goal}",
|
|
1261
|
+
content=(
|
|
1262
|
+
f"Recurring unresolved theme in {area}: '{sample_goal}' appeared {len(items)} times "
|
|
1263
|
+
"without a durable goal or learning."
|
|
1264
|
+
),
|
|
817
1265
|
reasoning=reasoning,
|
|
1266
|
+
prevention="Convert recurring themes into an explicit workflow goal before they keep resurfacing.",
|
|
818
1267
|
priority="high",
|
|
819
1268
|
)
|
|
1269
|
+
if learning_result.get("ok"):
|
|
1270
|
+
resolved += 1
|
|
1271
|
+
completed_followups += _complete_matching_followup(
|
|
1272
|
+
conn,
|
|
1273
|
+
description,
|
|
1274
|
+
f"Resolved inline by daily self-audit via learning #{learning_result.get('learning_id')}.",
|
|
1275
|
+
)
|
|
820
1276
|
conn.commit()
|
|
1277
|
+
if resolved:
|
|
1278
|
+
message = f"{resolved} repeated unresolved theme(s) formalized inline"
|
|
1279
|
+
if completed_followups:
|
|
1280
|
+
message += f" | completed {completed_followups} legacy followup(s)"
|
|
1281
|
+
finding("INFO", "formalization", message)
|
|
1282
|
+
remaining = max(0, len(loose_topics) - resolved)
|
|
1283
|
+
if remaining:
|
|
1284
|
+
finding("WARN", "formalization", f"{remaining} repeated topic(s) still lack durable inline formalization")
|
|
821
1285
|
conn.close()
|
|
822
1286
|
|
|
823
1287
|
|
|
@@ -1261,6 +1725,169 @@ def check_codex_startup_discipline():
|
|
|
1261
1725
|
finding(severity, "codex-startup", message)
|
|
1262
1726
|
|
|
1263
1727
|
|
|
1728
|
+
def _clear_findings(area: str, contains: str = "") -> int:
|
|
1729
|
+
removed = 0
|
|
1730
|
+
keep: list[dict] = []
|
|
1731
|
+
for item in findings:
|
|
1732
|
+
same_area = item.get("area") == area
|
|
1733
|
+
same_fragment = not contains or contains in str(item.get("msg") or "")
|
|
1734
|
+
if same_area and same_fragment:
|
|
1735
|
+
removed += 1
|
|
1736
|
+
continue
|
|
1737
|
+
keep.append(item)
|
|
1738
|
+
if removed:
|
|
1739
|
+
findings[:] = keep
|
|
1740
|
+
return removed
|
|
1741
|
+
|
|
1742
|
+
|
|
1743
|
+
def _sync_managed_bootstraps_inline() -> list[str]:
|
|
1744
|
+
try:
|
|
1745
|
+
from bootstrap_docs import sync_client_bootstrap
|
|
1746
|
+
from client_preferences import CLIENT_CLAUDE_CODE, CLIENT_CODEX
|
|
1747
|
+
except Exception:
|
|
1748
|
+
return []
|
|
1749
|
+
|
|
1750
|
+
results: list[str] = []
|
|
1751
|
+
for client in (CLIENT_CLAUDE_CODE, CLIENT_CODEX):
|
|
1752
|
+
try:
|
|
1753
|
+
outcome = sync_client_bootstrap(client, nexo_home=NEXO_HOME)
|
|
1754
|
+
except Exception:
|
|
1755
|
+
continue
|
|
1756
|
+
if not outcome.get("ok"):
|
|
1757
|
+
continue
|
|
1758
|
+
action = str(outcome.get("action") or "")
|
|
1759
|
+
if action and action != "unchanged":
|
|
1760
|
+
results.append(f"{client}:{action}")
|
|
1761
|
+
return results
|
|
1762
|
+
|
|
1763
|
+
|
|
1764
|
+
def _sanitize_watchdog_registry_inline() -> dict:
|
|
1765
|
+
if not HASH_REGISTRY.exists():
|
|
1766
|
+
return {"ok": False, "removed": []}
|
|
1767
|
+
forbidden = ["CLAUDE.md", "AGENTS.md", "server.py", "plugin_loader.py"]
|
|
1768
|
+
original_lines = HASH_REGISTRY.read_text(errors="ignore").splitlines()
|
|
1769
|
+
kept_lines = []
|
|
1770
|
+
removed: set[str] = set()
|
|
1771
|
+
for line in original_lines:
|
|
1772
|
+
if any(name in line for name in forbidden):
|
|
1773
|
+
for name in forbidden:
|
|
1774
|
+
if name in line:
|
|
1775
|
+
removed.add(name)
|
|
1776
|
+
continue
|
|
1777
|
+
kept_lines.append(line)
|
|
1778
|
+
if not removed:
|
|
1779
|
+
return {"ok": False, "removed": []}
|
|
1780
|
+
new_text = "\n".join(kept_lines)
|
|
1781
|
+
if kept_lines:
|
|
1782
|
+
new_text += "\n"
|
|
1783
|
+
HASH_REGISTRY.write_text(new_text)
|
|
1784
|
+
return {"ok": True, "removed": sorted(removed)}
|
|
1785
|
+
|
|
1786
|
+
|
|
1787
|
+
def _refresh_golden_snapshots_inline() -> dict:
|
|
1788
|
+
pairs = [
|
|
1789
|
+
(NEXO_CODE / "db" / "__init__.py", SNAPSHOT_GOLDEN / "db" / "__init__.py"),
|
|
1790
|
+
(NEXO_CODE / "evolution_cycle.py", SNAPSHOT_GOLDEN / "evolution_cycle.py"),
|
|
1791
|
+
]
|
|
1792
|
+
refreshed: list[str] = []
|
|
1793
|
+
for live, snap in pairs:
|
|
1794
|
+
if not live.exists():
|
|
1795
|
+
continue
|
|
1796
|
+
if snap.exists() and _sha256(live) == _sha256(snap):
|
|
1797
|
+
continue
|
|
1798
|
+
snap.parent.mkdir(parents=True, exist_ok=True)
|
|
1799
|
+
shutil.copy2(live, snap)
|
|
1800
|
+
refreshed.append(live.name)
|
|
1801
|
+
return {"ok": bool(refreshed), "refreshed": refreshed}
|
|
1802
|
+
|
|
1803
|
+
|
|
1804
|
+
def _disable_broken_personal_plugins_inline(conn: sqlite3.Connection | None) -> dict:
|
|
1805
|
+
plugins_dir = NEXO_HOME / "plugins"
|
|
1806
|
+
if not plugins_dir.exists():
|
|
1807
|
+
return {"disabled": [], "registry_pruned": 0}
|
|
1808
|
+
|
|
1809
|
+
disabled: list[str] = []
|
|
1810
|
+
registry_pruned = 0
|
|
1811
|
+
personal_filenames: set[str] = set()
|
|
1812
|
+
if conn is not None and _table_exists(conn, "plugins"):
|
|
1813
|
+
try:
|
|
1814
|
+
rows = conn.execute(
|
|
1815
|
+
"SELECT filename, created_by FROM plugins WHERE created_by = 'personal'"
|
|
1816
|
+
).fetchall()
|
|
1817
|
+
personal_filenames = {str(row["filename"] or "").strip() for row in rows if str(row["filename"] or "").strip()}
|
|
1818
|
+
except Exception:
|
|
1819
|
+
personal_filenames = set()
|
|
1820
|
+
|
|
1821
|
+
for plugin_file in sorted(plugins_dir.glob("*.py")):
|
|
1822
|
+
try:
|
|
1823
|
+
py_compile.compile(str(plugin_file), doraise=True)
|
|
1824
|
+
except Exception:
|
|
1825
|
+
disabled_path = plugin_file.with_name(plugin_file.name + ".disabled")
|
|
1826
|
+
plugin_file.rename(disabled_path)
|
|
1827
|
+
disabled.append(plugin_file.name)
|
|
1828
|
+
if conn is not None and _table_exists(conn, "plugins"):
|
|
1829
|
+
conn.execute("DELETE FROM plugins WHERE filename = ?", (plugin_file.name,))
|
|
1830
|
+
registry_pruned += 1
|
|
1831
|
+
|
|
1832
|
+
if conn is not None and _table_exists(conn, "plugins"):
|
|
1833
|
+
for filename in sorted(personal_filenames):
|
|
1834
|
+
if not filename:
|
|
1835
|
+
continue
|
|
1836
|
+
if not (plugins_dir / filename).exists():
|
|
1837
|
+
conn.execute("DELETE FROM plugins WHERE filename = ?", (filename,))
|
|
1838
|
+
registry_pruned += 1
|
|
1839
|
+
return {"disabled": disabled, "registry_pruned": registry_pruned}
|
|
1840
|
+
|
|
1841
|
+
|
|
1842
|
+
def run_mechanical_autofixes():
|
|
1843
|
+
conn = None
|
|
1844
|
+
try:
|
|
1845
|
+
if NEXO_DB.exists():
|
|
1846
|
+
conn = sqlite3.connect(str(NEXO_DB))
|
|
1847
|
+
conn.row_factory = sqlite3.Row
|
|
1848
|
+
|
|
1849
|
+
bootstrap_actions = _sync_managed_bootstraps_inline()
|
|
1850
|
+
if bootstrap_actions:
|
|
1851
|
+
finding("INFO", "autofix", f"Managed bootstraps refreshed inline: {', '.join(bootstrap_actions)}")
|
|
1852
|
+
|
|
1853
|
+
registry_result = _sanitize_watchdog_registry_inline()
|
|
1854
|
+
if registry_result.get("ok"):
|
|
1855
|
+
_clear_findings("watchdog", "mutable files still protected")
|
|
1856
|
+
finding(
|
|
1857
|
+
"INFO",
|
|
1858
|
+
"watchdog",
|
|
1859
|
+
"Self-audit sanitized watchdog registry inline: "
|
|
1860
|
+
+ ", ".join(registry_result.get("removed") or []),
|
|
1861
|
+
)
|
|
1862
|
+
|
|
1863
|
+
snapshot_result = _refresh_golden_snapshots_inline()
|
|
1864
|
+
if snapshot_result.get("ok"):
|
|
1865
|
+
_clear_findings("snapshots", "golden snapshot drift")
|
|
1866
|
+
finding(
|
|
1867
|
+
"INFO",
|
|
1868
|
+
"snapshots",
|
|
1869
|
+
"Self-audit refreshed golden snapshots inline: "
|
|
1870
|
+
+ ", ".join(snapshot_result.get("refreshed") or []),
|
|
1871
|
+
)
|
|
1872
|
+
|
|
1873
|
+
plugin_result = _disable_broken_personal_plugins_inline(conn)
|
|
1874
|
+
disabled = plugin_result.get("disabled") or []
|
|
1875
|
+
pruned = int(plugin_result.get("registry_pruned") or 0)
|
|
1876
|
+
if disabled or pruned:
|
|
1877
|
+
details: list[str] = []
|
|
1878
|
+
if disabled:
|
|
1879
|
+
details.append(f"disabled {len(disabled)} personal plugin(s): {', '.join(disabled)}")
|
|
1880
|
+
if pruned:
|
|
1881
|
+
details.append(f"pruned {pruned} stale plugin registry entrie(s)")
|
|
1882
|
+
finding("INFO", "autofix", "Self-audit plugin autofix: " + " | ".join(details))
|
|
1883
|
+
|
|
1884
|
+
if conn is not None:
|
|
1885
|
+
conn.commit()
|
|
1886
|
+
finally:
|
|
1887
|
+
if conn is not None:
|
|
1888
|
+
conn.close()
|
|
1889
|
+
|
|
1890
|
+
|
|
1264
1891
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
1265
1892
|
# Stage B: Interpretation (automation backend) — NEW in v2
|
|
1266
1893
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
@@ -1282,7 +1909,17 @@ def interpret_findings(raw_findings: list) -> bool:
|
|
|
1282
1909
|
|
|
1283
1910
|
You are NEXO's morning self-audit interpreter. The mechanical checks found
|
|
1284
1911
|
{len(errors)} errors and {len(warns)} warnings. Your job is to UNDERSTAND what's
|
|
1285
|
-
actually wrong, not just list findings.
|
|
1912
|
+
actually wrong, not just list findings.
|
|
1913
|
+
|
|
1914
|
+
CRITICAL — SEARCH BEFORE CREATING LEARNINGS:
|
|
1915
|
+
Before calling nexo_learning_add, you MUST call nexo_learning_search with keywords
|
|
1916
|
+
from the finding's area and topic. If a matching active learning already exists:
|
|
1917
|
+
- Call nexo_learning_update(id=<existing_id>, ...) to refresh it with the new
|
|
1918
|
+
evidence/date instead of creating a duplicate.
|
|
1919
|
+
- Only use nexo_learning_add (with supersedes_id=<old_id>) when the existing
|
|
1920
|
+
learning is materially wrong or outdated, not just to add another observation.
|
|
1921
|
+
If no existing learning matches, then nexo_learning_add is appropriate.
|
|
1922
|
+
The same applies to nexo_followup_create — search existing followups first.
|
|
1286
1923
|
|
|
1287
1924
|
RAW FINDINGS:
|
|
1288
1925
|
{findings_json}
|
|
@@ -1376,6 +2013,7 @@ def main():
|
|
|
1376
2013
|
run_watchdog_smoke()
|
|
1377
2014
|
check_watchdog_smoke()
|
|
1378
2015
|
check_cognitive_health()
|
|
2016
|
+
run_mechanical_autofixes()
|
|
1379
2017
|
|
|
1380
2018
|
errors = sum(1 for f in findings if f["severity"] == "ERROR")
|
|
1381
2019
|
warns = sum(1 for f in findings if f["severity"] == "WARN")
|