nexo-brain 7.23.13 → 7.24.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 +13 -11
  3. package/bin/nexo-brain.js +42 -235
  4. package/package.json +1 -1
  5. package/src/automation_supervisor.py +1 -1
  6. package/src/cli.py +255 -9
  7. package/src/cognitive_control_observatory.py +224 -0
  8. package/src/dashboard/app.py +26 -9
  9. package/src/db/__init__.py +2 -0
  10. package/src/db/_learnings.py +1 -1
  11. package/src/db/_memory_v2.py +107 -1
  12. package/src/db/_protocol.py +2 -2
  13. package/src/db/_reminders.py +132 -4
  14. package/src/db/_schema.py +2 -2
  15. package/src/events_bus.py +4 -5
  16. package/src/learning_resolver.py +419 -0
  17. package/src/lifecycle_events.py +9 -9
  18. package/src/local_context/api.py +67 -5
  19. package/src/local_context/usage_events.py +24 -0
  20. package/src/memory_observation_processor.py +28 -0
  21. package/src/memory_retrieval.py +5 -5
  22. package/src/operator_language.py +2 -0
  23. package/src/plugins/backup.py +1 -1
  24. package/src/plugins/cortex.py +21 -21
  25. package/src/plugins/episodic_memory.py +11 -11
  26. package/src/plugins/goal_engine.py +3 -3
  27. package/src/plugins/personal_scripts.py +75 -0
  28. package/src/plugins/protocol.py +10 -1
  29. package/src/pre_answer_router.py +116 -0
  30. package/src/r_catalog.py +4 -5
  31. package/src/saved_not_used_audit.py +31 -31
  32. package/src/script_registry.py +444 -1
  33. package/src/scripts/deep-sleep/apply_findings.py +79 -17
  34. package/src/scripts/nexo-daily-self-audit.py +46 -13
  35. package/src/scripts/nexo-email-migrate-config.py +2 -2
  36. package/src/scripts/nexo-email-monitor.py +19 -19
  37. package/src/scripts/nexo-followup-hygiene.py +40 -8
  38. package/src/scripts/nexo-followup-runner.py +31 -31
  39. package/src/scripts/nexo-inbox-hook.sh +1 -1
  40. package/src/scripts/nexo-learning-validator.py +24 -3
  41. package/src/server.py +73 -1
  42. package/src/system_catalog.py +31 -31
  43. package/src/tools_learnings.py +96 -65
  44. package/src/tools_memory_v2.py +2 -2
  45. package/src/tools_sessions.py +25 -7
  46. package/templates/core-prompts/postmortem-consolidator.md +3 -3
  47. package/templates/core-prompts/r17-promise-debt-injection.md +1 -1
  48. package/templates/core-prompts/server-mcp-instructions.md +6 -6
  49. 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,
@@ -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
 
@@ -73,7 +73,7 @@ RESULTS_FILE = data_dir() / "followup-runner-results.json"
73
73
  CLI_TIMEOUT = AUTOMATION_SUBPROCESS_TIMEOUT
74
74
  LOCK_FILE = LOG_DIR / "followup-runner.lock"
75
75
  MAX_FOLLOWUPS_PER_RUN = 5 # Focus: Opus can actually execute 5, not 30
76
- COOLDOWN_DAYS = 3 # Don't retry needs_decision/blocked for 3 days
76
+ COOLDOWN_DAYS = 3 # Don't retry waiting_user/stale_review/blocked for 3 days
77
77
  STALE_FOLLOWUP_TRIAGE_DAYS = 14
78
78
  MAX_STALE_TRIAGE_PER_RUN = 8
79
79
  MAX_NEEDS_OPERATOR_BRIEFING = 12
