nexo-brain 7.37.3 → 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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +3 -1
- package/package.json +1 -1
- package/src/closure_promise_audit.py +134 -0
- package/src/cost_secret_sweep.py +120 -0
- package/src/email_credentials.py +38 -6
- package/src/enforcement_engine.py +81 -1
- package/src/evidence_matrix.py +94 -0
- package/src/hooks/post_tool_use.py +476 -6
- package/src/managed_mcp/lock.json +3 -3
- package/src/mcp_write_queue.py +7 -0
- package/src/plugins/cards.py +84 -1
- package/src/plugins/protocol.py +476 -19
- package/src/pre_answer_router.py +12 -8
- package/src/r14_correction_learning.py +62 -0
- package/src/rules/core-rules.json +42 -2
- package/src/scripts/cost_secret_sweep.py +37 -0
- package/src/scripts/deep-sleep/apply_findings.py +252 -0
- package/src/scripts/deep-sleep/synthesize.py +50 -3
- package/src/scripts/nexo-daily-self-audit.py +202 -3
- package/src/scripts/nexo-morning-agent.py +160 -2
- package/src/server.py +29 -2
- package/src/skills/support-second-ticket-parallel-sweep/guide.md +19 -0
- package/src/skills/support-second-ticket-parallel-sweep/skill.json +30 -0
- package/src/skills/verify-prod-config/guide.md +46 -0
- package/src/skills/verify-prod-config/skill.json +36 -0
- package/src/tools_api_call.py +66 -8
- package/templates/CLAUDE.md.template +10 -0
- package/templates/CODEX.AGENTS.md.template +10 -0
- package/templates/core-prompts/morning-agent.md +10 -0
- package/templates/core-prompts/r14-accepted-correction-injection.md +1 -0
- package/templates/core-prompts/r14-accepted-correction-question.md +1 -0
- package/templates/core-prompts/server-mcp-instructions.md +5 -0
- package/tool-enforcement-map.json +39 -0
|
@@ -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
|
|
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
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
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.
|
|
10
|
-
"integrity": "sha512-
|
|
11
|
-
"tarball": "https://registry.npmjs.org/chrome-devtools-mcp/-/chrome-devtools-mcp-1.
|
|
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"
|
package/src/mcp_write_queue.py
CHANGED
|
@@ -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
|
|