nexo-brain 7.32.0 → 7.34.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.
@@ -0,0 +1,290 @@
1
+ """Build safe HTML previews before real WhatsApp/email batch sends.
2
+
3
+ This module is intentionally send-agnostic: it reads code/log/queue artifacts,
4
+ separates internal or test messages from deliverable candidates, renders a
5
+ sanitized HTML review document, and enforces a hard cap on real sends.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import json
12
+ import re
13
+ from dataclasses import dataclass
14
+ from pathlib import Path
15
+ from typing import Any, Iterable
16
+
17
+ from email_presentation import compose_html_document, text_to_html_fragment
18
+ from tools_email_guard import should_block_email_send
19
+
20
+
21
+ DEFAULT_REAL_SEND_LIMIT = 10
22
+ INTERNAL_MARKERS = (
23
+ "[internal]",
24
+ "internal:",
25
+ "nexo_internal",
26
+ "solo interno",
27
+ "nota interna",
28
+ "mensaje interno",
29
+ "test:",
30
+ "[test]",
31
+ "dry-run",
32
+ "dry_run",
33
+ "prueba",
34
+ )
35
+ TEST_RECIPIENT_PATTERNS = (
36
+ re.compile(r"(^|@)(example|test|localhost)(\.|$)", re.I),
37
+ re.compile(r"\+test\b", re.I),
38
+ re.compile(r"^(?:0+|123456789|600000000)$"),
39
+ )
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class PreviewMessage:
44
+ source: str
45
+ channel: str
46
+ recipient: str
47
+ body: str
48
+ subject: str = ""
49
+ metadata: dict[str, Any] | None = None
50
+
51
+ @property
52
+ def fingerprint(self) -> str:
53
+ base = "\x1f".join([
54
+ self.channel.strip().lower(),
55
+ self.recipient.strip().lower(),
56
+ self.subject.strip(),
57
+ " ".join(self.body.split()),
58
+ ])
59
+ return str(abs(hash(base)))
60
+
61
+
62
+ @dataclass(frozen=True)
63
+ class PreviewResult:
64
+ deliverable: list[PreviewMessage]
65
+ internal_or_test: list[PreviewMessage]
66
+ blocked: list[dict[str, str]]
67
+ real_send_limit: int
68
+
69
+ @property
70
+ def capped_deliverable(self) -> list[PreviewMessage]:
71
+ return self.deliverable[: self.real_send_limit]
72
+
73
+ @property
74
+ def over_limit_count(self) -> int:
75
+ return max(0, len(self.deliverable) - self.real_send_limit)
76
+
77
+ def to_dict(self) -> dict[str, Any]:
78
+ return {
79
+ "deliverable_count": len(self.deliverable),
80
+ "capped_deliverable_count": len(self.capped_deliverable),
81
+ "internal_or_test_count": len(self.internal_or_test),
82
+ "blocked_count": len(self.blocked),
83
+ "real_send_limit": self.real_send_limit,
84
+ "over_limit_count": self.over_limit_count,
85
+ "deliverable": [_message_to_dict(m) for m in self.capped_deliverable],
86
+ "internal_or_test": [_message_to_dict(m) for m in self.internal_or_test],
87
+ "blocked": self.blocked,
88
+ }
89
+
90
+
91
+ def _message_to_dict(message: PreviewMessage) -> dict[str, Any]:
92
+ return {
93
+ "source": message.source,
94
+ "channel": message.channel,
95
+ "recipient": message.recipient,
96
+ "subject": message.subject,
97
+ "body": message.body,
98
+ "metadata": message.metadata or {},
99
+ "fingerprint": message.fingerprint,
100
+ }
101
+
102
+
103
+ def read_messages(paths: Iterable[Path | str]) -> list[PreviewMessage]:
104
+ messages: list[PreviewMessage] = []
105
+ for raw_path in paths:
106
+ path = Path(raw_path)
107
+ if not path.exists() or not path.is_file():
108
+ raise FileNotFoundError(str(path))
109
+ text = path.read_text(encoding="utf-8", errors="replace")
110
+ messages.extend(_parse_artifact(path, text))
111
+ return messages
112
+
113
+
114
+ def _parse_artifact(path: Path, text: str) -> list[PreviewMessage]:
115
+ stripped = text.strip()
116
+ if not stripped:
117
+ return []
118
+ if path.suffix.lower() == ".jsonl":
119
+ rows = [json.loads(line) for line in stripped.splitlines() if line.strip()]
120
+ return [_row_to_message(row, path, index) for index, row in enumerate(rows, start=1)]
121
+ if path.suffix.lower() == ".json":
122
+ payload = json.loads(stripped)
123
+ if isinstance(payload, list):
124
+ rows = payload
125
+ elif isinstance(payload, dict):
126
+ rows = payload.get("messages") or payload.get("items") or payload.get("queue") or [payload]
127
+ else:
128
+ rows = []
129
+ return [_row_to_message(row, path, index) for index, row in enumerate(rows, start=1) if isinstance(row, dict)]
130
+ return [PreviewMessage(source=str(path), channel="log", recipient="", body=stripped)]
131
+
132
+
133
+ def _row_to_message(row: dict[str, Any], path: Path, index: int) -> PreviewMessage:
134
+ recipient = str(
135
+ row.get("recipient")
136
+ or row.get("to")
137
+ or row.get("phone")
138
+ or row.get("email")
139
+ or ""
140
+ ).strip()
141
+ body = str(
142
+ row.get("body")
143
+ or row.get("message")
144
+ or row.get("text")
145
+ or row.get("html")
146
+ or ""
147
+ ).strip()
148
+ channel = str(row.get("channel") or row.get("type") or _infer_channel(recipient)).strip().lower()
149
+ subject = str(row.get("subject") or "").strip()
150
+ return PreviewMessage(
151
+ source=f"{path}:{index}",
152
+ channel=channel or "unknown",
153
+ recipient=recipient,
154
+ subject=subject,
155
+ body=body,
156
+ metadata={k: v for k, v in row.items() if k not in {"body", "message", "text", "html"}},
157
+ )
158
+
159
+
160
+ def _infer_channel(recipient: str) -> str:
161
+ if "@" in recipient:
162
+ return "email"
163
+ if recipient:
164
+ return "whatsapp"
165
+ return "unknown"
166
+
167
+
168
+ def is_internal_or_test(message: PreviewMessage) -> bool:
169
+ haystack = " ".join([
170
+ message.channel,
171
+ message.recipient,
172
+ message.subject,
173
+ message.body,
174
+ json.dumps(message.metadata or {}, ensure_ascii=False, sort_keys=True),
175
+ ]).lower()
176
+ if any(marker in haystack for marker in INTERNAL_MARKERS):
177
+ return True
178
+ recipient = message.recipient.strip()
179
+ return any(pattern.search(recipient) for pattern in TEST_RECIPIENT_PATTERNS)
180
+
181
+
182
+ def build_preview(messages: Iterable[PreviewMessage], *, real_send_limit: int = DEFAULT_REAL_SEND_LIMIT) -> PreviewResult:
183
+ if real_send_limit < 1:
184
+ raise ValueError("real_send_limit must be >= 1")
185
+ deliverable: list[PreviewMessage] = []
186
+ internal_or_test: list[PreviewMessage] = []
187
+ blocked: list[dict[str, str]] = []
188
+ seen: set[str] = set()
189
+
190
+ for message in messages:
191
+ if is_internal_or_test(message):
192
+ internal_or_test.append(message)
193
+ continue
194
+ blocked_by_secret, reason = should_block_email_send(
195
+ "\n".join([message.subject, message.body, json.dumps(message.metadata or {}, ensure_ascii=False)])
196
+ )
197
+ if blocked_by_secret:
198
+ blocked.append({"source": message.source, "recipient": message.recipient, "reason": reason})
199
+ continue
200
+ if message.fingerprint in seen:
201
+ blocked.append({"source": message.source, "recipient": message.recipient, "reason": "duplicate message"})
202
+ continue
203
+ seen.add(message.fingerprint)
204
+ deliverable.append(message)
205
+
206
+ return PreviewResult(
207
+ deliverable=deliverable,
208
+ internal_or_test=internal_or_test,
209
+ blocked=blocked,
210
+ real_send_limit=real_send_limit,
211
+ )
212
+
213
+
214
+ def render_preview_html(result: PreviewResult) -> str:
215
+ parts = [
216
+ "<h1>Previsualización de lote</h1>",
217
+ "<table><tbody>",
218
+ f"<tr><th>Enviables</th><td>{len(result.deliverable)}</td></tr>",
219
+ f"<tr><th>Incluidos por límite</th><td>{len(result.capped_deliverable)}</td></tr>",
220
+ f"<tr><th>Internos/tests separados</th><td>{len(result.internal_or_test)}</td></tr>",
221
+ f"<tr><th>Bloqueados</th><td>{len(result.blocked)}</td></tr>",
222
+ f"<tr><th>Exceso de lote</th><td>{result.over_limit_count}</td></tr>",
223
+ "</tbody></table>",
224
+ "<h2>Candidatos a envío real</h2>",
225
+ _render_message_list(result.capped_deliverable),
226
+ "<h2>Separados: internos/tests</h2>",
227
+ _render_message_list(result.internal_or_test),
228
+ "<h2>Bloqueados</h2>",
229
+ _render_blocked(result.blocked),
230
+ ]
231
+ return compose_html_document("".join(parts))
232
+
233
+
234
+ def _render_message_list(messages: list[PreviewMessage]) -> str:
235
+ if not messages:
236
+ return "<p>Ninguno.</p>"
237
+ rows = []
238
+ for message in messages:
239
+ body = text_to_html_fragment(message.body[:1200])
240
+ rows.append(
241
+ "<tr>"
242
+ f"<td>{text_to_html_fragment(message.channel)}</td>"
243
+ f"<td>{text_to_html_fragment(message.recipient or '(sin destinatario)')}</td>"
244
+ f"<td>{text_to_html_fragment(message.subject or message.source)}</td>"
245
+ f"<td>{body}</td>"
246
+ "</tr>"
247
+ )
248
+ return "<table><thead><tr><th>Canal</th><th>Destino</th><th>Asunto/fuente</th><th>Mensaje</th></tr></thead><tbody>" + "".join(rows) + "</tbody></table>"
249
+
250
+
251
+ def _render_blocked(blocked: list[dict[str, str]]) -> str:
252
+ if not blocked:
253
+ return "<p>Ninguno.</p>"
254
+ rows = [
255
+ "<tr>"
256
+ f"<td>{text_to_html_fragment(item.get('source', ''))}</td>"
257
+ f"<td>{text_to_html_fragment(item.get('recipient', ''))}</td>"
258
+ f"<td>{text_to_html_fragment(item.get('reason', ''))}</td>"
259
+ "</tr>"
260
+ for item in blocked
261
+ ]
262
+ return "<table><thead><tr><th>Fuente</th><th>Destino</th><th>Motivo</th></tr></thead><tbody>" + "".join(rows) + "</tbody></table>"
263
+
264
+
265
+ def main(argv: list[str] | None = None) -> int:
266
+ parser = argparse.ArgumentParser(description="Generate a safe HTML preview for WhatsApp/email batch candidates.")
267
+ parser.add_argument("paths", nargs="+", help="JSON, JSONL, log, or text artifacts to inspect.")
268
+ parser.add_argument("--limit", type=int, default=DEFAULT_REAL_SEND_LIMIT, help="Maximum real sends allowed in one batch.")
269
+ parser.add_argument("--html-out", required=True, help="Destination HTML preview file.")
270
+ parser.add_argument("--json-out", default="", help="Optional JSON summary destination.")
271
+ args = parser.parse_args(argv)
272
+
273
+ result = build_preview(read_messages(args.paths), real_send_limit=args.limit)
274
+ Path(args.html_out).write_text(render_preview_html(result), encoding="utf-8")
275
+ if args.json_out:
276
+ Path(args.json_out).write_text(json.dumps(result.to_dict(), indent=2, ensure_ascii=False), encoding="utf-8")
277
+ print(json.dumps({
278
+ "html_out": args.html_out,
279
+ "json_out": args.json_out,
280
+ "deliverable": len(result.deliverable),
281
+ "capped_deliverable": len(result.capped_deliverable),
282
+ "internal_or_test": len(result.internal_or_test),
283
+ "blocked": len(result.blocked),
284
+ "over_limit": result.over_limit_count,
285
+ }, ensure_ascii=False))
286
+ return 0
287
+
288
+
289
+ if __name__ == "__main__":
290
+ raise SystemExit(main())
@@ -157,7 +157,10 @@ def _requires_external_real_world_check(task: dict, *parts: str) -> bool:
157
157
  if str(task.get("task_type") or "").strip() not in ACTION_TASKS:
158
158
  return False
159
159
  text = _external_real_world_text(task, *parts)
160
- return any(keyword in text for keyword in EXTERNAL_REAL_WORLD_ACTION_KEYWORDS)
160
+ return any(
161
+ _contains_external_action_keyword(text, keyword)
162
+ for keyword in EXTERNAL_REAL_WORLD_ACTION_KEYWORDS
163
+ )
161
164
 
162
165
 
163
166
  def _has_external_real_world_evidence(text: str) -> bool:
@@ -169,6 +172,18 @@ def _has_external_real_world_evidence(text: str) -> bool:
169
172
  return has_verify_verb and has_artifact
170
173
 
171
174
 
175
+ def _contains_external_action_keyword(text: str, keyword: str) -> bool:
176
+ clean_text = str(text or "").lower()
177
+ clean_keyword = str(keyword or "").lower().strip()
178
+ if not clean_text or not clean_keyword:
179
+ return False
180
+ return re.search(
181
+ rf"(?<![a-z0-9]){re.escape(clean_keyword)}(?![a-z0-9])",
182
+ clean_text,
183
+ re.IGNORECASE,
184
+ ) is not None
185
+
186
+
172
187
  ACTION_TASKS = {"edit", "execute", "delegate"}
173
188
  RESPONSE_TASKS = {"answer", "analyze"}
174
189
  _GUARD_TOUCH_DEBT_TYPES = {
@@ -1146,13 +1161,16 @@ def _capture_learning(
1146
1161
  content: str,
1147
1162
  reasoning: str,
1148
1163
  priority: str = "high",
1164
+ prevention: str = "",
1165
+ applies_to_override: str = "",
1166
+ source_authority: str = "explicit_instruction",
1149
1167
  ) -> dict:
1150
1168
  from tools_learnings import find_conflicting_active_learning, handle_learning_add
1151
1169
 
1152
1170
  clean_title = (title or "").strip()[:120]
1153
1171
  clean_content = (content or "").strip()
1154
1172
  clean_reasoning = (reasoning or f"Captured from protocol task {task_id}").strip()
1155
- applies_to = ",".join(effective_files)
1173
+ applies_to = applies_to_override.strip() if applies_to_override.strip() else ",".join(effective_files)
1156
1174
  if not clean_title or not clean_content:
1157
1175
  return {"ok": False, "error": "insufficient context for learning capture"}
1158
1176
 
@@ -1168,9 +1186,11 @@ def _capture_learning(
1168
1186
  title=clean_title,
1169
1187
  content=clean_content,
1170
1188
  reasoning=clean_reasoning,
1189
+ prevention=prevention,
1171
1190
  applies_to=applies_to,
1172
1191
  priority=priority,
1173
1192
  supersedes_id=supersedes_id,
1193
+ source_authority=source_authority,
1174
1194
  )
1175
1195
  match = re.search(r"Learning #(\d+) added", response)
1176
1196
  if match:
@@ -1180,6 +1200,20 @@ def _capture_learning(
1180
1200
  "response": response,
1181
1201
  "superseded_id": supersedes_id or None,
1182
1202
  }
1203
+ # A near/exact duplicate is a SUCCESSFUL no-op merge — the learning already
1204
+ # exists and no duplicate row was created (handle_learning_add returns
1205
+ # "already exists" / "resolved as merge"). Treat it as success so idempotent
1206
+ # re-captures (e.g. the same self-detected error twice) do not report a
1207
+ # phantom learning_ok=False in the close-response telemetry.
1208
+ dedup = re.search(r"Learning #(\d+) (?:already exists|resolved as merge)", response)
1209
+ if dedup:
1210
+ return {
1211
+ "ok": True,
1212
+ "deduped": True,
1213
+ "id": int(dedup.group(1)),
1214
+ "response": response,
1215
+ "superseded_id": supersedes_id or None,
1216
+ }
1183
1217
  return {
1184
1218
  "ok": False,
1185
1219
  "error": response,
@@ -1217,6 +1251,136 @@ def _auto_capture_learning(task: dict, task_id: str, effective_files: list[str],
1217
1251
  )
1218
1252
 
1219
1253
 
1254
+ # ── Forgotten-step followup detector (objective omission markers) ──────
1255
+ _FORGOTTEN_STEP_FOLLOWUP_RE = re.compile(
1256
+ r"\b(?:forgot|forgotten|missed|omitted|never (?:created|added|set up|configured|deployed|ran)|"
1257
+ r"missing (?:the )?(?:cron|step|trigger|hook|migration|index|webhook|deploy)|"
1258
+ r"olvid[éeè]|me olvid[éeè]|falt[óoa]ba?|no se (?:cre[óo]|configur[óo]|despleg[óo]|registr[óo]))\b",
1259
+ re.IGNORECASE,
1260
+ )
1261
+
1262
+
1263
+ def _followup_signals_forgotten_step(*descriptions: object) -> bool:
1264
+ """True only when a followup description objectively states an omission.
1265
+
1266
+ A generic 'verify weekly' or 'monitor X' followup must NOT count — only an
1267
+ explicit 'forgot/missing/never created the cron' style description does.
1268
+ """
1269
+ for desc in descriptions:
1270
+ text = str(desc or "").strip()
1271
+ if text and _FORGOTTEN_STEP_FOLLOWUP_RE.search(text):
1272
+ return True
1273
+ return False
1274
+
1275
+
1276
+ def _detect_and_capture_self_error(
1277
+ task: dict,
1278
+ task_id: str,
1279
+ *,
1280
+ clean_outcome: str,
1281
+ closure_text: str,
1282
+ correction: bool,
1283
+ effective_files: list[str],
1284
+ forgotten_step_followup: bool,
1285
+ debts_created: list[dict],
1286
+ ) -> dict | None:
1287
+ """Ola 2 — auto-detect that a PRIOR own action was wrong and learn from it.
1288
+
1289
+ Runs AFTER the current task is closed. Compares it against recently
1290
+ closed-as-done tasks; on high-confidence objective evidence it creates a
1291
+ learning with a concrete prevention rule (source_authority=code_test_evidence,
1292
+ NOT a Francisco correction). On low confidence it records a low-confidence
1293
+ candidate as an INFO protocol_debt — never a learning. Best-effort: any
1294
+ failure returns None and never blocks the close.
1295
+
1296
+ Returns a small dict describing what happened (for the close response), or
1297
+ None when nothing was detected / on error.
1298
+ """
1299
+ try:
1300
+ import self_error_detector as sed
1301
+ from db import list_recent_closed_tasks
1302
+
1303
+ # Only closes that actually claim progress can host / reveal a self-error.
1304
+ if clean_outcome not in {"done", "partial"}:
1305
+ return None
1306
+
1307
+ prior_tasks = list_recent_closed_tasks(
1308
+ outcome="done",
1309
+ exclude_task_id=task_id,
1310
+ within_days=sed.LOOKBACK_DAYS,
1311
+ limit=sed.MAX_PRIOR_TASKS,
1312
+ )
1313
+ if not prior_tasks:
1314
+ # Nothing previously declared done → cannot have a revealed self-error
1315
+ # from file overlap. A forgotten-step followup alone is candidate-only.
1316
+ if not forgotten_step_followup:
1317
+ return None
1318
+
1319
+ evaluation = sed.evaluate_self_error(
1320
+ current_task=task,
1321
+ prior_tasks=prior_tasks,
1322
+ closure_text=closure_text,
1323
+ correction_happened=correction,
1324
+ forgotten_step_followup=forgotten_step_followup,
1325
+ )
1326
+
1327
+ decision = evaluation.get("decision")
1328
+ if decision == "none":
1329
+ return None
1330
+
1331
+ if decision == "candidate":
1332
+ # Low-confidence: record a quiet INFO candidate, NEVER a learning.
1333
+ # Reuses the existing open-debt dedup so the same candidate does not
1334
+ # pile up across repeated closes of the same task.
1335
+ debt = _ensure_open_debt(
1336
+ task.get("session_id", ""),
1337
+ task_id,
1338
+ "self_error_candidate",
1339
+ severity="info",
1340
+ evidence=(
1341
+ f"Low-confidence self-error candidate (confidence="
1342
+ f"{evaluation.get('confidence')}, signal={evaluation.get('signal')}). "
1343
+ f"{'; '.join(evaluation.get('reasons') or [])[:400]}"
1344
+ ),
1345
+ debts=debts_created,
1346
+ )
1347
+ return {
1348
+ "decision": "candidate",
1349
+ "confidence": evaluation.get("confidence"),
1350
+ "signal": evaluation.get("signal"),
1351
+ "debt_id": debt.get("id"),
1352
+ }
1353
+
1354
+ # decision == "fire": create the learning with a concrete prevention.
1355
+ payload = sed.build_self_error_learning(current_task=task, evaluation=evaluation)
1356
+ learning = _capture_learning(
1357
+ task,
1358
+ task_id,
1359
+ effective_files,
1360
+ category=payload["category"],
1361
+ title=payload["title"],
1362
+ content=payload["content"],
1363
+ reasoning=payload["reasoning"],
1364
+ priority="high",
1365
+ prevention=payload["prevention"],
1366
+ applies_to_override=payload["applies_to"],
1367
+ source_authority=payload["source_authority"],
1368
+ )
1369
+ return {
1370
+ "decision": "fire",
1371
+ "confidence": evaluation.get("confidence"),
1372
+ "signal": evaluation.get("signal"),
1373
+ "prior_task_id": evaluation.get("prior_task_id"),
1374
+ "overlap_files": evaluation.get("overlap_files"),
1375
+ "learning_ok": bool(learning.get("ok")),
1376
+ "learning_id": learning.get("id"),
1377
+ "learning_error": None if learning.get("ok") else learning.get("error"),
1378
+ }
1379
+ except Exception:
1380
+ # Self-error detection is strictly best-effort; never break a close.
1381
+ return None
1382
+
1383
+
1220
1384
  def _append_debt_ref(debts: list[dict], debt: dict, *, debt_type: str, severity: str):
1221
1385
  debt_id = debt.get("id")
1222
1386
  if debt_id and any(item.get("id") == debt_id for item in debts):
@@ -2170,31 +2334,30 @@ def handle_task_close(
2170
2334
  limit=3,
2171
2335
  )
2172
2336
  if pending_corrections:
2173
- debt = _ensure_open_debt(
2174
- task["session_id"],
2175
- task_id,
2176
- "missing_learning_after_correction",
2177
- severity="error",
2178
- evidence=(
2179
- "User correction was detected for this session and has not "
2180
- "been resolved by nexo_learning_add. task_close is blocked "
2181
- "until a durable learning is persisted."
2182
- ),
2183
- debts=debts_created,
2184
- )
2185
- return json.dumps(
2186
- {
2187
- "ok": False,
2188
- "error": "Cannot close task while a detected user correction has no durable nexo_learning_add.",
2189
- "hint": "Call nexo_learning_add with the reusable rule learned from the correction, then retry nexo_task_close.",
2190
- "task_id": task_id,
2191
- "blocked_by": "d5_correction_learning_required",
2192
- "debt_id": debt.get("id"),
2193
- "pending_corrections": len(pending_corrections),
2194
- },
2195
- ensure_ascii=False,
2196
- indent=2,
2337
+ # SOFT enforcement (Ola 1): do NOT block the close. A detected user
2338
+ # correction without a durable nexo_learning_add opens/dedupes an
2339
+ # error-severity protocol_debt and the task still closes. The daily
2340
+ # self-audit + correction_requirement_summary surface the open debt, and
2341
+ # if THIS close supplies the learning, the `if correction:` block below
2342
+ # captures it and resolves both the requirement and the debt. A hard
2343
+ # block here interrupted the operator on every correction (friction);
2344
+ # the debt is the non-blocking signal instead.
2345
+ learning_in_this_close = bool(
2346
+ (learning_title or "").strip() and (learning_content or "").strip()
2197
2347
  )
2348
+ if not learning_in_this_close:
2349
+ _ensure_open_debt(
2350
+ task["session_id"],
2351
+ task_id,
2352
+ "missing_learning_after_correction",
2353
+ severity="error",
2354
+ evidence=(
2355
+ "User correction detected for this session without a durable "
2356
+ "nexo_learning_add; debt opened (soft enforcement) — task closed "
2357
+ "but a follow-up learning is required."
2358
+ ),
2359
+ debts=debts_created,
2360
+ )
2198
2361
 
2199
2362
  # ── Evidence enforcement: reject 'done' without proof ──
2200
2363
  # G1 hardening: "done" is no longer allowed to degrade into a debt-only
@@ -2643,6 +2806,25 @@ def handle_task_close(
2643
2806
  followup_id=created_followup_id,
2644
2807
  outcome_notes=outcome_notes,
2645
2808
  )
2809
+
2810
+ # ── Ola 2: auto-detect a PRIOR own action that this close reveals as
2811
+ # wrong (e.g. code shipped earlier but the cron was never created). On
2812
+ # high-confidence objective evidence, capture an immediate learning +
2813
+ # prevention rule (source_authority=code_test_evidence, not a Francisco
2814
+ # correction); on low confidence, only a quiet INFO candidate. Strictly
2815
+ # best-effort — runs after the task is already persisted-closed.
2816
+ self_error = _detect_and_capture_self_error(
2817
+ task,
2818
+ task_id,
2819
+ clean_outcome=clean_outcome,
2820
+ closure_text=closure_text,
2821
+ correction=correction,
2822
+ effective_files=effective_files,
2823
+ forgotten_step_followup=_followup_signals_forgotten_step(
2824
+ followup_description, outcome_notes
2825
+ ),
2826
+ debts_created=debts_created,
2827
+ )
2646
2828
  capture_context_event(
2647
2829
  event_type=f"protocol_task_{clean_outcome}",
2648
2830
  title=(task.get("goal") or task_id)[:160],
@@ -2724,10 +2906,17 @@ def handle_task_close(
2724
2906
  pass # Drive detection is best-effort
2725
2907
 
2726
2908
  open_debts = list_protocol_debts(status="open", task_id=task_id, limit=20)
2909
+ # The self-error CANDIDATE debt is an informational, non-actionable signal
2910
+ # (low confidence; recorded for audit/dedup, never a learning). It must not
2911
+ # flip an otherwise-clean close into "done_with_debts" — that would be the
2912
+ # exact kind of noise/debt Francisco rejects.
2913
+ status_debts = [
2914
+ debt for debt in open_debts if debt.get("debt_type") != "self_error_candidate"
2915
+ ]
2727
2916
 
2728
2917
  status = "clean"
2729
2918
  next_action = "Task closed cleanly."
2730
- if open_debts:
2919
+ if status_debts:
2731
2920
  if clean_outcome == "done":
2732
2921
  status = "done_with_debts"
2733
2922
  next_action = "Task closed as done, but resolve the open protocol debt next."
@@ -2779,6 +2968,8 @@ def handle_task_close(
2779
2968
  "memory_event": memory_event,
2780
2969
  "memory_event_ok": bool(memory_event and memory_event.get("ok")),
2781
2970
  }
2971
+ if self_error:
2972
+ response["self_error"] = self_error
2782
2973
  if durable_checkpoint:
2783
2974
  response["durable_checkpoint"] = durable_checkpoint
2784
2975
  return json.dumps(response, ensure_ascii=False, indent=2)