nexo-brain 5.3.27 → 5.3.30

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,459 @@
1
+ """Desktop bridge — read-only commands for NEXO Desktop (Electron UI).
2
+
3
+ Exposes four read-only commands so Desktop can auto-adapt its UI:
4
+ nexo schema --json → editable-field schema for Preferences UI
5
+ nexo identity --json → canonical assistant identity + source path
6
+ nexo onboard --json → onboarding wizard steps
7
+ nexo scan-profile → build profile.json from CLAUDE.md + memory
8
+
9
+ All commands honor NEXO_HOME. None mutate state unless --apply is passed
10
+ on scan-profile (default is dry-run: prints the proposed payload).
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import os
16
+ import re
17
+ import sys
18
+ from pathlib import Path
19
+ from typing import Any
20
+
21
+ SCHEMA_VERSION = 1
22
+ ONBOARD_VERSION = 1
23
+
24
+
25
+ def _nexo_home() -> Path:
26
+ return Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
27
+
28
+
29
+ def _calibration_path() -> Path:
30
+ return _nexo_home() / "brain" / "calibration.json"
31
+
32
+
33
+ def _profile_path() -> Path:
34
+ return _nexo_home() / "brain" / "profile.json"
35
+
36
+
37
+ def _read_json(path: Path) -> dict:
38
+ try:
39
+ if path.is_file():
40
+ return json.loads(path.read_text())
41
+ except Exception:
42
+ pass
43
+ return {}
44
+
45
+
46
+ def _print_json(payload: Any) -> int:
47
+ sys.stdout.write(json.dumps(payload, ensure_ascii=False, indent=2) + "\n")
48
+ return 0
49
+
50
+
51
+ # ------------------------------------------------------------------ schema
52
+
53
+ def _schema_fields() -> list[dict]:
54
+ """Canonical list of editable fields across calibration.json / profile.json.
55
+
56
+ Each field declares its storage path (dot notation) and which file it
57
+ lives in, so Desktop can read/write precisely without guessing.
58
+ """
59
+ return [
60
+ {
61
+ "path": "user.name",
62
+ "file": "calibration.json",
63
+ "label": {"es": "Nombre", "en": "Name"},
64
+ "type": "text",
65
+ "group": "personal",
66
+ },
67
+ {
68
+ "path": "user.language",
69
+ "file": "calibration.json",
70
+ "label": {"es": "Idioma", "en": "Language"},
71
+ "type": "select",
72
+ "group": "personal",
73
+ "options": [
74
+ {"value": "es", "label": {"es": "Español", "en": "Spanish"}},
75
+ {"value": "en", "label": {"es": "Inglés", "en": "English"}},
76
+ {"value": "ca", "label": {"es": "Catalán", "en": "Catalan"}},
77
+ {"value": "fr", "label": {"es": "Francés", "en": "French"}},
78
+ {"value": "de", "label": {"es": "Alemán", "en": "German"}},
79
+ {"value": "it", "label": {"es": "Italiano", "en": "Italian"}},
80
+ {"value": "pt", "label": {"es": "Portugués", "en": "Portuguese"}},
81
+ ],
82
+ },
83
+ {
84
+ "path": "user.timezone",
85
+ "file": "calibration.json",
86
+ "label": {"es": "Zona horaria", "en": "Timezone"},
87
+ "type": "text",
88
+ "group": "personal",
89
+ "default": "Europe/Madrid",
90
+ },
91
+ {
92
+ "path": "user.assistant_name",
93
+ "file": "calibration.json",
94
+ "label": {"es": "Nombre del asistente", "en": "Assistant name"},
95
+ "type": "text",
96
+ "group": "personal",
97
+ "default": "NEXO",
98
+ },
99
+ {
100
+ "path": "personality.autonomy",
101
+ "file": "calibration.json",
102
+ "label": {"es": "Autonomía", "en": "Autonomy"},
103
+ "type": "select",
104
+ "group": "personality",
105
+ "options": [
106
+ {"value": "conservative", "label": {"es": "Conservadora", "en": "Conservative"}},
107
+ {"value": "balanced", "label": {"es": "Equilibrada", "en": "Balanced"}},
108
+ {"value": "full", "label": {"es": "Plena", "en": "Full"}},
109
+ ],
110
+ },
111
+ {
112
+ "path": "personality.communication",
113
+ "file": "calibration.json",
114
+ "label": {"es": "Comunicación", "en": "Communication"},
115
+ "type": "select",
116
+ "group": "personality",
117
+ "options": [
118
+ {"value": "concise", "label": {"es": "Concisa", "en": "Concise"}},
119
+ {"value": "balanced", "label": {"es": "Equilibrada", "en": "Balanced"}},
120
+ {"value": "detailed", "label": {"es": "Detallada", "en": "Detailed"}},
121
+ ],
122
+ },
123
+ {
124
+ "path": "personality.honesty",
125
+ "file": "calibration.json",
126
+ "label": {"es": "Honestidad", "en": "Honesty"},
127
+ "type": "select",
128
+ "group": "personality",
129
+ "options": [
130
+ {"value": "firm-pushback", "label": {"es": "Firme", "en": "Firm pushback"}},
131
+ {"value": "mention-and-follow", "label": {"es": "Menciona y sigue", "en": "Mention and follow"}},
132
+ {"value": "just-execute", "label": {"es": "Solo ejecuta", "en": "Just execute"}},
133
+ ],
134
+ },
135
+ {
136
+ "path": "personality.proactivity",
137
+ "file": "calibration.json",
138
+ "label": {"es": "Proactividad", "en": "Proactivity"},
139
+ "type": "select",
140
+ "group": "personality",
141
+ "options": [
142
+ {"value": "reactive", "label": {"es": "Reactiva", "en": "Reactive"}},
143
+ {"value": "suggestive", "label": {"es": "Sugerente", "en": "Suggestive"}},
144
+ {"value": "proactive", "label": {"es": "Proactiva", "en": "Proactive"}},
145
+ ],
146
+ },
147
+ {
148
+ "path": "personality.error_handling",
149
+ "file": "calibration.json",
150
+ "label": {"es": "Errores", "en": "Error handling"},
151
+ "type": "select",
152
+ "group": "personality",
153
+ "options": [
154
+ {"value": "brief-fix", "label": {"es": "Arreglo breve", "en": "Brief fix"}},
155
+ {"value": "explain-and-learn", "label": {"es": "Explica y aprende", "en": "Explain and learn"}},
156
+ ],
157
+ },
158
+ {
159
+ "path": "preferences.menu_on_demand",
160
+ "file": "calibration.json",
161
+ "label": {"es": "Menú bajo demanda", "en": "Menu on demand"},
162
+ "type": "boolean",
163
+ "group": "preferences",
164
+ "default": True,
165
+ },
166
+ {
167
+ "path": "preferences.show_pending_items",
168
+ "file": "calibration.json",
169
+ "label": {"es": "Mostrar pendientes al inicio", "en": "Show pending items at startup"},
170
+ "type": "boolean",
171
+ "group": "preferences",
172
+ "default": False,
173
+ },
174
+ {
175
+ "path": "preferences.execution_first",
176
+ "file": "calibration.json",
177
+ "label": {"es": "Ejecuta antes de preguntar", "en": "Execute before asking"},
178
+ "type": "boolean",
179
+ "group": "preferences",
180
+ "default": True,
181
+ },
182
+ {
183
+ "path": "preferences.report_style",
184
+ "file": "calibration.json",
185
+ "label": {"es": "Estilo de reporte", "en": "Report style"},
186
+ "type": "select",
187
+ "group": "preferences",
188
+ "options": [
189
+ {"value": "essentials_only", "label": {"es": "Solo esencial", "en": "Essentials only"}},
190
+ {"value": "balanced", "label": {"es": "Equilibrado", "en": "Balanced"}},
191
+ {"value": "verbose", "label": {"es": "Detallado", "en": "Verbose"}},
192
+ ],
193
+ },
194
+ {
195
+ "path": "meta.role",
196
+ "file": "calibration.json",
197
+ "label": {"es": "Rol / ocupación", "en": "Role / occupation"},
198
+ "type": "text",
199
+ "group": "about_you",
200
+ },
201
+ {
202
+ "path": "meta.technical_level",
203
+ "file": "calibration.json",
204
+ "label": {"es": "Nivel técnico", "en": "Technical level"},
205
+ "type": "select",
206
+ "group": "about_you",
207
+ "options": [
208
+ {"value": "beginner", "label": {"es": "Principiante", "en": "Beginner"}},
209
+ {"value": "intermediate", "label": {"es": "Intermedio", "en": "Intermediate"}},
210
+ {"value": "advanced", "label": {"es": "Avanzado", "en": "Advanced"}},
211
+ ],
212
+ },
213
+ ]
214
+
215
+
216
+ def _schema_groups() -> list[dict]:
217
+ return [
218
+ {"id": "personal", "label": {"es": "Personal", "en": "Personal"}, "order": 1},
219
+ {"id": "personality", "label": {"es": "Personalidad", "en": "Personality"}, "order": 2},
220
+ {"id": "preferences", "label": {"es": "Preferencias", "en": "Preferences"}, "order": 3},
221
+ {"id": "about_you", "label": {"es": "Sobre ti", "en": "About you"}, "order": 4},
222
+ ]
223
+
224
+
225
+ def cmd_schema(args) -> int:
226
+ payload = {
227
+ "schema_version": SCHEMA_VERSION,
228
+ "groups": _schema_groups(),
229
+ "fields": _schema_fields(),
230
+ }
231
+ return _print_json(payload)
232
+
233
+
234
+ # ---------------------------------------------------------------- identity
235
+
236
+ def _resolve_identity() -> dict:
237
+ """Resolve the canonical assistant name + which source produced it."""
238
+ cal = _read_json(_calibration_path())
239
+ prof = _read_json(_profile_path())
240
+
241
+ probes: list[tuple[str, Any]] = [
242
+ ("calibration.user.assistant_name",
243
+ cal.get("user", {}).get("assistant_name") if isinstance(cal.get("user"), dict) else None),
244
+ ("calibration.operator_name", cal.get("operator_name")),
245
+ ("calibration.assistant_name", cal.get("assistant_name")),
246
+ ("calibration.identity", cal.get("identity")),
247
+ ("profile.operator_name", prof.get("operator_name")),
248
+ ("profile.assistant_name", prof.get("assistant_name")),
249
+ ]
250
+ for source, value in probes:
251
+ if isinstance(value, str) and value.strip():
252
+ return {"name": value.strip(), "source": source,
253
+ "writable_source": "calibration.user.assistant_name"}
254
+
255
+ return {"name": "NEXO", "source": "default",
256
+ "writable_source": "calibration.user.assistant_name"}
257
+
258
+
259
+ def cmd_identity(args) -> int:
260
+ return _print_json(_resolve_identity())
261
+
262
+
263
+ # ---------------------------------------------------------------- onboard
264
+
265
+ def _onboard_steps() -> list[dict]:
266
+ return [
267
+ {
268
+ "id": "name",
269
+ "prompt": {"es": "¿Cómo te llamas?", "en": "What's your name?"},
270
+ "type": "text",
271
+ "writes": "user.name",
272
+ "file": "calibration.json",
273
+ "optional": False,
274
+ "validate": r"^\S.{0,60}$",
275
+ },
276
+ {
277
+ "id": "language",
278
+ "prompt": {"es": "¿En qué idioma quieres operar?", "en": "Which language should we use?"},
279
+ "type": "select",
280
+ "writes": "user.language",
281
+ "file": "calibration.json",
282
+ "optional": False,
283
+ "default": "es",
284
+ "options": [
285
+ {"value": "es", "label": {"es": "Español", "en": "Spanish"}},
286
+ {"value": "en", "label": {"es": "Inglés", "en": "English"}},
287
+ ],
288
+ },
289
+ {
290
+ "id": "assistant_name",
291
+ "prompt": {"es": "¿Cómo se llamará tu asistente?", "en": "What will your assistant be called?"},
292
+ "type": "text",
293
+ "writes": "user.assistant_name",
294
+ "file": "calibration.json",
295
+ "optional": True,
296
+ "default": "NEXO",
297
+ },
298
+ {
299
+ "id": "role",
300
+ "prompt": {"es": "¿A qué te dedicas?", "en": "What do you do?"},
301
+ "type": "text",
302
+ "writes": "meta.role",
303
+ "file": "calibration.json",
304
+ "optional": True,
305
+ },
306
+ {
307
+ "id": "technical_level",
308
+ "prompt": {"es": "¿Cuál es tu nivel técnico?", "en": "What's your technical level?"},
309
+ "type": "select",
310
+ "writes": "meta.technical_level",
311
+ "file": "calibration.json",
312
+ "optional": True,
313
+ "default": "intermediate",
314
+ "options": [
315
+ {"value": "beginner", "label": {"es": "Principiante", "en": "Beginner"}},
316
+ {"value": "intermediate", "label": {"es": "Intermedio", "en": "Intermediate"}},
317
+ {"value": "advanced", "label": {"es": "Avanzado", "en": "Advanced"}},
318
+ ],
319
+ },
320
+ {
321
+ "id": "welcome",
322
+ "type": "welcome",
323
+ "message": {
324
+ "es": "Listo. A partir de ahora aprendo observándote. Dime qué necesitas.",
325
+ "en": "Ready. From now on I learn by watching. Tell me what you need.",
326
+ },
327
+ },
328
+ ]
329
+
330
+
331
+ def cmd_onboard(args) -> int:
332
+ payload = {
333
+ "onboard_version": ONBOARD_VERSION,
334
+ "steps": _onboard_steps(),
335
+ }
336
+ return _print_json(payload)
337
+
338
+
339
+ # ------------------------------------------------------------ scan-profile
340
+
341
+ # Patterns we try to lift from the user's CLAUDE.md into profile.json.
342
+ # Kept deliberately narrow to avoid false positives.
343
+ _CLAUDE_MD_CANDIDATES = [
344
+ Path.home() / ".claude" / "CLAUDE.md",
345
+ Path.home() / "CLAUDE.md",
346
+ ]
347
+
348
+
349
+ def _read_claude_md() -> str:
350
+ for p in _CLAUDE_MD_CANDIDATES:
351
+ try:
352
+ if p.is_file():
353
+ return p.read_text(errors="ignore")
354
+ except Exception:
355
+ continue
356
+ return ""
357
+
358
+
359
+ def _guess_role(claude_md: str, cal: dict) -> str:
360
+ meta_role = cal.get("meta", {}).get("role") if isinstance(cal.get("meta"), dict) else None
361
+ if isinstance(meta_role, str) and meta_role.strip():
362
+ return meta_role.strip()
363
+ m = re.search(r"(?im)^\s*[-*]?\s*(?:role|rol|ocupaci[oó]n|dedica)\s*[:=]\s*(.+)$", claude_md)
364
+ if m:
365
+ return m.group(1).strip().strip(".")
366
+ return ""
367
+
368
+
369
+ def _guess_technical_level(claude_md: str, cal: dict) -> str:
370
+ meta_tl = cal.get("meta", {}).get("technical_level") if isinstance(cal.get("meta"), dict) else None
371
+ if isinstance(meta_tl, str) and meta_tl.strip():
372
+ return meta_tl.strip()
373
+ text = claude_md.lower()
374
+ if "advanced" in text or "avanzado" in text or "senior" in text:
375
+ return "advanced"
376
+ if "intermediate" in text or "intermedio" in text:
377
+ return "intermediate"
378
+ if "beginner" in text or "principiante" in text:
379
+ return "beginner"
380
+ return ""
381
+
382
+
383
+ def _guess_timezone(cal: dict) -> str:
384
+ tz = cal.get("user", {}).get("timezone") if isinstance(cal.get("user"), dict) else None
385
+ if isinstance(tz, str) and tz.strip():
386
+ return tz.strip()
387
+ return os.environ.get("TZ", "") or ""
388
+
389
+
390
+ def _build_profile_payload() -> dict:
391
+ cal = _read_json(_calibration_path())
392
+ claude_md = _read_claude_md()
393
+
394
+ user = cal.get("user", {}) if isinstance(cal.get("user"), dict) else {}
395
+ payload = {
396
+ "version": 1,
397
+ "source": "scan-profile",
398
+ "user": {
399
+ "name": user.get("name", "") or "",
400
+ "language": user.get("language", "") or "",
401
+ "timezone": _guess_timezone(cal),
402
+ },
403
+ "meta": {
404
+ "role": _guess_role(claude_md, cal),
405
+ "technical_level": _guess_technical_level(claude_md, cal),
406
+ },
407
+ "signals": {
408
+ "has_claude_md": bool(claude_md),
409
+ "claude_md_chars": len(claude_md),
410
+ "calibration_present": bool(cal),
411
+ },
412
+ }
413
+ return payload
414
+
415
+
416
+ def cmd_scan_profile(args) -> int:
417
+ profile_path = _profile_path()
418
+ payload = _build_profile_payload()
419
+ use_json = bool(getattr(args, "json", False))
420
+ apply = bool(getattr(args, "apply", False))
421
+ force = bool(getattr(args, "force", False))
422
+
423
+ status = "preview"
424
+ written = False
425
+ reason = ""
426
+
427
+ if apply:
428
+ if profile_path.exists() and not force:
429
+ status = "skipped"
430
+ reason = "profile.json already exists (use --force to overwrite)"
431
+ else:
432
+ try:
433
+ profile_path.parent.mkdir(parents=True, exist_ok=True)
434
+ profile_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2))
435
+ status = "written"
436
+ written = True
437
+ except Exception as exc:
438
+ status = "error"
439
+ reason = f"write failed: {exc}"
440
+
441
+ result = {
442
+ "status": status,
443
+ "path": str(profile_path),
444
+ "written": written,
445
+ "reason": reason,
446
+ "payload": payload,
447
+ }
448
+
449
+ if use_json:
450
+ return _print_json(result)
451
+
452
+ # Human-readable fallback
453
+ sys.stdout.write(f"scan-profile: {status}\n")
454
+ sys.stdout.write(f" path: {profile_path}\n")
455
+ if reason:
456
+ sys.stdout.write(f" reason: {reason}\n")
457
+ sys.stdout.write(" preview:\n")
458
+ sys.stdout.write(json.dumps(payload, ensure_ascii=False, indent=2) + "\n")
459
+ return 0 if status != "error" else 1
@@ -225,6 +225,23 @@ def _session_has_guard_check(conn, sid: str) -> bool:
225
225
  return bool(row)
226
226
 
227
227
 
228
+ def _session_has_guard_for_file(conn, sid: str, filepath: str) -> bool:
229
+ """Check if guard_check was called for a specific file in this session."""
230
+ if not filepath:
231
+ return False
232
+ normalized = _normalize_file_path(filepath)
233
+ basename = os.path.basename(filepath)
234
+ # guard_checks.files is a comma-separated or JSON list of paths/areas
235
+ row = conn.execute(
236
+ """SELECT 1 FROM guard_checks
237
+ WHERE session_id = ?
238
+ AND (files LIKE ? OR files LIKE ? OR files LIKE ?)
239
+ LIMIT 1""",
240
+ (sid, f"%{normalized}%", f"%{basename}%", f"%{filepath}%"),
241
+ ).fetchone()
242
+ return bool(row)
243
+
244
+
228
245
  def _find_open_debt(conn, *, session_id: str, task_id: str, debt_type: str, file_token: str) -> dict | None:
229
246
  row = conn.execute(
230
247
  """SELECT *