@@ -134,7 +134,7 @@ def _history_has_recent_movement(history, *, days: int = STALE_FOLLOWUP_TRIAGE_D
134
134
 
135
135
  def _is_stale_followup_for_triage(followup: dict) -> bool:
136
136
  status = str(followup.get("status") or "").strip().lower()
137
- if status in {"needs_decision", "waiting_user", "blocked", "waiting"}:
137
+ if status in {"needs_decision", "waiting_user", "blocked", "waiting", "stale_review"}:
138
138
  return False
139
139
  if _followup_days_overdue(str(followup.get("date") or "")) < STALE_FOLLOWUP_TRIAGE_DAYS:
140
140
  return False
@@ -148,7 +148,7 @@ def _is_in_cooldown(fu_id: str, state: dict) -> bool:
148
148
  if not last:
149
149
  return False
150
150
  last_status = last.get("status", "")
151
- if last_status not in ("needs_decision", "blocked"):
151
+ if last_status not in ("needs_decision", "waiting_user", "stale_review", "blocked"):
152
152
  return False
153
153
  last_date_str = last.get("date", "")
154
154
  if not last_date_str:
@@ -298,17 +298,17 @@ def get_all_active_followups(state: dict) -> dict:
298
298
  conn = sqlite3.connect(str(NEXO_DB))
299
299
  conn.row_factory = sqlite3.Row
300
300
  try:
301
- rows = conn.execute(
302
- "SELECT id, description, date, reasoning, verification, priority, recurrence, status, owner, updated_at "
303
- "FROM followups WHERE status NOT LIKE 'COMPLETED%' "
304
- "AND UPPER(COALESCE(status, '')) NOT IN ('BLOCKED', 'ARCHIVED', 'DELETED', 'WAITING', 'DONE') "
305
- "AND description NOT LIKE '[Abandoned]%' "
306
- "ORDER BY "
307
- " CASE priority "
308
- " WHEN 'critical' THEN 1 WHEN 'high' THEN 2 "
309
- " WHEN 'medium' THEN 3 WHEN 'low' THEN 4 ELSE 5 END, "
310
- " date ASC"
311
- ).fetchall()
301
+ snapshot = nexo_db.followup_lifecycle_snapshot(limit=5000)
302
+ rows = [
303
+ item for item in (snapshot.get("lanes") or {}).get("active", [])
304
+ if not str(item.get("description") or "").startswith("[Abandoned]")
305
+ ]
306
+ rows.sort(
307
+ key=lambda item: (
308
+ {"critical": 1, "high": 2, "medium": 3, "low": 4}.get(str(item.get("priority") or "medium"), 5),
309
+ str(item.get("date") or "9999-12-31"),
310
+ )
311
+ )
312
312
 
313
313
  result = {"actionable": [], "needs_operator": [], "future": [], "backlog": [], "cooled_down": [], "stale_triage": []}
314
314
  undated_triage_budget = 2
@@ -429,7 +429,7 @@ def complete_followup_if_needed(fu_id: str, result_summary: str = ""):
429
429
  return
430
430
  try:
431
431
  nexo_db.complete_followup(fu_id, result_summary)
432
- log(f" {fu_id}: marcado completado por el runner")
432
+ log(f" {fu_id}: marked completed por el runner")
433
433
  except Exception as exc:
434
434
  log(f" {fu_id}: failed to mark followup as completed ({exc})")
435
435
 
@@ -488,7 +488,7 @@ def attention_reminder_id(fu_id: str) -> str:
488
488
 
489
489
 
490
490
  def attention_reminder_category(status: str) -> str:
491
- return "decisions" if status == "needs_decision" else "waiting"
491
+ return "decisions" if status in {"needs_decision", "waiting_user", "stale_review"} else "waiting"
492
492
 
493
493
 
494
494
  def attention_reminder_description(
@@ -502,14 +502,14 @@ def attention_reminder_description(
502
502
  detail = " ".join((summary or "").split())
503
503
  if not detail:
504
504
  detail = (
505
- "El runner no puede cerrar este punto sin intervención del operador."
505
+ "The runner cannot close this item without operator input."
506
506
  if _uses_spanish(operator_language)
507
507
  else "The runner cannot close this item without operator input."
508
508
  )
509
509
  description = f"{fu_id}: {detail}"
510
510
  opts_text = render_options(options)
511
511
  if opts_text:
512
- description += f" {'Opciones' if _uses_spanish(operator_language) else 'Options'}: {opts_text}"
512
+ description += f" {'Options' if _uses_spanish(operator_language) else 'Options'}: {opts_text}"
513
513
  return description[:480]
514
514
 
515
515
 
@@ -548,7 +548,7 @@ def upsert_attention_reminder(
548
548
  log(f" {fu_id}: failed to update reminder {reminder_id} ({result['error']})")
549
549
  return
550
550
  nexo_db.add_reminder_note(reminder_id, description, actor="followup-runner")
551
- log(f" {fu_id}: reminder {reminder_id} actualizado para orchestrator")
551
+ log(f" {fu_id}: reminder {reminder_id} updated for orchestrator")
552
552
  return
553
553
 
554
554
  result = nexo_db.create_reminder(
@@ -565,7 +565,7 @@ def upsert_attention_reminder(
565
565
  f"source_followup={fu_id} status={status}",
566
566
  actor="followup-runner",
567
567
  )
568
- log(f" {fu_id}: reminder {reminder_id} creado para orchestrator")
568
+ log(f" {fu_id}: reminder {reminder_id} created for orchestrator")
569
569
 
570
570
 
571
571
  def resolve_attention_reminder(fu_id: str, *, resolution: str = ""):
@@ -579,14 +579,14 @@ def resolve_attention_reminder(fu_id: str, *, resolution: str = ""):
579
579
  if resolution:
580
580
  nexo_db.add_reminder_note(
581
581
  reminder_id,
582
- f"Resuelto desde {fu_id}: {resolution[:300]}",
582
+ f"Resolved from {fu_id}: {resolution[:300]}",
583
583
  actor="followup-runner",
584
584
  )
585
585
  result = nexo_db.complete_reminder(reminder_id)
586
586
  if result.get("error"):
587
587
  log(f" {fu_id}: failed to complete reminder {reminder_id} ({result['error']})")
588
588
  return
589
- log(f" {fu_id}: reminder {reminder_id} marcado completado")
589
+ log(f" {fu_id}: reminder {reminder_id} marked completed")
590
590
 
591
591
 
592
592
  def defer_followup_after_attention(
@@ -602,7 +602,7 @@ def defer_followup_after_attention(
602
602
  details = summary.strip()
603
603
  opts_text = render_options(options)
604
604
  if opts_text:
605
- details = f"{details}\nOpciones: {opts_text}"
605
+ details = f"{details}\nOptions: {opts_text}"
606
606
  if details:
607
607
  note_result = nexo_db.add_followup_note(
608
608
  fu_id,
@@ -690,10 +690,10 @@ def get_recent_activity(hours: int = 24) -> str:
690
690
 
691
691
  # Recent followup notes from the runner
692
692
  notes = conn.execute(
693
- "SELECT followup_id, note, created_at FROM followup_history "
694
- "WHERE actor='followup-runner' AND created_at >= datetime('now', ?)"
693
+ "SELECT item_id AS followup_id, note, created_at FROM item_history "
694
+ "WHERE item_type='followup' AND actor='followup-runner' AND created_at >= ? "
695
695
  "ORDER BY created_at DESC LIMIT 10",
696
- (f"-{hours} hours",)
696
+ ((datetime.now() - timedelta(hours=hours)).timestamp(),),
697
697
  ).fetchall()
698
698
  if notes:
699
699
  lines.append("\nFOLLOWUP NOTES WRITTEN (last 24h):")
@@ -821,7 +821,7 @@ def main():
821
821
  update_followup_fields(
822
822
  fid,
823
823
  date_value=date.today().isoformat(),
824
- status="needs_decision",
824
+ status="stale_review",
825
825
  history_event="stale_triage",
826
826
  history_note=summary,
827
827
  )
@@ -829,10 +829,10 @@ def main():
829
829
  fid,
830
830
  summary=summary,
831
831
  options={"a": "close obsolete", "b": "reschedule", "c": "convert to next action"},
832
- status="needs_decision",
832
+ status="stale_review",
833
833
  operator_language=_operator_language(),
834
834
  )
835
- record_attempt(state, fid, "needs_decision")
835
+ record_attempt(state, fid, "stale_review")
836
836
 
837
837
  results = []
838
838
 
@@ -914,7 +914,7 @@ def main():
914
914
  advance_recurrent(fid, recurrence, summary)
915
915
  resolve_attention_reminder(fid, resolution=summary)
916
916
  record_attempt(state, fid, "checked")
917
- elif r["status"] in ("needs_decision", "blocked"):
917
+ elif r["status"] in ("needs_decision", "waiting_user", "stale_review", "blocked"):
918
918
  defer_followup_after_attention(
919
919
  fid,
920
920
  summary=summary,
@@ -929,7 +929,7 @@ def main():
929
929
 
930
930
  total = len(all_actionable) + len(groups["needs_operator"]) + len(groups["future"]) + len(groups["backlog"]) + len(stale_triage)
931
931
  attention_handed_off = any(
932
- r.get("needs_attention") or r["status"] in ("needs_decision", "blocked")
932
+ r.get("needs_attention") or r["status"] in ("needs_decision", "waiting_user", "stale_review", "blocked")
933
933
  for r in results
934
934
  )
935
935
  if total > 0 or results:
@@ -65,7 +65,7 @@ if [ -n "$MESSAGES" ]; then
65
65
  fi
66
66
 
67
67
  if [ -n "$QUESTIONS" ]; then
68
- echo " ⚠ PREGUNTAS de otra terminal — responder con nexo_answer:"
68
+ echo " ⚠ QUESTIONS from another terminal — answer with nexo_answer:"
69
69
  echo "$QUESTIONS" | while IFS='|' read -r qid from question; do
70
70
  echo " Q[$qid] de [$from]: $question"
71
71
  done