nexo-brain 7.23.13 → 7.25.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 (59) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +15 -11
  3. package/bin/nexo-brain.js +42 -235
  4. package/package.json +1 -1
  5. package/src/auto_update.py +30 -0
  6. package/src/automation_supervisor.py +1 -1
  7. package/src/cli.py +255 -9
  8. package/src/cognitive_control_observatory.py +224 -0
  9. package/src/crons/manifest.json +13 -0
  10. package/src/dashboard/app.py +26 -9
  11. package/src/db/__init__.py +2 -0
  12. package/src/db/_fts.py +38 -8
  13. package/src/db/_learnings.py +1 -1
  14. package/src/db/_memory_v2.py +107 -1
  15. package/src/db/_protocol.py +2 -2
  16. package/src/db/_reminders.py +132 -4
  17. package/src/db/_schema.py +48 -2
  18. package/src/doctor/providers/runtime.py +69 -0
  19. package/src/events_bus.py +4 -5
  20. package/src/learning_resolver.py +419 -0
  21. package/src/lifecycle_events.py +9 -9
  22. package/src/local_context/api.py +67 -5
  23. package/src/local_context/usage_events.py +24 -0
  24. package/src/memory_fabric.py +536 -0
  25. package/src/memory_observation_processor.py +28 -0
  26. package/src/memory_retrieval.py +5 -5
  27. package/src/operator_language.py +2 -0
  28. package/src/plugins/backup.py +1 -1
  29. package/src/plugins/cortex.py +21 -21
  30. package/src/plugins/episodic_memory.py +11 -11
  31. package/src/plugins/goal_engine.py +3 -3
  32. package/src/plugins/personal_scripts.py +75 -0
  33. package/src/plugins/protocol.py +10 -1
  34. package/src/pre_answer_router.py +120 -3
  35. package/src/r_catalog.py +4 -5
  36. package/src/saved_not_used_audit.py +31 -31
  37. package/src/script_registry.py +444 -1
  38. package/src/scripts/deep-sleep/apply_findings.py +79 -17
  39. package/src/scripts/nexo-backup.sh +30 -0
  40. package/src/scripts/nexo-daily-self-audit.py +46 -13
  41. package/src/scripts/nexo-email-migrate-config.py +2 -2
  42. package/src/scripts/nexo-email-monitor.py +19 -19
  43. package/src/scripts/nexo-followup-hygiene.py +40 -8
  44. package/src/scripts/nexo-followup-runner.py +31 -31
  45. package/src/scripts/nexo-inbox-hook.sh +1 -1
  46. package/src/scripts/nexo-learning-validator.py +24 -3
  47. package/src/scripts/nexo-memory-fabric.py +45 -0
  48. package/src/server.py +73 -1
  49. package/src/system_catalog.py +31 -31
  50. package/src/tools_learnings.py +96 -65
  51. package/src/tools_memory_v2.py +2 -2
  52. package/src/tools_sessions.py +25 -7
  53. package/src/tools_transcripts.py +50 -8
  54. package/src/transcript_index.py +105 -2
  55. package/src/transcript_utils.py +65 -13
  56. package/templates/core-prompts/postmortem-consolidator.md +3 -3
  57. package/templates/core-prompts/r17-promise-debt-injection.md +1 -1
  58. package/templates/core-prompts/server-mcp-instructions.md +6 -6
  59. package/tool-enforcement-map.json +143 -13
@@ -33,6 +33,7 @@ if str(NEXO_CODE) not in sys.path:
33
33
  import db as nexo_db
34
34
  import paths
35
35
  from cognitive_paths import resolve_cognitive_db
36
+ from learning_resolver import resolve_learning_candidate
36
37
 
37
38
  def _resolve_nexo_db() -> Path:
