nexo-brain 7.37.4 → 7.38.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.
@@ -39,6 +39,7 @@ _NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
39
39
  INBOX_CHECK_THRESHOLD_SECONDS = int(
40
40
  os.environ.get("NEXO_INBOX_CHECK_THRESHOLD_SECONDS", "60")
41
41
  )
42
+ AUTO_GUARD_TTL_SECONDS = int(os.environ.get("NEXO_AUTO_GUARD_TTL_SECONDS", "300"))
42
43
 
43
44
 
44
45
  def _resolve_sid_from_payload(payload: dict) -> str:
@@ -238,11 +239,20 @@ def _extract_command(payload: dict) -> str:
238
239
 
239
240
  def _is_production_mutation_command(cmd: str) -> bool:
240
241
  patterns = (
242
+ r"\bgit\s+push\s+origin\s+main\b(?!.*--dry-run)",
243
+ r"\bfirebase\s+deploy\b(?!.*--dry-run)",
244
+ r"\bshopify\s+theme\s+push\b(?!.*--dry-run)",
245
+ r"\bgh\s+pr\s+merge\b(?!.*--dry-run)",
246
+ r"\baz\s+vm\s+create\b(?!.*--dry-run)",
247
+ r"\bgcloud\s+run\s+deploy\b(?!.*--dry-run)",
241
248
  r"\bgit\s+push\b(?!.*--dry-run)(?=.*\b(?:origin\s+)?(?:main|master|stable|release)\b)",
249
+ r"\bgit\s+push\b(?!.*--dry-run)(?=.*--tags\b)",
250
+ r"\bgh\s+release\s+(?:create|upload|edit)\b",
242
251
  r"\bgcloud\s+builds\s+submit\b",
243
252
  r"\bgcloud\s+builds\s+triggers\s+run\b",
244
253
  r"\bgcloud\s+run\s+(?:deploy|services\s+update|jobs\s+deploy|jobs\s+update)\b",
245
254
  r"\bgcloud\s+dns\s+record-sets\s+transaction\s+execute\b",
255
+ r"\b(?:alembic\s+upgrade|prisma\s+migrate\s+deploy|sequelize\s+db:migrate|knex\s+migrate:latest|rails\s+db:migrate|python(?:3)?\s+manage\.py\s+migrate|php\s+artisan\s+migrate)\b",
246
256
  r"\bg(?:sutil|cloud\s+storage)\b.*\b(?:cp|rsync)\b.*\b(?:release|stable|cdn|bucket|buckets)\b",
247
257
  r"\b(?:rsync|scp)\b(?!.*--dry-run).+\s+\S+:(?:/[^ \n\r;]*)(?:public_html|httpdocs|www|webroot|home/nexodesk)\b",
248
258
  r"\bssh\b[^'\"]*['\"][^'\"]*(?:sed\s+-i|tee\s+|>\s*\S|>>\s*\S|rm\s+-|mv\s+|cp\s+)[^'\"]*(?:public_html|httpdocs|/var/www|/opt/|/home/nexodesk)[^'\"]*['\"]",
@@ -255,6 +265,167 @@ def _is_production_mutation_command(cmd: str) -> bool:
255
265
  return any(re.search(pattern, cmd, re.IGNORECASE | re.DOTALL) for pattern in patterns)
256
266
 
257
267
 
268
+ def _mutation_files(payload: dict) -> list[str]:
269
+ if not _is_shared_mutation_payload(payload):
270
+ return []
271
+ tool_input = _tool_input(payload)
272
+ files: set[str] = set()
273
+ for key in ("file_path", "path", "files", "paths"):
274
+ files.update(_split_files(tool_input.get(key)))
275
+ return sorted(files)
276
+
277
+
278
+ def _project_atlas_path() -> Path:
279
+ try:
280
+ import paths # type: ignore
281
+
282
+ return paths.brain_dir() / "project-atlas.json"
283
+ except Exception:
284
+ return _NEXO_HOME / "brain" / "project-atlas.json"
285
+
286
+
287
+ def _atlas_projects(atlas: dict) -> dict:
288
+ if isinstance(atlas.get("projects"), dict):
289
+ return atlas["projects"]
290
+ return {key: value for key, value in atlas.items() if isinstance(value, dict) and not str(key).startswith("_")}
291
+
292
+
293
+ def _iter_atlas_locations(entry: dict):
294
+ locations = entry.get("locations")
295
+ if isinstance(locations, dict):
296
+ for value in locations.values():
297
+ if isinstance(value, str) and value.strip():
298
+ yield value.strip()
299
+ for key in ("repo", "local", "theme_local", "main_repo", "mcp_server"):
300
+ value = entry.get(key)
301
+ if isinstance(value, str) and value.strip():
302
+ yield value.strip()
303
+
304
+
305
+ def _resolve_area_from_atlas(files: list[str]) -> str:
306
+ if not files:
307
+ return ""
308
+ try:
309
+ atlas = json.loads(_project_atlas_path().read_text(encoding="utf-8"))
310
+ except Exception:
311
+ return ""
312
+ expanded_files = [str(Path(item).expanduser()) for item in files if item]
313
+ best: tuple[int, str] = (0, "")
314
+ for project_key, entry in _atlas_projects(atlas).items():
315
+ if not isinstance(entry, dict):
316
+ continue
317
+ for raw_location in _iter_atlas_locations(entry):
318
+ location = str(Path(raw_location.replace("~", str(Path.home()))).expanduser())
319
+ if not location or location == ".":
320
+ continue
321
+ for filepath in expanded_files:
322
+ if filepath == location or filepath.startswith(location.rstrip("/") + "/"):
323
+ score = len(location)
324
+ if score > best[0]:
325
+ best = (score, str(project_key))
326
+ if best[1]:
327
+ return best[1]
328
+ joined = "\n".join(expanded_files).lower()
329
+ if "/.nexo/core/" in joined or "/documents/_phpstormprojects/nexo/" in joined:
330
+ return "nexo"
331
+ return ""
332
+
333
+
334
+ def _recent_guard_check_exists(sid: str, files: list[str], area: str, now: float | None = None) -> bool:
335
+ if not sid:
336
+ return False
337
+ try:
338
+ from db import get_db # type: ignore
339
+ except Exception:
340
+ return False
341
+ cutoff = float(now) if now is not None else time.time()
342
+ cutoff -= AUTO_GUARD_TTL_SECONDS
343
+ try:
344
+ conn = get_db()
345
+ rows = conn.execute(
346
+ "SELECT files, area, strftime('%s', created_at) AS created_epoch "
347
+ "FROM guard_checks WHERE session_id = ? ORDER BY id DESC LIMIT 50",
348
+ (sid,),
349
+ ).fetchall()
350
+ except Exception:
351
+ return False
352
+ file_tokens = {Path(item).name for item in files if item}
353
+ file_tokens.update(item for item in files if item)
354
+ for row in rows:
355
+ try:
356
+ created_epoch = float(row["created_epoch"] or 0)
357
+ except Exception:
358
+ created_epoch = 0.0
359
+ if created_epoch and created_epoch < cutoff:
360
+ continue
361
+ row_area = str(row["area"] or "").strip()
362
+ row_files = str(row["files"] or "")
363
+ if area and row_area == area:
364
+ return True
365
+ if any(token and token in row_files for token in file_tokens):
366
+ return True
367
+ return False
368
+
369
+
370
+ def _record_auto_guard_debt(sid: str, evidence: str) -> None:
371
+ try:
372
+ from db import create_protocol_debt # type: ignore
373
+
374
+ create_protocol_debt(
375
+ sid or "unknown",
376
+ "auto_guard_check_failed",
377
+ severity="error",
378
+ evidence=evidence[:4000],
379
+ )
380
+ except Exception:
381
+ pass
382
+
383
+
384
+ def _queue_auto_guard_check(payload: dict, sid: str, now: float | None = None) -> str | None:
385
+ files = _mutation_files(payload)
386
+ if not files:
387
+ return None
388
+ area = _resolve_area_from_atlas(files)
389
+ if _recent_guard_check_exists(sid, files, area, now=now):
390
+ return None
391
+ guard_payload = {
392
+ "files": ",".join(files),
393
+ "area": area,
394
+ "project_hint": area,
395
+ "include_schemas": "true",
396
+ "enforce_runtime_core_block": "true",
397
+ }
398
+ try:
399
+ from mcp_write_queue import enqueue_write # type: ignore
400
+
401
+ queued = enqueue_write("guard_check", guard_payload, priority="high", wait=True, timeout_ms=2500)
402
+ except Exception as exc:
403
+ evidence = f"PostToolUse auto guard_check could not enqueue: {type(exc).__name__}: {exc}"
404
+ _record_auto_guard_debt(sid, evidence)
405
+ return append_operator_language_contract(
406
+ "No he podido registrar automáticamente la revisión previa del cambio; queda marcada para revisión antes del cierre."
407
+ )
408
+ error_text = str(queued.get("last_error") or queued.get("error") or "")
409
+ if not queued.get("accepted") or queued.get("status") in {"failed", "dead_letter"} or "Unknown tool" in error_text:
410
+ evidence = f"PostToolUse auto guard_check failed for files={files}, area={area}: {queued}"
411
+ _record_auto_guard_debt(sid, evidence)
412
+ return append_operator_language_contract(
413
+ "No he podido registrar automáticamente la revisión previa del cambio; queda marcada para revisión antes del cierre."
414
+ )
415
+ return None
416
+
417
+
418
+ def _is_release_publication_command(cmd: str) -> bool:
419
+ patterns = (
420
+ r"\bgit\s+push\b(?!.*--dry-run)(?=.*--tags\b)",
421
+ r"\bnpm\s+publish\b",
422
+ r"\bgh\s+release\s+(?:create|upload|edit)\b",
423
+ r"\bupload-release\.sh\b",
424
+ r"\bcws-upload(?:\.sh)?\b.*\bpublish\b",
425
+ )
426
+ return any(re.search(pattern, cmd, re.IGNORECASE | re.DOTALL) for pattern in patterns)
427
+
428
+
258
429
  _WEBROOT_BACKUP_RE = re.compile(
259
430
  r"https?://[^\s'\"<>]+(?:\.php\.(?:bak|old|new)|\.(?:bak|old|new|sql|zip|tar|tgz|gz|env))(?:[?#][^\s'\"<>]*)?",
260
431
  re.IGNORECASE,
@@ -335,6 +506,27 @@ def _task_close_payload_has_change_trace(payload: dict) -> bool:
335
506
  return bool(files and what and why)
336
507
 
337
508
 
509
+ def _change_log_has_production_release_refs(payload: dict) -> bool:
510
+ tool_input = _tool_input(payload)
511
+ try:
512
+ joined = json.dumps(tool_input, ensure_ascii=False).lower()
513
+ except Exception:
514
+ joined = str(tool_input).lower()
515
+ has_production_scope = (
516
+ str(tool_input.get("scope") or "").strip().lower() == "production"
517
+ or "scope=production" in joined
518
+ or '"scope": "production"' in joined
519
+ or '"scope":"production"' in joined
520
+ )
521
+ has_commit = bool(
522
+ re.search(r"\bcommit(?:_ref)?\b", joined)
523
+ and re.search(r"\b[0-9a-f]{7,40}\b", joined, re.IGNORECASE)
524
+ )
525
+ has_tag = bool(re.search(r"\b(?:tag|version)\b", joined) and re.search(r"\bv?\d+\.\d+\.\d+(?:[-+][a-z0-9_.-]+)?\b", joined, re.IGNORECASE))
526
+ has_release_url = bool(re.search(r"https://github\.com/[^ \n\r\t'\"<>]+/releases/(?:tag|download)/[^ \n\r\t'\"<>]+", joined, re.IGNORECASE))
527
+ return has_production_scope and (has_commit or has_tag or has_release_url)
528
+
529
+
338
530
  def _queue_change_log_from_task_close(payload: dict, sid: str, pending: dict) -> bool:
339
531
  if not _task_close_payload_has_change_trace(payload):
340
532
  return False
@@ -361,6 +553,94 @@ def _queue_change_log_from_task_close(payload: dict, sid: str, pending: dict) ->
361
553
  return bool(queued.get("accepted"))
362
554
 
363
555
 
556
+ def _extract_workdir(payload: dict) -> str:
557
+ tool_input = _tool_input(payload)
558
+ for key in ("cwd", "workdir", "working_dir"):
559
+ value = tool_input.get(key)
560
+ if isinstance(value, str) and value.strip():
561
+ return value.strip()
562
+ return ""
563
+
564
+
565
+ def _git_head_change_context(workdir: str) -> dict:
566
+ if not workdir:
567
+ return {}
568
+ path = Path(workdir)
569
+ if not path.is_dir():
570
+ return {}
571
+ try:
572
+ inside = subprocess.run(
573
+ ["git", "rev-parse", "--is-inside-work-tree"],
574
+ cwd=str(path),
575
+ capture_output=True,
576
+ text=True,
577
+ timeout=2,
578
+ )
579
+ if inside.returncode != 0 or inside.stdout.strip() != "true":
580
+ return {}
581
+ head = subprocess.run(
582
+ ["git", "rev-parse", "--short=12", "HEAD"],
583
+ cwd=str(path),
584
+ capture_output=True,
585
+ text=True,
586
+ timeout=2,
587
+ )
588
+ files = subprocess.run(
589
+ ["git", "diff-tree", "--no-commit-id", "--name-only", "-r", "HEAD"],
590
+ cwd=str(path),
591
+ capture_output=True,
592
+ text=True,
593
+ timeout=2,
594
+ )
595
+ except Exception:
596
+ return {}
597
+ result: dict[str, str] = {}
598
+ if head.returncode == 0 and head.stdout.strip():
599
+ result["commit_ref"] = head.stdout.strip()
600
+ if files.returncode == 0 and files.stdout.strip():
601
+ result["files"] = ", ".join(line.strip() for line in files.stdout.splitlines() if line.strip())
602
+ return result
603
+
604
+
605
+ def _production_change_log_payload(payload: dict, sid: str) -> dict:
606
+ cmd = _extract_command(payload)
607
+ tool_text = _extract_tool_text(payload)
608
+ context = _git_head_change_context(_extract_workdir(payload))
609
+ combined = "\n".join(part for part in (cmd, tool_text) if part)
610
+ version_match = re.search(r"\bv?\d+\.\d+\.\d+(?:[-+][A-Za-z0-9_.-]+)?\b", combined)
611
+ sha_match = re.search(r"\b[0-9a-f]{7,40}\b", combined, re.IGNORECASE)
612
+ files = context.get("files") or "produccion: mutacion detectada por comando"
613
+ commit_ref = context.get("commit_ref") or (sha_match.group(0)[:12] if sha_match else "")
614
+ evidence = tool_text.strip()[:1000] if tool_text.strip() else "PostToolUse detecto comando de produccion; pendiente de evidencia adicional en task_close si aplica."
615
+ what = "Cambio de produccion detectado automaticamente"
616
+ if version_match:
617
+ what += f" ({version_match.group(0)})"
618
+ return {
619
+ "session_id": sid,
620
+ "files": files,
621
+ "what_changed": what,
622
+ "why": cmd[:500],
623
+ "triggered_by": "PostToolUse automatic production mutation detector",
624
+ "affects": "produccion",
625
+ "risks": "registro automatico conservador; revisar si el comando fue un falso positivo",
626
+ "verify": evidence,
627
+ "commit_ref": commit_ref,
628
+ }
629
+
630
+
631
+ def _queue_change_log_from_production_mutation(payload: dict, sid: str) -> bool:
632
+ try:
633
+ from mcp_write_queue import enqueue_write # type: ignore
634
+ except Exception:
635
+ return False
636
+ queued = enqueue_write(
637
+ "change_log",
638
+ _production_change_log_payload(payload, sid),
639
+ priority="high",
640
+ )
641
+ return bool(queued.get("accepted"))
642
+
643
+
364
644
  def _read_json(path: Path) -> dict:
365
645
  try:
366
646
  return json.loads(path.read_text(encoding="utf-8"))
@@ -493,11 +773,19 @@ def check_production_change_log_closeout(payload: dict, sid: str) -> str | None:
493
773
  pending_path = _pending_change_log_path(sid)
494
774
  rotation_followup_id = _ensure_webroot_backup_rotation_followup(payload, sid)
495
775
  if _is_change_log_tool(tool_name):
776
+ pending = _read_json(pending_path)
777
+ if pending.get("requires_explicit_production_change_log") and not _change_log_has_production_release_refs(payload):
778
+ message = (
779
+ "Cierre pendiente: el cambio de release/tag/publicación requiere `nexo_change_log(...)` "
780
+ "con `scope=production` y una referencia verificable a commit, tag o URL de release."
781
+ )
782
+ return append_operator_language_contract(message)
496
783
  pending_path.unlink(missing_ok=True)
497
784
  return None
498
785
 
499
786
  cmd = _extract_command(payload)
500
787
  if cmd and _is_production_mutation_command(cmd):
788
+ is_release_publication = _is_release_publication_command(cmd)
501
789
  _write_json(
502
790
  pending_path,
503
791
  {
@@ -506,27 +794,203 @@ def check_production_change_log_closeout(payload: dict, sid: str) -> str | None:
506
794
  "tool_name": tool_name,
507
795
  "created_at": time.time(),
508
796
  "triggered_by": "PostToolUse production mutation detector",
797
+ "requires_explicit_production_change_log": is_release_publication,
509
798
  },
510
799
  )
800
+ if not is_release_publication and _queue_change_log_from_production_mutation(payload, sid):
801
+ pending_path.unlink(missing_ok=True)
802
+ return None
511
803
 
512
804
  pending = _read_json(pending_path)
513
805
  if not pending:
514
806
  return None
515
807
 
516
- if _is_task_close_tool(tool_name) and _queue_change_log_from_task_close(payload, sid, pending):
808
+ if (
809
+ _is_task_close_tool(tool_name)
810
+ and not pending.get("requires_explicit_production_change_log")
811
+ and _queue_change_log_from_task_close(payload, sid, pending)
812
+ ):
517
813
  pending_path.unlink(missing_ok=True)
518
814
  return None
519
815
 
520
- message = (
521
- "Cierre pendiente: se detectó una señal de despliegue/publicación de producción y todavía no consta "
522
- "`nexo_change_log(...)` ni un `nexo_task_close(...)` con archivos, motivo y verificación suficiente. "
523
- "Registra el cambio antes de declarar la tarea cerrada."
524
- )
816
+ if pending.get("requires_explicit_production_change_log"):
817
+ message = (
818
+ "Cierre pendiente: se detectó una publicación/tag/release. Antes de cerrar debe constar "
819
+ "`nexo_change_log(...)` con `scope=production` y referencia a commit, tag o URL de release."
820
+ )
821
+ else:
822
+ message = (
823
+ "Cierre pendiente: se detectó una señal de despliegue/publicación de producción y todavía no consta "
824
+ "`nexo_change_log(...)` ni un `nexo_task_close(...)` con archivos, motivo y verificación suficiente. "
825
+ "Registra el cambio antes de declarar la tarea cerrada."
826
+ )
525
827
  if rotation_followup_id:
526
828
  message += f" Además, el canary webroot creó el followup de rotación {rotation_followup_id}."
527
829
  return append_operator_language_contract(message)
528
830
 
529
831
 
832
+ def _domain_error_cascade_path(sid: str) -> Path:
833
+ safe_sid = "".join(ch if ch.isalnum() or ch in "-_" else "_" for ch in (sid or "unknown"))
834
+ return _production_closeout_dir() / f"domain-error-cascade-{safe_sid}.json"
835
+
836
+
837
+ def _payload_error_text(payload: dict) -> str:
838
+ parts = [_extract_command(payload), _extract_tool_text(payload)]
839
+ for key in ("stderr", "stdout", "output", "tool_response"):
840
+ value = payload.get(key)
841
+ if isinstance(value, str):
842
+ parts.append(value)
843
+ for key in ("exit_code", "returncode", "status"):
844
+ value = payload.get(key)
845
+ if value not in (None, "", 0, "0", "success", "ok"):
846
+ parts.append(f"{key}={value}")
847
+ return "\n".join(part for part in parts if part)
848
+
849
+
850
+ def _detect_error_domain(payload: dict) -> str:
851
+ text = _payload_error_text(payload)
852
+ if not text:
853
+ return ""
854
+ if not re.search(
855
+ r"\b(error|failed|failure|traceback|exception|timeout|denied|quota|resource_exhausted|429|5\d\d|could not|no se pudo|fall[oó])\b",
856
+ text,
857
+ re.IGNORECASE,
858
+ ):
859
+ return ""
860
+ domain_patterns = (
861
+ ("gcloud", r"\b(gcloud|cloudbuild|cloud\s+build|cloud\s+run|cloud\s+sql|googleapis|resource_exhausted)\b"),
862
+ ("credits", r"\b(credits?|billing|stripe|saldo|recarga|quota|coste|cost)\b"),
863
+ ("imap", r"\b(imap|mailbox|email|correo|mxroute|smtp)\b"),
864
+ ("recovery", r"\b(recovery|carrito|abandon|checkout|whatsappqueue|queuedrain)\b"),
865
+ ("cloud", r"\b(cloud|dns|cloudflare|secret\s+manager|secretmanager|cloud\s+storage)\b"),
866
+ )
867
+ for domain, pattern in domain_patterns:
868
+ if re.search(pattern, text, re.IGNORECASE):
869
+ return domain
870
+ return "general"
871
+
872
+
873
+ def check_domain_error_cascade(payload: dict, sid: str, now: float | None = None) -> str | None:
874
+ domain = _detect_error_domain(payload)
875
+ if not domain:
876
+ return None
877
+ current = float(now) if now is not None else time.time()
878
+ path = _domain_error_cascade_path(sid or "unknown")
879
+ state = _read_json(path) or {"sid": sid or "unknown", "domains": {}}
880
+ domains = state.setdefault("domains", {})
881
+ events = [
882
+ item for item in domains.get(domain, [])
883
+ if isinstance(item, dict) and current - float(item.get("ts") or 0) <= 1800
884
+ ]
885
+ events.append({"ts": current, "tool": _tool_name(payload), "command": _extract_command(payload)[:240]})
886
+ domains[domain] = events[-5:]
887
+ _write_json(path, state)
888
+ if len(events) < 2:
889
+ return None
890
+ last_prompt = float(state.get("last_prompted", {}).get(domain, 0) if isinstance(state.get("last_prompted"), dict) else 0)
891
+ if current - last_prompt < 900:
892
+ return None
893
+ prompted = state.setdefault("last_prompted", {})
894
+ prompted[domain] = current
895
+ _write_json(path, state)
896
+ message = (
897
+ f"Cascada detectada en `{domain}`: van {len(events)} errores del mismo dominio en la ventana reciente. "
898
+ "Antes de seguir parcheando en serie, abre subagentes en paralelo con piezas independientes: "
899
+ "1) causa raíz/logs, 2) credenciales/cuotas/configuración, 3) fix mínimo + prueba de cierre. "
900
+ "Cada subagente debe recibir alcance acotado y parar si no puede verificar."
901
+ )
902
+ return append_operator_language_contract(message)
903
+
904
+
905
+ _SUPPORT_TICKET_TOOLS = {
906
+ "nexo_support_ticket_create",
907
+ "mcp__nexo__nexo_support_ticket_create",
908
+ "nexo_support_ticket_message",
909
+ "mcp__nexo__nexo_support_ticket_message",
910
+ }
911
+
912
+
913
+ def _support_ticket_failure_domain(payload: dict) -> str:
914
+ if _tool_name(payload) not in _SUPPORT_TICKET_TOOLS:
915
+ return ""
916
+ try:
917
+ text = json.dumps(_tool_input(payload), ensure_ascii=False)
918
+ except Exception:
919
+ text = str(_tool_input(payload) or "")
920
+ combined = "\n".join(part for part in (text, _extract_tool_text(payload)) if part)
921
+ if not combined:
922
+ return ""
923
+ domain_patterns = (
924
+ ("cloud", r"\b(cloud|gcloud|cloudflare|dns|provision(?:ing)?|secret\s*manager|secretmanager|cloud\s*run|cloud\s*sql)\b"),
925
+ ("credits", r"\b(credits?|cr[eé]ditos?|billing|saldo|stripe|checkout|portal|quota|cuota|coste|cost)\b"),
926
+ ("voice", r"\b(voice|voz|vapi|elevenlabs|audio|tts|stt)\b"),
927
+ ("image", r"\b(image|imagen|imagenes|im[aá]genes|fal|replicate|gpt-image|render)\b"),
928
+ ("provisioning", r"\b(provision(?:ing)?|alta|tenant|workspace|account|cuenta|api\s*key|scope|token)\b"),
929
+ )
930
+ for domain, pattern in domain_patterns:
931
+ if re.search(pattern, combined, re.IGNORECASE):
932
+ return domain
933
+ return ""
934
+
935
+
936
+ def _support_ticket_client_key(payload: dict) -> str:
937
+ tool_input = _tool_input(payload)
938
+ for key in ("client_message_id", "ticket_id", "customer_id", "account_id", "shop_id", "tenant_id"):
939
+ value = tool_input.get(key)
940
+ if isinstance(value, str) and value.strip():
941
+ return value.strip()[:160]
942
+ subject = str(tool_input.get("subject") or tool_input.get("title") or "").strip()
943
+ message = str(tool_input.get("message") or tool_input.get("body") or "").strip()
944
+ seed = (subject or message or _extract_tool_text(payload) or _extract_command(payload)).strip()
945
+ return seed[:160]
946
+
947
+
948
+ def _support_ticket_cascade_path(sid: str) -> Path:
949
+ safe_sid = "".join(ch if ch.isalnum() or ch in "-_" else "_" for ch in (sid or "unknown"))
950
+ return _production_closeout_dir() / f"support-ticket-cascade-{safe_sid}.json"
951
+
952
+
953
+ def check_support_ticket_second_failure_sweep(payload: dict, sid: str, now: float | None = None) -> str | None:
954
+ domain = _support_ticket_failure_domain(payload)
955
+ if not domain:
956
+ return None
957
+ current = float(now) if now is not None else time.time()
958
+ path = _support_ticket_cascade_path(sid or "unknown")
959
+ state = _read_json(path) or {"sid": sid or "unknown", "domains": {}}
960
+ domains = state.setdefault("domains", {})
961
+ events = [
962
+ item for item in domains.get(domain, [])
963
+ if isinstance(item, dict) and current - float(item.get("ts") or 0) <= 72 * 3600
964
+ ]
965
+ client_key = _support_ticket_client_key(payload)
966
+ events.append(
967
+ {
968
+ "ts": current,
969
+ "tool": _tool_name(payload),
970
+ "client": client_key,
971
+ "summary": str(_tool_input(payload).get("subject") or _tool_input(payload).get("title") or "")[:240],
972
+ }
973
+ )
974
+ domains[domain] = events[-20:]
975
+ _write_json(path, state)
976
+ distinct_clients = {str(item.get("client") or "").strip() for item in events if str(item.get("client") or "").strip()}
977
+ if len(events) < 2 or len(distinct_clients) < 2:
978
+ return None
979
+ prompted = state.setdefault("last_prompted", {})
980
+ last_prompt = float(prompted.get(domain, 0) if isinstance(prompted, dict) else 0)
981
+ if last_prompt and current - last_prompt < 6 * 3600:
982
+ return None
983
+ prompted[domain] = current
984
+ _write_json(path, state)
985
+ message = (
986
+ f"Segundo fallo de cliente en `{domain}` detectado dentro de 72h. "
987
+ "Antes de responder el siguiente ticket, activa `SK-SUPPORT-SECOND-TICKET-PARALLEL-SWEEP` "
988
+ "y lanza 2-3 subagentes en paralelo sobre el flujo completo: idempotencia/reservas, "
989
+ "scope tokens/configuración, errores cacheados/logs y smoke final. Cierra el P1 en la misma tanda."
990
+ )
991
+ return append_operator_language_contract(message)
992
+
993
+
530
994
  _SHARED_MUTATION_TOOLS = {
531
995
  "Edit",
532
996
  "Write",
@@ -662,9 +1126,12 @@ def main() -> int:
662
1126
  try:
663
1127
  sid = _resolve_sid_from_payload(payload)
664
1128
  reminder = check_inbox_and_emit_reminder(sid)
1129
+ auto_guard_message = _queue_auto_guard_check(payload, sid)
665
1130
  change_log_message = check_production_change_log_closeout(payload, sid)
666
1131
  post_change_trace_message = check_post_change_trace_closeout(payload, sid)
667
1132
  shared_scope_message = check_shared_scope_closeout(payload)
1133
+ cascade_message = check_domain_error_cascade(payload, sid)
1134
+ support_second_ticket_message = check_support_ticket_second_failure_sweep(payload, sid)
668
1135
  g1_message: str | None = None
669
1136
  try:
670
1137
  from g1_enforcer import check_response_contract_gate # type: ignore
@@ -674,9 +1141,12 @@ def main() -> int:
674
1141
  combined = _combine_system_messages(
675
1142
  protocol_message,
676
1143
  reminder,
1144
+ auto_guard_message,
677
1145
  change_log_message,
678
1146
  post_change_trace_message,
679
1147
  shared_scope_message,
1148
+ cascade_message,
1149
+ support_second_ticket_message,
680
1150
  g1_message,
681
1151
  )
682
1152
  if combined:
@@ -6,9 +6,9 @@
6
6
  "chrome-devtools-mcp": {
7
7
  "source_type": "npm",
8
8
  "package": "chrome-devtools-mcp",
9
- "version": "1.3.0",
10
- "integrity": "sha512-52NVUwWSL4eW7W9nsDrzYJF96IKVuxEwAn4O7ZfdNRtopS954P9nryJbdYwg7vdqxhLrvioGFlm5e4P41WXsiw==",
11
- "tarball": "https://registry.npmjs.org/chrome-devtools-mcp/-/chrome-devtools-mcp-1.3.0.tgz",
9
+ "version": "1.4.0",
10
+ "integrity": "sha512-OOT5mYMUbqqgpIGx0s0TgxnlicRSDm3yhZWzK3pWkB6TUPb+WdpI0j6OQoTnNZ9mqn9rwXiCOLzY4UALp+XsiQ==",
11
+ "tarball": "https://registry.npmjs.org/chrome-devtools-mcp/-/chrome-devtools-mcp-1.4.0.tgz",
12
12
  "bin": "chrome-devtools-mcp",
13
13
  "engines": {
14
14
  "node": "^20.19.0 || ^22.12.0 || >=23"
@@ -267,6 +267,13 @@ def _apply_write(record: dict[str, Any]) -> None:
267
267
  if str(result).startswith("ERROR:"):
268
268
  raise ValueError(result)
269
269
  return
270
+ if kind == "guard_check":
271
+ from plugins.guard import handle_guard_check
272
+
273
+ result = handle_guard_check(**payload)
274
+ if str(result).startswith("ERROR:"):
275
+ raise ValueError(result)
276
+ return
270
277
  if kind == "learning_add":
271
278
  from tools_learnings import handle_learning_add
272
279