@@ -548,6 +565,28 @@ def process_pre_tool_event(payload: dict) -> dict:
548
565
  "reason_code": "guard_unacknowledged",
549
566
  }
550
567
  )
568
+ continue
569
+
570
+ # Check if guard_check was called for this specific file
571
+ if not _session_has_guard_for_file(conn, sid, filepath):
572
+ debt = _ensure_protocol_debt(
573
+ conn,
574
+ session_id=sid,
575
+ task_id=task["task_id"],
576
+ debt_type="write_without_file_guard_check",
577
+ severity="warn",
578
+ evidence=f"{tool_name} attempted on {filepath} without a prior guard_check covering that file.",
579
+ file_token=filepath,
580
+ )
581
+ blocks.append(
582
+ {
583
+ "file": filepath,
584
+ "task_id": task["task_id"],
585
+ "debt_id": debt.get("id"),
586
+ "debt_type": "write_without_file_guard_check",
587
+ "reason_code": "missing_file_guard",
588
+ }
589
+ )
551
590
 
552
591
  return {
553
592
  "ok": True,
@@ -728,6 +767,11 @@ def format_pretool_block_message(result: dict) -> str:
728
767
  lines.append(
729
768
  f"- {file_note}: task {item['task_id']} still has blocking guard debt. Acknowledge it with `nexo_task_acknowledge_guard` before retrying."
730
769
  )
770
+ elif item.get("reason_code") == "missing_file_guard":
771
+ lines.append(
772
+ f"- {file_note}: `nexo_guard_check` obligatorio antes de editar. "
773
+ f"Run `nexo_guard_check(files='{file_note}')` first, then retry the edit."
774
+ )
731
775
  elif strictness == "learning":
732
776
  lines.append(
733
777
  f"- {file_note}: open `nexo_task_open(task_type='edit', files=['{file_note}'])` first, then rerun the edit."
@@ -10,6 +10,7 @@ import time
10
10
 
11
11
  from db import get_db
12
12
  from fastmcp.tools import Tool
13
+ from tree_hygiene import is_duplicate_artifact_name
13
14
 
14
15
  SERVER_DIR = os.path.dirname(os.path.abspath(__file__))
15
16
  PLUGINS_DIR = os.path.join(SERVER_DIR, "plugins")
@@ -47,12 +48,16 @@ def load_all_plugins(mcp) -> int:
47
48
  if os.path.isdir(PLUGINS_DIR):
48
49
  for f in sorted(os.listdir(PLUGINS_DIR)):
49
50
  if f.endswith(".py") and f != "__init__.py":
51
+ if is_duplicate_artifact_name(os.path.join(PLUGINS_DIR, f)):
52
+ continue
50
53
  plugin_map[f] = (PLUGINS_DIR, "repo")
51
54
 
52
55
  # 2. Personal plugins (override if same filename)
53
56
  if os.path.isdir(PERSONAL_PLUGINS_DIR):
54
57
  for f in sorted(os.listdir(PERSONAL_PLUGINS_DIR)):
55
58
  if f.endswith(".py") and f != "__init__.py":
59
+ if is_duplicate_artifact_name(os.path.join(PERSONAL_PLUGINS_DIR, f)):
60
+ continue
56
61
  source = "personal (override)" if f in plugin_map else "personal"
57
62
  plugin_map[f] = (PERSONAL_PLUGINS_DIR, source)
58
63
 
@@ -10,6 +10,7 @@ import time
10
10
  from pathlib import Path
11
11
 
12
12
  from runtime_home import export_resolved_nexo_home
13
+ from tree_hygiene import is_duplicate_artifact_name
13
14
 
14
15
  # Code root is the parent of plugins/:
15
16
  # - source checkout: <repo>/src
@@ -100,7 +101,7 @@ def _refresh_installed_manifest():
100
101
  if src_crons.exists():
101
102
  dst_crons.mkdir(parents=True, exist_ok=True)
102
103
  for f in src_crons.iterdir():
103
- if f.is_file():
104
+ if f.is_file() and not is_duplicate_artifact_name(f):
104
105
  dest = dst_crons / f.name
105
106
  if _paths_match(f, dest):
106
107
  continue
@@ -111,11 +112,11 @@ def _refresh_installed_manifest():
111
112
  "generated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
112
113
  "script_names": sorted(
113
114
  f.name for f in (artifact_src / "scripts").iterdir()
114
- if f.is_file()
115
+ if f.is_file() and not is_duplicate_artifact_name(f)
115
116
  ) if (artifact_src / "scripts").is_dir() else [],
116
117
  "hook_names": sorted(
117
118
  f.name for f in (artifact_src / "hooks").iterdir()
118
- if f.is_file()
119
+ if f.is_file() and not is_duplicate_artifact_name(f)
119
120
  ) if (artifact_src / "hooks").is_dir() else [],
120
121
  }
121
122
  (config_dir / "runtime-core-artifacts.json").write_text(
@@ -368,7 +369,7 @@ def _sync_hooks_to_home():
368
369
  hooks_dest.mkdir(parents=True, exist_ok=True)
369
370
  synced = 0
370
371
  for f in hooks_src.iterdir():
371
- if f.is_file() and f.suffix == ".sh":
372
+ if f.is_file() and f.suffix == ".sh" and not is_duplicate_artifact_name(f):
372
373
  dest = hooks_dest / f.name
373
374
  if not _paths_match(f, dest):
374
375
  shutil.copy2(str(f), str(dest))