38
39
  candidates = [
@@ -161,21 +162,24 @@ def _top_followups_by_impact(limit: int = 5) -> list[dict]:
161
162
  conn.close()
162
163
  return []
163
164
  impact_factors_sql = ", impact_factors" if "impact_factors" in columns else ""
164
- rows = [
165
- dict(row)
166
- for row in conn.execute(
167
- f"""SELECT id, description, date, priority, impact_score{impact_factors_sql}
168
- FROM followups
169
- WHERE status IN ('PENDING', 'ACTIVE', 'WAITING', 'BLOCKED')
170
- ORDER BY
171
- CASE WHEN COALESCE(impact_score, 0) > 0 THEN 0 ELSE 1 END ASC,
172
- COALESCE(impact_score, 0) DESC,
173
- CASE WHEN date IS NULL OR date = '' THEN 1 ELSE 0 END ASC,
174
- date ASC
175
- LIMIT ?""",
176
- (max(1, int(limit)),),
177
- ).fetchall()
178
- ]
165
+ owner_sql = ", owner" if "owner" in columns else ""
166
+ rows = []
167
+ for row in conn.execute(
168
+ f"""SELECT id, description, date, priority, impact_score, status{owner_sql}{impact_factors_sql}
169
+ FROM followups
170
+ ORDER BY
171
+ CASE WHEN COALESCE(impact_score, 0) > 0 THEN 0 ELSE 1 END ASC,
172
+ COALESCE(impact_score, 0) DESC,
173
+ CASE WHEN date IS NULL OR date = '' THEN 1 ELSE 0 END ASC,
174
+ date ASC
175
+ LIMIT ?""",
176
+ (max(20, int(limit) * 4),),
177
+ ).fetchall():
178
+ item = dict(row)
179
+ if nexo_db.followup_lifecycle_lane(item) == "active":
180
+ rows.append(item)
181
+ if len(rows) >= max(1, int(limit)):
182
+ break
179
183
  conn.close()
180
184
  except Exception:
181
185
  return []
@@ -501,6 +505,18 @@ def _find_learning_match(category: str, title: str, content: str) -> dict | None
501
505
  return candidates[0]
502
506
 
503
507
 
508
+ def _learning_by_id(learning_id: int) -> dict | None:
509
+ if not learning_id or not NEXO_DB.exists():
510
+ return None
511
+ conn = sqlite3.connect(str(NEXO_DB))
512
+ conn.row_factory = sqlite3.Row
513
+ try:
514
+ row = conn.execute("SELECT * FROM learnings WHERE id = ?", (int(learning_id),)).fetchone()
515
+ return dict(row) if row else None
516
+ finally:
517
+ conn.close()
518
+
519
+
504
520
  def _update_learning_row(learning_id: int, updates: dict[str, object]) -> None:
505
521
  if not updates:
506
522
  return
@@ -545,7 +561,34 @@ def add_learning(category: str, title: str, content: str) -> dict:
545
561
  if not NEXO_DB.exists():
546
562
  return {"success": False, "error": "nexo.db not found"}
547
563
  try:
548
- existing = _find_learning_match(category, title, content)
564
+ resolution = resolve_learning_candidate(
565
+ category=category,
566
+ title=title,
567
+ content=content,
568
+ source_authority="deep_sleep",
569
+ )
570
+ if resolution["action"] == "reject":
571
+ return {"success": False, "error": resolution["reason"], "outcome": "rejected_learning"}
572
+ if resolution["action"] == "conflict_review":
573
+ return _flag_learning_contradiction(
574
+ {
575
+ "id": resolution.get("target_id"),
576
+ "title": resolution.get("target_title"),
577
+ "_similarity": resolution.get("similarity", 0.0),
578
+ },
579
+ category,
580
+ title,
581
+ content,
582
+ )
583
+
584
+ canonical_supersedes_id = int(resolution.get("target_id") or 0) if resolution["action"] == "supersede" else 0
585
+ existing = None if canonical_supersedes_id else _find_learning_match(category, title, content)
586
+ if resolution["action"] == "merge" and int(resolution.get("target_id") or 0):
587
+ row = _learning_by_id(int(resolution["target_id"]))
588
+ if row:
589
+ existing = dict(row)
590
+ existing["_similarity"] = float(resolution.get("similarity") or 1.0)
591
+ existing["_contradiction"] = False
549
592
  if existing:
550
593
  similarity = existing.get("_similarity", 0.0)
551
594
  if existing.get("_contradiction"):
@@ -631,7 +674,21 @@ def add_learning(category: str, title: str, content: str) -> dict:
631
674
  learning_id = cursor.lastrowid
632
675
  conn.commit()
633
676
  conn.close()
634
- return {"success": True, "id": learning_id, "outcome": "new_learning"}
677
+ if canonical_supersedes_id:
678
+ try:
679
+ nexo_db.supersede_learning(
680
+ int(canonical_supersedes_id),
681
+ int(learning_id),
682
+ f"Superseded by Deep Sleep canonical learning #{learning_id}.",
683
+ )
684
+ except Exception:
685
+ pass
686
+ return {
687
+ "success": True,
688
+ "id": learning_id,
689
+ "outcome": "new_learning" if not canonical_supersedes_id else "superseding_learning",
690
+ "supersedes_id": canonical_supersedes_id,
691
+ }
635
692
  except Exception as e:
636
693
  return {"success": False, "error": str(e)}
637
694
 
@@ -677,6 +734,11 @@ def create_followup(description: str, date: str = "", reasoning_note: str = "",
677
734
  )
678
735
  if followup_result.get("error"):
679
736
  return {"success": False, "error": followup_result["error"]}
737
+ if desired_status.lower() == "archived":
738
+ conn = sqlite3.connect(str(NEXO_DB))
739
+ conn.execute("UPDATE followups SET status = ? WHERE id = ?", ("archived", fid))
740
+ conn.commit()
741
+ conn.close()
680
742
  if desired_status != "PENDING":
681
743
  nexo_db.add_followup_note(
682
744
  fid,
@@ -2,6 +2,8 @@
2
2
  # NEXO DB hourly backup — crontab: 0 * * * * $NEXO_HOME/core/scripts/nexo-backup.sh
3
3
  NEXO_HOME="${NEXO_HOME:-$HOME/.nexo}"
4
4
  NEXO_DIR="$NEXO_HOME"
5
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
6
+ CORE_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
5
7
  BACKUP_DIR="$NEXO_HOME/runtime/backups"
6
8
  if [ ! -d "$BACKUP_DIR" ] && [ -d "$NEXO_HOME/backups" ]; then
7
9
  BACKUP_DIR="$NEXO_HOME/backups"
@@ -23,7 +25,35 @@ LOCAL_CONTEXT_MAX_BACKUP_BYTES="${NEXO_LOCAL_CONTEXT_MAX_BACKUP_BYTES:-214748364
23
25
 
24
26
  mkdir -p "$BACKUP_DIR" "$WEEKLY_DIR"
25
27
 
28
+ reconcile_memory_fabric_before_prune() {
29
+ python3 - "$BACKUP_DIR" "$CORE_DIR" <<'PY' >/dev/null 2>&1 || true
30
+ from __future__ import annotations
31
+
32
+ import sys
33
+ from pathlib import Path
34
+
35
+ backup_dir = Path(sys.argv[1])
36
+ core_dir = Path(sys.argv[2])
37
+ for candidate in (core_dir, core_dir.parent / "src"):
38
+ if candidate.exists():
39
+ sys.path.insert(0, str(candidate))
40
+
41
+ try:
42
+ import memory_fabric
43
+
44
+ memory_fabric.reconcile_backup_diaries(
45
+ backups_root=backup_dir,
46
+ max_backup_files=80,
47
+ limit=10000,
48
+ )
49
+ except Exception:
50
+ pass
51
+ PY
52
+ }
53
+
26
54
  cleanup_backups() {
55
+ reconcile_memory_fabric_before_prune
56
+
27
57
  PRUNER="$NEXO_HOME/core/scripts/prune_runtime_backups.py"
28
58
  if [ ! -f "$PRUNER" ]; then
29
59
  PRUNER="$(dirname "$0")/prune_runtime_backups.py"
@@ -75,6 +75,7 @@ from constants import AUTOMATION_SUBPROCESS_TIMEOUT
75
75
  from core_prompts import render_core_prompt
76
76
  from cognitive_paths import audit_cognitive_db_paths, resolve_cognitive_db
77
77
  import db as nexo_db
78
+ from learning_resolver import applies_overlap, looks_contradictory, resolve_learning_candidate
78
79
  from public_evolution_queue import queue_public_port_candidate
79
80
 
80
81
  try:
@@ -390,12 +391,34 @@ def _upsert_inline_learning(
390
391
  return {"ok": False, "reason": "learnings_missing"}
391
392
 
392
393
  columns = _table_columns(conn, "learnings")
394
+ resolution = resolve_learning_candidate(
395
+ category=category,
396
+ title=title,
397
+ content=content,
398
+ reasoning=reasoning,
399
+ prevention=prevention,
400
+ applies_to=applies_to,
401
+ priority=priority,
402
+ source_authority="code_test_evidence",
403
+ conn=conn,
404
+ )
405
+ if resolution["action"] == "reject":
406
+ return {"ok": False, "reason": resolution["reason"], "resolver": resolution}
407
+ if resolution["action"] == "conflict_review":
408
+ return {"ok": False, "reason": "conflict_review_required", "resolver": resolution}
409
+ resolver_target_id = int(resolution.get("target_id") or 0)
410
+ supersede_target_id = resolver_target_id if resolution["action"] == "supersede" else 0
393
411
  rows = conn.execute(
394
412
  "SELECT * FROM learnings WHERE COALESCE(status, 'active') != 'superseded' ORDER BY updated_at DESC, id DESC LIMIT 200"
395
413
  ).fetchall()
396
414
  target_signature = _topic_signature(f"{title} {content}")
397
415
  existing = None
398
416
  for row in rows:
417
+ if resolution["action"] == "merge" and resolver_target_id and int(row["id"]) == resolver_target_id:
418
+ existing = row
419
+ break
420
+ if supersede_target_id:
421
+ continue
399
422
  row_title = str(row["title"] or "").strip() if "title" in columns else ""
400
423
  row_content = str(row["content"] or "").strip() if "content" in columns else ""
401
424
  row_applies = str(row["applies_to"] or "").strip() if "applies_to" in columns else ""
@@ -471,6 +494,13 @@ def _upsert_inline_learning(
471
494
  list(values.values()),
472
495
  )
473
496
  learning_id = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
497
+ if supersede_target_id:
498
+ _supersede_learning_inline(
499
+ conn,
500
+ keep_id=int(learning_id),
501
+ retire_id=supersede_target_id,
502
+ note=f"Self-audit canonical resolver superseded learning #{supersede_target_id}.",
503
+ )
474
504
  return {"ok": True, "action": "created", "learning_id": int(learning_id)}
475
505
 
476
506
 
@@ -681,8 +711,8 @@ def _retire_stale_audit_goals_inline(
681
711
  closed_at = ?
682
712
  WHERE goal_id = ?""",
683
713
  (
684
- "Ninguna. Placeholder stale retirado automáticamente; el self-audit lo recreará si el patrón reaparece.",
685
- f"Self-audit placeholder stale >{max_age_hours}h sin workflow runs abiertos.",
714
+ "None. Stale placeholder removed automatically; self-audit will recreate it if the pattern reappears.",
715
+ f"Self-audit placeholder stale >{max_age_hours}h without open workflow runs.",
686
716
  now_iso,
687
717
  now_iso,
688
718
  row["goal_id"],
@@ -853,15 +883,20 @@ def check_overdue_reminders():
853
883
  def check_overdue_followups():
854
884
  if not NEXO_DB.exists():
855
885
  return
856
- conn = sqlite3.connect(str(NEXO_DB))
857
886
  today = datetime.now().strftime("%Y-%m-%d")
858
- rows = conn.execute(
859
- "SELECT description, date FROM followups WHERE status='PENDING' AND date < ? AND date != '' ORDER BY date",
860
- (today,)
861
- ).fetchall()
862
- conn.close()
887
+ try:
888
+ active = (nexo_db.followup_lifecycle_snapshot(limit=5000).get("lanes") or {}).get("active", [])
889
+ rows = [item for item in active if item.get("date") and str(item["date"]) < today]
890
+ rows.sort(key=lambda item: str(item.get("date") or ""))
891
+ except Exception:
892
+ conn = sqlite3.connect(str(NEXO_DB))
893
+ rows = conn.execute(
894
+ "SELECT description, date FROM followups WHERE status='PENDING' AND date < ? AND date != '' ORDER BY date",
895
+ (today,)
896
+ ).fetchall()
897
+ conn.close()
863
898
  if rows:
864
- finding("WARN", "followups", f"{len(rows)} overdue: {', '.join(r[0][:40] for r in rows[:5])}")
899
+ finding("WARN", "followups", f"{len(rows)} overdue: {', '.join(str((r.get('description') if isinstance(r, dict) else r[0]) or '')[:40] for r in rows[:5])}")
865
900
 
866
901
 
867
902
  def check_uncommitted_changes():
@@ -1156,8 +1191,6 @@ def check_learning_contradictions():
1156
1191
  conn.close()
1157
1192
  return
1158
1193
 
1159
- from tools_learnings import _applies_overlap, _looks_contradictory
1160
-
1161
1194
  rows = conn.execute(
1162
1195
  """SELECT id, title, content, applies_to
1163
1196
  FROM learnings
@@ -1168,9 +1201,9 @@ def check_learning_contradictions():
1168
1201
  contradictions: list[tuple[sqlite3.Row, sqlite3.Row]] = []
1169
1202
  for index, left in enumerate(rows):
1170
1203
  for right in rows[index + 1:]:
1171
- if not _applies_overlap(left["applies_to"], right["applies_to"]):
1204
+ if not applies_overlap(left["applies_to"], right["applies_to"]):
1172
1205
  continue
1173
- if not _looks_contradictory(
1206
+ if not looks_contradictory(
1174
1207
  f"{left['title']} {left['content']}",
1175
1208
  f"{right['title']} {right['content']}",
1176
1209
  ):
@@ -171,8 +171,8 @@ def main(argv: list[str]) -> int:
171
171
  is_default=False,
172
172
  )
173
173
  print(
174
- f"(cuenta '{args.label}' ya existe — email={account.get('email')}, "
175
- "normalizo el contrato agente y sólo completo buzones del operador faltantes)."
174
+ f"(account '{args.label}' already exists — email={account.get('email')}, "
175
+ "normalizing the agent contract and only filling missing operator mailboxes)."
176
176
  )
177
177
  else:
178
178
  account = add_email_account(
@@ -395,12 +395,12 @@ def load_config():
395
395
  return cfg
396
396
  except Exception:
397
397
  pass
398
- # v0.32.5 — graceful return None when no email setup yet. Antes esto
399
- # lanzaba FileNotFoundError y el cron de cada minuto generaba 1440
400
- # filas de error/día por cliente sin email configurado. 50 clientes
401
- # pagados sin email setup 72k filas de error/día watchdog L2
402
- # alertas falsas ruido en logs y posible token burn. Ahora el
403
- # cron retorna sin error cuando no hay config.
398
+ # v0.32.5 — return None gracefully when no email setup exists yet. Before
399
+ # this, FileNotFoundError bubbled up and the once-per-minute cron generated
400
+ # 1,440 error rows per day per client without configured email. 50 paid
401
+ # clients without email setup meant 72k error rows/day, false watchdog L2
402
+ # alerts, log noise, and possible token burn. The cron now returns without
403
+ # error when config is absent.
404
404
  try:
405
405
  with open(CONFIG_PATH) as f:
406
406
  payload = json.load(f)
@@ -952,8 +952,8 @@ def scan_debt(db_path=EMAIL_DB_PATH, *, max_items=5):
952
952
  {
953
953
  "email_id": row["email_id"],
954
954
  "kind": "ack",
955
- "label": f"ACK sin cierre >{DEBT_SLA_HOURS}h — {row['subject'] or row['email_id']} [{row['email_id']}]",
956
- "detail": f"ack desde {row['last_ack_ts']}",
955
+ "label": f"ACK without closure >{DEBT_SLA_HOURS}h — {row['subject'] or row['email_id']} [{row['email_id']}]",
956
+ "detail": f"ack since {row['last_ack_ts']}",
957
957
  }
958
958
  )
959
959
 
@@ -979,8 +979,8 @@ def scan_debt(db_path=EMAIL_DB_PATH, *, max_items=5):
979
979
  {
980
980
  "email_id": row["email_id"],
981
981
  "kind": "commitment",
982
- "label": f"COMPROMISO sin cierre >{DEBT_SLA_HOURS}h — {row['subject'] or row['email_id']} [{row['email_id']}]",
983
- "detail": f"commitment desde {row['last_commitment_ts']}",
982
+ "label": f"COMMITMENT without closure >{DEBT_SLA_HOURS}h — {row['subject'] or row['email_id']} [{row['email_id']}]",
983
+ "detail": f"commitment since {row['last_commitment_ts']}",
984
984
  }
985
985
  )
986
986
 
@@ -1932,13 +1932,13 @@ def _localized_operator_escalation_email(
1932
1932
  details: str,
1933
1933
  ) -> tuple[str, str]:
1934
1934
  if _uses_spanish(operator_language):
1935
- subject = f"[NEXO] Emails que necesitan atención manual ({exhausted_count})"
1935
+ subject = f"[NEXO] Emails requiring manual attention ({exhausted_count})"
1936
1936
  body = (
1937
- f"Hola {operator_name},\n\n"
1938
- f"Los siguientes emails ya se han intentado {MAX_EMAIL_ATTEMPTS} veces "
1939
- f"sin completarse correctamente (la sesión cae antes de terminar):\n\n{details}\n\n"
1940
- "Los he marcado como `needs_interactive`. "
1941
- f"Abre {assistant_name} Desktop y pregúntale por ese email para resolverlo manualmente.\n\n"
1937
+ f"Hello {operator_name},\n\n"
1938
+ f"The following emails have already been attempted {MAX_EMAIL_ATTEMPTS} times "
1939
+ f"without succeeding (the session dies before completion):\n\n{details}\n\n"
1940
+ "I marked them as `needs_interactive`. "
1941
+ f"Open {assistant_name} Desktop and ask about the affected email so it can be resolved manually.\n\n"
1942
1942
  f"— {assistant_name}"
1943
1943
  )
1944
1944
  return subject, body
@@ -2494,9 +2494,9 @@ def main():
2494
2494
  return
2495
2495
 
2496
2496
  config = load_config()
2497
- # v0.32.5 — exit cleanly cuando no hay email setup.
2498
- # Antes el FileNotFoundError propagaba a 1440 errores/día por
2499
- # cliente Win sin email. Ahora salimos en silencio.
2497
+ # v0.32.5 — exit cleanly when no email setup exists. Before this,
2498
+ # FileNotFoundError bubbled up to 1,440 errors/day per Windows client
2499
+ # without email. Now the monitor exits silently.
2500
2500
  if config is None:
2501
2501
  log.info("No email config — skipping monitor check.")
2502
2502
  return
@@ -5,8 +5,9 @@ NEXO Followup Hygiene — Weekly cleanup of followup/reminder statuses.
5
5
 
6
6
  Runs Sundays via LaunchAgent (or manually). Tasks:
7
7
  1. Normalize dirty statuses (COMPLETED YYYY-MM-DD -> COMPLETED)
8
- 2. Escalate PENDING followups >14 days without updates to needs_decision
9
- 3. Generate summary of orphaned/forgotten followups for synthesis
8
+ 2. Move PENDING followups >14 days without updates to stale_review
9
+ 3. Expire stale_review followups that stayed untouched for the TTL window
10
+ 4. Generate summary of orphaned/forgotten followups for synthesis
10
11
 
11
12
  No CLI needed — this is pure mechanical cleanup.
12
13
  """
@@ -32,6 +33,7 @@ COORD_DIR = paths.coordination_dir()
32
33
  LOG_FILE = paths.logs_dir() / "followup-hygiene.log"
33
34
 
34
35
  TODAY = date.today().isoformat()
36
+ EXPIRE_STALE_REVIEW_DAYS = int(os.environ.get("NEXO_FOLLOWUP_EXPIRE_STALE_REVIEW_DAYS", "90") or "90")
35
37
 
36
38
 
37
39
  def log(msg):
@@ -91,7 +93,7 @@ def main():
91
93
  stale = conn.execute(
92
94
  "SELECT id, description, date, updated_at FROM followups "
93
95
  "WHERE status NOT LIKE 'COMPLETED%' "
94
- "AND status NOT IN ('DELETED','archived','blocked','waiting','needs_decision','waiting_user') "
96
+ "AND UPPER(COALESCE(status, '')) NOT IN ('DELETED','ARCHIVED','BLOCKED','WAITING','WAITING_EXTERNAL','NEEDS_DECISION','WAITING_USER','PARKED','STALE_REVIEW','EXPIRED','DONE') "
95
97
  "AND date != '' AND date < ? "
96
98
  "AND (updated_at IS NULL OR updated_at = '' OR updated_at < ?) "
97
99
  "ORDER BY date",
@@ -106,23 +108,51 @@ def main():
106
108
  for s in stale:
107
109
  result = nexo_db.update_followup(
108
110
  str(s["id"]),
109
- status="needs_decision",
111
+ status="stale_review",
110
112
  date=TODAY,
111
113
  history_actor="followup-hygiene",
112
114
  history_event="stale_triage",
113
115
  history_note=(
114
- "Weekly hygiene escalated this old due followup to needs_decision "
116
+ "Weekly hygiene moved this old due followup to stale_review "
115
117
  "instead of leaving it in the executable briefing indefinitely."
116
118
  ),
117
119
  )
118
120
  if not result.get("error"):
119
121
  escalated_stale.append(str(s["id"]))
120
122
 
121
- # 3. Orphaned followups (no date, no recent update)
123
+ # 3. Expire stale_review followups that received no decision for the TTL window
124
+ expire_cutoff = datetime.now().timestamp() - (max(1, EXPIRE_STALE_REVIEW_DAYS) * 24 * 60 * 60)
125
+ expirable = conn.execute(
126
+ "SELECT id, description, updated_at FROM followups "
127
+ "WHERE UPPER(COALESCE(status, '')) = 'STALE_REVIEW' "
128
+ "AND (updated_at IS NULL OR updated_at = '' OR updated_at < ?) "
129
+ "ORDER BY updated_at ASC",
130
+ (expire_cutoff,),
131
+ ).fetchall()
132
+ expired_ids = []
133
+ if expirable:
134
+ log(f"Expiring {len(expirable)} stale_review followups untouched for >{EXPIRE_STALE_REVIEW_DAYS} days:")
135
+ for item in expirable[:10]:
136
+ log(f" {item['id']}: {item['description'][:60]}")
137
+ for item in expirable:
138
+ result = nexo_db.update_followup(
139
+ str(item["id"]),
140
+ status="expired",
141
+ history_actor="followup-hygiene",
142
+ history_event="expired",
143
+ history_note=(
144
+ f"Weekly hygiene expired stale_review followup after "
145
+ f"{EXPIRE_STALE_REVIEW_DAYS} days without movement."
146
+ ),
147
+ )
148
+ if not result.get("error"):
149
+ expired_ids.append(str(item["id"]))
150
+
151
+ # 4. Orphaned followups (no date, no recent update)
122
152
  orphans = conn.execute(
123
153
  "SELECT id, description FROM followups "
124
154
  "WHERE status NOT LIKE 'COMPLETED%' "
125
- "AND status NOT IN ('DELETED','archived','blocked','waiting') "
155
+ "AND UPPER(COALESCE(status, '')) NOT IN ('DELETED','ARCHIVED','BLOCKED','WAITING','WAITING_EXTERNAL','NEEDS_DECISION','WAITING_USER','PARKED','STALE_REVIEW','EXPIRED','DONE') "
126
156
  "AND (date IS NULL OR date = '') "
127
157
  "ORDER BY id"
128
158
  ).fetchall()
@@ -141,9 +171,11 @@ def main():
141
171
  "dirty_normalized": dirty_f + dirty_r,
142
172
  "stale_count": len(stale) if stale else 0,
143
173
  "stale_escalated_count": len(escalated_stale),
174
+ "expired_count": len(expired_ids),
144
175
  "orphan_count": len(orphans) if orphans else 0,
145
176
  "stale_ids": [s["id"] for s in stale[:20]] if stale else [],
146
177
  "stale_escalated_ids": escalated_stale[:20],
178
+ "expired_ids": expired_ids[:20],
147
179
  "orphan_ids": [o["id"] for o in orphans[:20]] if orphans else [],
148
180
  }
149
181
 
@@ -151,7 +183,7 @@ def main():
151
183
  summary_file.parent.mkdir(parents=True, exist_ok=True)
152
184
  summary_file.write_text(json.dumps(summary, indent=2))
153
185
 
154
- log(f"Summary: {dirty_f + dirty_r} normalized, {len(escalated_stale)} stale escalated, {len(orphans) if orphans else 0} orphans")
186
+ log(f"Summary: {dirty_f + dirty_r} normalized, {len(escalated_stale)} stale escalated, {len(expired_ids)} expired, {len(orphans) if orphans else 0} orphans")
155
187
  log("=== Followup Hygiene complete ===")
156
188
 
157
189