claude-code-arcane 2.1.0 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +57 -55
  3. package/agents/audio/audio-director.md +90 -0
  4. package/agents/audio/audio-programmer.md +97 -0
  5. package/agents/audio/audio-qa.md +95 -0
  6. package/agents/audio/composer.md +88 -0
  7. package/agents/audio/technical-sound-designer.md +90 -0
  8. package/agents/audio/voice-director.md +96 -0
  9. package/agents/career/career-strategist.md +88 -0
  10. package/agents/career/cv-reviewer.md +81 -0
  11. package/agents/career/hiring-manager.md +68 -0
  12. package/agents/career/mock-interviewer.md +76 -0
  13. package/agents/freelance/client-screener.md +84 -0
  14. package/agents/freelance/discovery-call.md +65 -0
  15. package/agents/freelance/pipeline-strategist.md +99 -0
  16. package/agents/freelance/proposal-reviewer.md +82 -0
  17. package/dist/cli.js +52 -13
  18. package/docs/RELEASE-SETUP.md +1 -1
  19. package/docs/SKILLS-CATALOG.md +47 -3
  20. package/docs/USER-GUIDE.md +77 -72
  21. package/docs/agent-hierarchy.md +21 -1
  22. package/docs/presentations/build_arcane_deck.py +2 -2
  23. package/docs/presentations/build_arcane_deck_12.py +2 -2
  24. package/package.json +2 -2
  25. package/profiles/audio.yaml +45 -0
  26. package/profiles/freelance.yaml +43 -0
  27. package/profiles/job-hunt.yaml +5 -2
  28. package/rules/drafter-reviewer.md +16 -3
  29. package/rules/freelance-guardrails.md +53 -0
  30. package/rules/gamedev/audio-code.md +45 -0
  31. package/skills/adaptive-music/SKILL.md +171 -0
  32. package/skills/adaptive-music/references/anti-patterns.md +53 -0
  33. package/skills/audio-audit/SKILL.md +164 -0
  34. package/skills/audio-audit/references/anti-patterns.md +65 -0
  35. package/skills/audio-audit/scripts/check_assets.py +208 -0
  36. package/skills/audio-bible/SKILL.md +119 -0
  37. package/skills/audio-bible/references/anti-patterns.md +39 -0
  38. package/skills/audio-mix/SKILL.md +151 -0
  39. package/skills/audio-mix/references/anti-patterns.md +65 -0
  40. package/skills/audio-spec/SKILL.md +107 -0
  41. package/skills/audio-spec/references/anti-patterns.md +45 -0
  42. package/skills/career-registry/SKILL.md +95 -0
  43. package/skills/career-registry/scripts/career_registry.py +488 -0
  44. package/skills/client-screen/SKILL.md +83 -0
  45. package/skills/client-screen/references/red-flags.md +95 -0
  46. package/skills/cover-letter/SKILL.md +4 -3
  47. package/skills/cv-tailor/SKILL.md +11 -2
  48. package/skills/freelance-deliver/SKILL.md +90 -0
  49. package/skills/freelance-hunt/SKILL.md +126 -0
  50. package/skills/freelance-hunt/references/templates/Connects.md +39 -0
  51. package/skills/freelance-hunt/references/templates/Contrato.md +60 -0
  52. package/skills/freelance-hunt/references/templates/Propuesta.md +66 -0
  53. package/skills/freelance-kickoff/SKILL.md +94 -0
  54. package/skills/freelance-pipeline/SKILL.md +109 -0
  55. package/skills/freelance-pipeline/scripts/import_history.py +425 -0
  56. package/skills/freelance-profile/SKILL.md +93 -0
  57. package/skills/freelance-proposal/SKILL.md +112 -0
  58. package/skills/freelance-scan/SKILL.md +196 -0
  59. package/skills/freelance-scan/references/dedup.md +35 -0
  60. package/skills/freelance-scan/references/platforms.md +167 -0
  61. package/skills/freelance-scan/scripts/freelance_search.py +1077 -0
  62. package/skills/interview-prep/SKILL.md +11 -3
  63. package/skills/job-aplicar/SKILL.md +5 -1
  64. package/skills/job-hunt/SKILL.md +17 -0
  65. package/skills/job-outcome/SKILL.md +15 -1
  66. package/skills/job-upskill/SKILL.md +10 -2
  67. package/skills/middleware-integration/SKILL.md +186 -0
  68. package/skills/middleware-integration/references/anti-patterns.md +69 -0
  69. package/skills/midi-compose/SKILL.md +136 -0
  70. package/skills/midi-compose/references/smf-format.md +129 -0
  71. package/skills/midi-compose/scripts/compose_midi.py +326 -0
  72. package/skills/midi-compose/templates/example-composition.json +69 -0
  73. package/skills/music-composition/SKILL.md +106 -0
  74. package/skills/music-composition/references/form.md +57 -0
  75. package/skills/music-composition/references/game-music.md +82 -0
  76. package/skills/music-composition/references/harmony.md +82 -0
  77. package/skills/music-composition/references/melody.md +48 -0
  78. package/skills/music-composition/references/orchestration.md +69 -0
  79. package/skills/music-composition/references/rhythm-groove.md +49 -0
  80. package/skills/procedural-audio/SKILL.md +157 -0
  81. package/skills/procedural-audio/references/anti-patterns.md +45 -0
  82. package/skills/sfx-design/SKILL.md +130 -0
  83. package/skills/sfx-design/references/anti-patterns.md +53 -0
  84. package/skills/spatial-audio/SKILL.md +139 -0
  85. package/skills/spatial-audio/references/anti-patterns.md +53 -0
  86. package/skills/ui-sound-design/SKILL.md +133 -0
  87. package/skills/ui-sound-design/references/anti-patterns.md +57 -0
  88. package/skills/voice-pipeline/SKILL.md +150 -0
  89. package/skills/voice-pipeline/references/anti-patterns.md +69 -0
@@ -0,0 +1,488 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Registro y trazabilidad del career workspace: empleo y freelance en un solo lugar.
4
+
5
+ Contesta la pregunta que grepear a mano no contesta bien: "¿ya me postule aca?".
6
+ Y exporta todo a CSV para mirarlo en una planilla.
7
+
8
+ La regla que sostiene todo esto: **las notas son la fuente de verdad y el CSV se
9
+ deriva de ellas**. No hay una segunda base que mantener sincronizada, porque una
10
+ segunda base diverge en dos semanas y despues no sabes cual miente. Mismo flujo
11
+ unidireccional que el dedup de job-scrape: seen_jobs -> nota -> vista.
12
+
13
+ Lee tres tipos de nota, sin importar que perfil las creo:
14
+ tipo: aplicacion (03-Aplicaciones/, +job-hunt)
15
+ tipo: freelance (03-Aplicaciones/, +freelance)
16
+ tipo: contrato (08-Contratos/, +freelance)
17
+
18
+ Uso:
19
+ python career_registry.py check "Acme"
20
+ python career_registry.py check https://www.linkedin.com/jobs/view/123
21
+ python career_registry.py export --csv registro.csv
22
+ python career_registry.py stats
23
+ python career_registry.py audit
24
+
25
+ Todo es read-only salvo `export --csv`, que escribe el archivo que le pidas.
26
+ Solo stdlib. Compatible con Python 3.9.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import argparse
32
+ import csv
33
+ import io
34
+ import json
35
+ import os
36
+ import re
37
+ import sys
38
+ import unicodedata
39
+ from datetime import date, datetime
40
+ from pathlib import Path
41
+
42
+ DEFAULT_WORKSPACE = "career-workspace"
43
+ APPLICATIONS_DIR = "03-Aplicaciones"
44
+ CONTRACTS_DIR = "08-Contratos"
45
+ SEEN_JOBS = os.path.join("tools", "job_scraper", "seen_jobs.json")
46
+
47
+ KNOWN_TIPOS = ("aplicacion", "freelance", "contrato")
48
+
49
+ # Estados abiertos por tipo: los que todavia esperan algo del otro lado.
50
+ OPEN_STATES = {
51
+ "aplicacion": ("interesado", "aplicado", "entrevista", "oferta"),
52
+ "freelance": ("interesado", "screeneado", "propuesta_enviada",
53
+ "en_conversacion", "contrato_activo"),
54
+ "contrato": ("activo", "pausado"),
55
+ }
56
+ # Estados que significan "esto ya se envio" - los que importan para no repetir.
57
+ SENT_STATES = ("aplicado", "entrevista", "oferta", "contratado", "rechazado",
58
+ "sin_respuesta", "declinada", "propuesta_enviada",
59
+ "en_conversacion", "contrato_activo", "entregado",
60
+ "ganado_cerrado", "disputa")
61
+
62
+ STALE_DAYS = 21
63
+
64
+ # Sufijos legales que no distinguen una empresa de otra. Mismo criterio que el
65
+ # job_key del dedup, para que las dos herramientas coincidan.
66
+ LEGAL_SUFFIXES = ("inc", "llc", "ltd", "sa", "srl", "sas", "gmbh", "bv", "co",
67
+ "corp", "company", "sl", "spa", "plc", "ag", "oy", "ab")
68
+
69
+ CSV_COLUMNS = ("tipo", "contraparte", "titulo", "estado", "abierto", "plataforma",
70
+ "match_score", "riesgo_cliente", "perfil", "bid_o_monto",
71
+ "costo_postulacion", "fecha_envio", "fecha_actualizacion",
72
+ "dias_sin_movimiento", "link_oferta", "nota")
73
+
74
+
75
+ # --------------------------------------------------------------------------- #
76
+ # Lectura de notas
77
+ # --------------------------------------------------------------------------- #
78
+
79
+ def fold(text: str) -> str:
80
+ """Minusculas sin acentos, para comparar 'Tecnologia' con 'tecnología'."""
81
+ decomposed = unicodedata.normalize("NFD", (text or "").lower())
82
+ return "".join(c for c in decomposed if unicodedata.category(c) != "Mn")
83
+
84
+
85
+ def slug(text: str) -> str:
86
+ """
87
+ Nombre comparable: sin acentos, sin puntuacion, sin sufijo legal.
88
+
89
+ Los puntos se borran ANTES de tokenizar, no se cambian por espacios: "S.A."
90
+ tiene que colapsar a "sa" para que el sufijo legal se reconozca. Partiendolo
91
+ quedaba "s" y "a", ninguno esta en la lista, y "Globex S.A." no matcheaba con
92
+ "Globex".
93
+ """
94
+ base = re.sub(r"\.", "", fold(text))
95
+ base = re.sub(r"[^a-z0-9 ]+", " ", base)
96
+ words = [w for w in base.split() if w and w not in LEGAL_SUFFIXES]
97
+ return " ".join(words)
98
+
99
+
100
+ def parse_frontmatter(text: str):
101
+ """
102
+ Frontmatter YAML plano: `clave: valor` y listas en bloque.
103
+
104
+ No usa pyyaml porque los scripts de skill corren sin instalar nada. Soporta
105
+ lo que los templates del workspace realmente usan; cualquier cosa mas
106
+ exotica se ignora en vez de reventar.
107
+ """
108
+ if not text.startswith("---"):
109
+ return {}
110
+ end = text.find("\n---", 3)
111
+ if end == -1:
112
+ return {}
113
+ fields = {}
114
+ key = None
115
+ for raw in text[3:end].splitlines():
116
+ line = raw.rstrip()
117
+ if not line.strip() or line.lstrip().startswith("#"):
118
+ continue
119
+ if line.lstrip().startswith("- ") and key:
120
+ # Una lista en bloque solo cuenta si la clave quedo vacia; si no, el
121
+ # valor escalar ya leido es el bueno.
122
+ if not isinstance(fields.get(key), list):
123
+ if str(fields.get(key) or "").strip():
124
+ continue
125
+ fields[key] = []
126
+ fields[key].append(line.lstrip()[2:].strip())
127
+ continue
128
+ match = re.match(r"^([A-Za-z0-9_-]+)\s*:\s*(.*)$", line)
129
+ if not match:
130
+ continue
131
+ key = match.group(1)
132
+ fields[key] = match.group(2).strip()
133
+ return fields
134
+
135
+
136
+ def parse_date(value):
137
+ for fmt in ("%Y-%m-%d", "%d/%m/%Y", "%Y/%m/%d"):
138
+ try:
139
+ return datetime.strptime(str(value).strip(), fmt).date()
140
+ except (ValueError, TypeError):
141
+ continue
142
+ return None
143
+
144
+
145
+ def number(value):
146
+ """Numero o None. Un campo de template vacio no es un cero."""
147
+ text = str(value or "").strip().replace(",", ".")
148
+ try:
149
+ return float(text)
150
+ except ValueError:
151
+ return None
152
+
153
+
154
+ def days_since(value, today):
155
+ parsed = parse_date(value)
156
+ return (today - parsed).days if parsed else None
157
+
158
+
159
+ def canonical_url(url: str) -> str:
160
+ """URL sin tracking, para que dos links de la misma oferta coincidan."""
161
+ if not url:
162
+ return ""
163
+ clean = url.strip().split("#")[0]
164
+ clean = re.sub(r"[?&](utm_[^&]*|ref|source|trk|trackingId)=[^&]*", "", clean)
165
+ clean = re.sub(r"[?&]$", "", clean)
166
+ match = re.search(r"linkedin\.com/jobs/view/(\d+)", clean)
167
+ if match:
168
+ return "https://www.linkedin.com/jobs/view/{0}".format(match.group(1))
169
+ return clean.rstrip("/").lower()
170
+
171
+
172
+ def load_notes(workspace: Path):
173
+ """Todas las notas de aplicaciones, propuestas y contratos, normalizadas."""
174
+ rows, problems = [], []
175
+ today = date.today()
176
+
177
+ for folder in (APPLICATIONS_DIR, CONTRACTS_DIR):
178
+ base = workspace / folder
179
+ if not base.exists():
180
+ continue
181
+ for path in sorted(base.rglob("*.md")):
182
+ if path.name.startswith("_"): # _index y similares
183
+ continue
184
+ try:
185
+ text = path.read_text(encoding="utf-8", errors="replace")
186
+ except OSError as e:
187
+ problems.append({"nota": path.name, "problema": "no se pudo leer: {0}".format(e)})
188
+ continue
189
+ fields = parse_frontmatter(text)
190
+ if not fields:
191
+ problems.append({"nota": path.name, "problema": "sin frontmatter"})
192
+ continue
193
+
194
+ tipo = (fields.get("tipo") or "").strip() or "desconocido"
195
+ if tipo not in KNOWN_TIPOS:
196
+ problems.append({"nota": path.name,
197
+ "problema": "tipo desconocido: {0}".format(tipo)})
198
+
199
+ estado = (fields.get("estado") or "").strip()
200
+ # Compatibilidad: notas viejas usan `score` en vez de `match_score`.
201
+ score = fields.get("match_score") or fields.get("score") or ""
202
+ # Empresa en empleo, cliente en freelance y contratos.
203
+ contraparte = (fields.get("empresa") or fields.get("cliente") or "").strip()
204
+ fecha_envio = fields.get("fecha_aplicacion") or fields.get("fecha_envio") \
205
+ or fields.get("fecha_inicio") or ""
206
+ actualizacion = fields.get("fecha_actualizacion") or ""
207
+
208
+ rows.append({
209
+ "tipo": tipo,
210
+ "contraparte": contraparte,
211
+ "titulo": path.stem,
212
+ "estado": estado,
213
+ "abierto": "si" if estado in OPEN_STATES.get(tipo, ()) else "no",
214
+ "plataforma": (fields.get("plataforma") or fields.get("fuente") or "").strip(),
215
+ "match_score": score,
216
+ "riesgo_cliente": (fields.get("riesgo_cliente") or "").strip(),
217
+ "perfil": (fields.get("perfil") or fields.get("perfil_base") or "").strip(),
218
+ "bid_o_monto": (fields.get("bid") or fields.get("monto_acordado")
219
+ or fields.get("salario_rango") or "").strip(),
220
+ "costo_postulacion": (fields.get("connects_gastados") or "").strip(),
221
+ "fecha_envio": fecha_envio,
222
+ "fecha_actualizacion": actualizacion,
223
+ "dias_sin_movimiento": days_since(actualizacion, today),
224
+ "link_oferta": (fields.get("link_oferta") or "").strip(),
225
+ "nota": str(path.relative_to(workspace)).replace("\\", "/"),
226
+ # Solo en contratos. Se guardan crudas para que audit compare sin
227
+ # tener que reabrir el archivo.
228
+ "horas_estimadas": number(fields.get("horas_estimadas")),
229
+ "horas_reales": number(fields.get("horas_reales")),
230
+ })
231
+
232
+ return rows, problems
233
+
234
+
235
+ def load_seen_jobs(workspace: Path):
236
+ path = workspace / SEEN_JOBS
237
+ if not path.exists():
238
+ return None
239
+ try:
240
+ return json.loads(path.read_text(encoding="utf-8", errors="replace"))
241
+ except (OSError, ValueError):
242
+ return None
243
+
244
+
245
+ # --------------------------------------------------------------------------- #
246
+ # check: la pregunta "¿ya me postule aca?"
247
+ # --------------------------------------------------------------------------- #
248
+
249
+ def match_rows(rows, needle: str):
250
+ """
251
+ Busca por tres claves distintas porque el usuario pega cualquiera de las tres:
252
+ el nombre de la contraparte, la URL de la oferta, o el titulo de la nota.
253
+ """
254
+ hits = []
255
+ url = canonical_url(needle) if "://" in needle else ""
256
+ target = slug(needle)
257
+
258
+ for row in rows:
259
+ why = None
260
+ if url and canonical_url(row["link_oferta"]) == url:
261
+ why = "misma URL de la oferta"
262
+ elif target and target == slug(row["contraparte"]):
263
+ why = "misma contraparte"
264
+ elif target and target and target in slug(row["titulo"]):
265
+ why = "coincide con el titulo de la nota"
266
+ elif target and slug(row["contraparte"]) and target in slug(row["contraparte"]):
267
+ why = "contraparte parecida"
268
+ if why:
269
+ hits.append(dict(row, coincide_por=why))
270
+ return hits
271
+
272
+
273
+ def run_check(args) -> int:
274
+ workspace = resolve_workspace(args)
275
+ rows, _ = load_notes(workspace)
276
+ needle = args.termino
277
+ hits = match_rows(rows, needle)
278
+
279
+ seen = load_seen_jobs(workspace)
280
+ seen_hits = []
281
+ if seen and "://" in needle:
282
+ url = canonical_url(needle)
283
+ for entry in (seen.get("jobs") or seen.get("entries") or []):
284
+ if canonical_url(str(entry.get("url") or "")) == url:
285
+ seen_hits.append({"status": entry.get("status"),
286
+ "quick_score": entry.get("quick_score"),
287
+ "nota": entry.get("nota"),
288
+ "last_seen": entry.get("last_seen")})
289
+
290
+ already_sent = [h for h in hits if h["estado"] in SENT_STATES]
291
+ veredicto = "YA TE POSTULASTE" if already_sent else (
292
+ "YA LO VISTE, NO TE POSTULASTE" if (hits or seen_hits) else "SIN REGISTRO")
293
+
294
+ emit({
295
+ "busque": needle,
296
+ "veredicto": veredicto,
297
+ "coincidencias_en_notas": hits,
298
+ "coincidencias_en_dedup": seen_hits,
299
+ "dedup_disponible": seen is not None,
300
+ })
301
+ return 0
302
+
303
+
304
+ # --------------------------------------------------------------------------- #
305
+ # export, stats, audit
306
+ # --------------------------------------------------------------------------- #
307
+
308
+ def run_export(args) -> int:
309
+ workspace = resolve_workspace(args)
310
+ rows, problems = load_notes(workspace)
311
+ if args.tipo != "all":
312
+ rows = [r for r in rows if r["tipo"] == args.tipo]
313
+ rows.sort(key=lambda r: (r["tipo"], str(r["fecha_actualizacion"] or ""), r["titulo"]),
314
+ reverse=True)
315
+
316
+ buffer = io.StringIO()
317
+ writer = csv.DictWriter(buffer, fieldnames=list(CSV_COLUMNS), extrasaction="ignore",
318
+ lineterminator="\n")
319
+ writer.writeheader()
320
+ writer.writerows(rows)
321
+ content = buffer.getvalue()
322
+
323
+ if args.csv:
324
+ target = Path(args.csv)
325
+ # newline="" para que csv no meta \r\r\n en Windows.
326
+ with open(target, "w", encoding="utf-8-sig", newline="") as handle:
327
+ handle.write(content)
328
+ emit({"escrito": str(target), "filas": len(rows), "columnas": list(CSV_COLUMNS),
329
+ "notas_con_problemas": problems})
330
+ return 0
331
+
332
+ sys.stdout.write(content)
333
+ return 0
334
+
335
+
336
+ def run_stats(args) -> int:
337
+ workspace = resolve_workspace(args)
338
+ rows, problems = load_notes(workspace)
339
+ today = date.today()
340
+
341
+ by_tipo = {}
342
+ for row in rows:
343
+ bucket = by_tipo.setdefault(row["tipo"], {"total": 0, "abiertas": 0, "por_estado": {}})
344
+ bucket["total"] += 1
345
+ bucket["abiertas"] += 1 if row["abierto"] == "si" else 0
346
+ estado = row["estado"] or "(sin estado)"
347
+ bucket["por_estado"][estado] = bucket["por_estado"].get(estado, 0) + 1
348
+
349
+ enviadas = [r for r in rows if r["estado"] in SENT_STATES]
350
+ costo = sum(float(r["costo_postulacion"]) for r in enviadas
351
+ if str(r["costo_postulacion"]).replace(".", "", 1).isdigit())
352
+
353
+ emit({
354
+ "hoy": today.isoformat(),
355
+ "total_notas": len(rows),
356
+ "por_tipo": by_tipo,
357
+ "enviadas": len(enviadas),
358
+ "costo_de_postulaciones_registrado": costo or None,
359
+ "notas_con_problemas": len(problems),
360
+ # Sin muestra suficiente, cualquier tasa es ruido. Que lo diga el script
361
+ # y no la interpretacion de despues.
362
+ "muestra_suficiente_para_tasas": len(enviadas) >= 10,
363
+ })
364
+ return 0
365
+
366
+
367
+ def run_audit(args) -> int:
368
+ """Divergencias. Sin esto la trazabilidad es una intencion."""
369
+ workspace = resolve_workspace(args)
370
+ rows, problems = load_notes(workspace)
371
+ seen = load_seen_jobs(workspace)
372
+ today = date.today()
373
+
374
+ sin_estado = [r["nota"] for r in rows if not r["estado"]]
375
+ sin_contraparte = [r["nota"] for r in rows if not r["contraparte"]]
376
+ enviadas_sin_fecha = [r["nota"] for r in rows
377
+ if r["estado"] in SENT_STATES and not r["fecha_envio"]]
378
+ frenadas = [{"nota": r["nota"], "estado": r["estado"],
379
+ "dias": r["dias_sin_movimiento"]}
380
+ for r in rows
381
+ if r["abierto"] == "si" and (r["dias_sin_movimiento"] or 0) >= STALE_DAYS]
382
+
383
+ # Duplicados: la misma contraparte con el mismo titulo dos veces.
384
+ keys = {}
385
+ for row in rows:
386
+ key = (slug(row["contraparte"]), slug(row["titulo"]))
387
+ if not key[0]:
388
+ continue
389
+ keys.setdefault(key, []).append(row["nota"])
390
+ duplicados = [{"contraparte": k[0], "notas": v} for k, v in keys.items() if len(v) > 1]
391
+
392
+ # Horas reales por encima de lo estimado = trabajo regalado si no hubo change
393
+ # order. Aca solo se senala; el analisis del patron es de /freelance-pipeline.
394
+ horas_de_mas = []
395
+ for row in rows:
396
+ est, real = row.get("horas_estimadas"), row.get("horas_reales")
397
+ if est and real and real > est:
398
+ horas_de_mas.append({
399
+ "nota": row["nota"], "estimadas": est, "reales": real,
400
+ "exceso_horas": round(real - est, 2),
401
+ "exceso_pct": round((real - est) / est * 100),
402
+ })
403
+
404
+ punteros_rotos = []
405
+ if seen:
406
+ existing = {r["nota"] for r in rows}
407
+ for entry in (seen.get("jobs") or seen.get("entries") or []):
408
+ target = entry.get("nota")
409
+ if target and not any(target in n or n in target for n in existing):
410
+ punteros_rotos.append({"url": entry.get("url"), "nota": target})
411
+
412
+ emit({
413
+ "notas_revisadas": len(rows),
414
+ "sin_frontmatter_o_tipo_raro": problems,
415
+ "sin_estado": sin_estado,
416
+ "sin_contraparte": sin_contraparte,
417
+ "enviadas_sin_fecha_de_envio": enviadas_sin_fecha,
418
+ "abiertas_frenadas_mas_de_{0}_dias".format(STALE_DAYS): frenadas,
419
+ "posibles_duplicados": duplicados,
420
+ "contratos_con_horas_arriba_de_lo_estimado": horas_de_mas,
421
+ "punteros_de_dedup_rotos": punteros_rotos,
422
+ "dedup_disponible": seen is not None,
423
+ })
424
+ return 0
425
+
426
+
427
+ # --------------------------------------------------------------------------- #
428
+
429
+ def resolve_workspace(args) -> Path:
430
+ candidate = args.workspace or os.environ.get("CAREER_WORKSPACE") or DEFAULT_WORKSPACE
431
+ path = Path(candidate).expanduser()
432
+ if not path.exists():
433
+ emit({"error": "no existe el workspace: {0}".format(path),
434
+ "code": "NO_WORKSPACE",
435
+ "ayuda": "pasa --workspace, o defini CAREER_WORKSPACE, o corre /freelance-hunt setup"})
436
+ raise SystemExit(1)
437
+ return path
438
+
439
+
440
+ def emit(payload) -> None:
441
+ json.dump(payload, sys.stdout, ensure_ascii=False, indent=2, default=str)
442
+ sys.stdout.write("\n")
443
+
444
+
445
+ def force_utf8_streams() -> None:
446
+ """En Windows stdout es cp1252 y un acento revienta al pipear a un archivo."""
447
+ for stream in (sys.stdout, sys.stderr):
448
+ if hasattr(stream, "reconfigure"):
449
+ stream.reconfigure(encoding="utf-8")
450
+
451
+
452
+ def build_parser():
453
+ parser = argparse.ArgumentParser(
454
+ description="Registro y trazabilidad de postulaciones, propuestas y contratos.")
455
+ parser.add_argument("--workspace", help="ruta del career workspace")
456
+ sub = parser.add_subparsers(dest="command", required=True)
457
+
458
+ check = sub.add_parser("check", help="¿ya me postule aca? por empresa, URL o titulo")
459
+ check.add_argument("termino")
460
+ check.set_defaults(func=run_check)
461
+
462
+ export = sub.add_parser("export", help="exportar todo a CSV")
463
+ export.add_argument("--csv", help="archivo destino; sin esto va a stdout")
464
+ export.add_argument("--tipo", default="all",
465
+ choices=list(KNOWN_TIPOS) + ["all"])
466
+ export.set_defaults(func=run_export)
467
+
468
+ stats = sub.add_parser("stats", help="conteos por tipo y estado")
469
+ stats.set_defaults(func=run_stats)
470
+
471
+ audit = sub.add_parser("audit", help="divergencias, duplicados y notas frenadas")
472
+ audit.set_defaults(func=run_audit)
473
+ return parser
474
+
475
+
476
+ def main(argv=None) -> int:
477
+ force_utf8_streams()
478
+ args = build_parser().parse_args(argv)
479
+ try:
480
+ return args.func(args)
481
+ except SystemExit as e:
482
+ return int(e.code or 0)
483
+ except KeyboardInterrupt:
484
+ return 130
485
+
486
+
487
+ if __name__ == "__main__":
488
+ sys.exit(main())
@@ -0,0 +1,83 @@
1
+ ---
2
+ name: client-screen
3
+ description: "Score the risk of an Upwork client before spending Connects: payment verification, hire rate, budget vs scope, and red flags in the post. Produces a verdict, what to ask, and a recorded decision even when the answer is do not apply. Triggers: screenear cliente, vale la pena postularme, este cliente es confiable, red flags de esta oferta, riesgo del cliente, me conviene gastar connects."
4
+ argument-hint: "[job-url | nota de aplicación | texto del post pegado]"
5
+ category: "career"
6
+ user-invocable: true
7
+ allowed-tools: Read, Glob, Grep, Write, Edit, WebFetch, Task
8
+ ---
9
+
10
+ # Client Screen - Riesgo del cliente antes de gastar Connects
11
+
12
+ Evaluás al cliente **antes** de que se escriba una propuesta. Es el paso que no existe en la búsqueda de empleo y el que más plata ahorra: en Upwork postularse cuesta Connects, y un cliente malo cuesta además semanas y reputación.
13
+
14
+ Rutas relativas al career workspace (`--workspace`, env `CAREER_WORKSPACE`, o `./career-workspace/`).
15
+
16
+ Oferta indicada: `$ARGUMENTS`
17
+
18
+ ## Inputs
19
+
20
+ - **El job post**: URL → WebFetch (la página pública de la oferta suele ser accesible), texto pegado, o una nota existente de `03-Aplicaciones/`.
21
+ - **Los datos públicos del cliente**, que están en la misma página: payment method verificado o no, total gastado, cantidad de contrataciones, hire rate, promedio pagado por hora, reviews que dejó, país, antigüedad de la cuenta.
22
+ - **El piso de tarifa** de `01-Perfiles/`, para comparar contra el presupuesto ofrecido.
23
+
24
+ Si faltan los datos del cliente, **pedilos antes de opinar**. Un screen con la mitad de los datos es peor que ninguno: da falsa tranquilidad. Decí exactamente qué falta y dónde se ve en la página.
25
+
26
+ ## Proceso
27
+
28
+ ### 1. Recolectar los datos duros
29
+
30
+ Extraer del post y del panel del cliente. Anotar lo que no se pudo obtener, explícitamente.
31
+
32
+ ### 2. Presupuesto contra alcance
33
+
34
+ Estimar qué pide el post en serio (usar `/estimate` si el alcance es técnico y grande) y compararlo con lo que ofrece pagar, neto de comisión de Upwork.
35
+
36
+ Si hay brecha, cuantificala: "pide ~60h de trabajo, ofrece 300 USD = 5 USD/h, tu piso es 40". Ese número decide más que cualquier lectura de tono.
37
+
38
+ ### 3. Lanzar el agente `client-screener`
39
+
40
+ Contexto fresco, adversarial por diseño: arranca de la hipótesis de que el trabajo es un problema y busca la evidencia. Pasale inline el post completo y los datos del cliente. Devuelve nivel de riesgo, evidencia, brecha de presupuesto, qué preguntar y riesgo de JSS.
41
+
42
+ Existe como agente porque el sesgo del usuario apunta al otro lado: necesita trabajo, el post suena bien, y quiere que salga. Alguien tiene que empujar en contra, y no puede ser el que ya se entusiasmó leyendo la oferta.
43
+
44
+ Si el rol implica una llamada y el usuario quiere prepararla, ofrecer también `discovery-call`.
45
+
46
+ ### 4. Veredicto
47
+
48
+ `BAJO` · `MEDIO` · `ALTO` · `NO POSTULARSE`
49
+
50
+ Ante datos faltantes, el veredicto es el que corresponde a los datos que hay, no el optimista.
51
+
52
+ ### 5. Registrar la decisión, cualquiera sea
53
+
54
+ Con approval del usuario, crear o actualizar la nota en `03-Aplicaciones/` (template `Propuesta`, `tipo: freelance`):
55
+
56
+ - **Si se avanza:** `estado: screeneado`, sección `## Screen del cliente` con los datos duros, el nivel de riesgo, la brecha de presupuesto y las preguntas a hacer.
57
+ - **Si NO se avanza:** `estado: descartado` y el motivo en `## Notas`. **Esto no es opcional.** Un descarte sin registrar es un dato perdido, y `/freelance-pipeline` necesita los descartes para decirte si tu criterio de selección funciona o si estás filtrando mal.
58
+
59
+ ## Las señales que más importan
60
+
61
+ | Señal | Peso |
62
+ |---|---|
63
+ | Payment method sin verificar | El riesgo más caro. Sin verificación no hay garantía de pago |
64
+ | Presupuesto incoherente con el alcance | Expectativa desalineada, no oportunidad de negociar |
65
+ | Hire rate bajo con muchos posts publicados | Publica y no contrata: Connects a una oferta que quizá nunca se llene |
66
+ | Alcance difuso ("y otras tareas", "vamos definiendo") | Sin límite escrito no hay límite |
67
+ | Pedido de trabajo real gratis como "prueba" | Distinto de un test acotado y razonable |
68
+ | Presión para pagar fuera de la plataforma | Viola ToS, te deja sin protección de pago y arriesga tu cuenta |
69
+ | Reviews que el cliente dejó a otros freelancers | Si califica mal a todos, el patrón es él |
70
+
71
+ Detalle y cómo leer cada una: `references/red-flags.md`.
72
+
73
+ ## Reglas
74
+
75
+ - **Nunca inventar datos del cliente.** Si no se conoce el hire rate, se pide; no se estima.
76
+ - **No evaluar la propuesta acá.** Eso es de `/freelance-proposal`.
77
+ - **Un cliente limpio es riesgo bajo, y decirlo también es el trabajo.** Marcar todo como riesgoso vuelve el screen inútil y el usuario deja de correrlo.
78
+ - Sin guiones largos en el texto agregado a las notas.
79
+ - Nunca marcar `propuesta_enviada`: eso lo hace `/job-outcome` después del envío manual.
80
+
81
+ ## Handoff
82
+
83
+ Pedí aprobación (approval) antes de escribir la nota. Screen COMPLETE cuando la decisión quedó registrada, incluso si fue descartar. Si se avanza, el siguiente paso es `/estimate` para el esfuerzo y después `/freelance-proposal`; si se descarta, `/freelance-scan` para seguir con la cola.
@@ -0,0 +1,95 @@
1
+ # Red flags de un cliente - cómo leer cada señal
2
+
3
+ El objetivo no es descartar clientes: es no gastar Connects donde el resultado esperado es negativo, y no ganar contratos que van a costar más de lo que pagan.
4
+
5
+ Un cliente con payment verificado, historial y alcance escrito es **riesgo bajo**, y decirlo es parte del trabajo. Un screen que marca todo como riesgoso deja de leerse.
6
+
7
+ ## Señales duras (del panel del cliente)
8
+
9
+ Están en la página pública de la oferta. Si alguna falta, pedirla antes de opinar.
10
+
11
+ ### Payment method sin verificar
12
+
13
+ El riesgo más caro de la lista. Sin verificación no hay garantía de pago, y la protección de la plataforma se apoya en eso.
14
+
15
+ Un cliente nuevo sin verificar todavía es distinto de uno con historial que nunca verificó. El primero es incertidumbre; el segundo es una decisión que ya tomó.
16
+
17
+ ### Hire rate bajo con muchos posts publicados
18
+
19
+ Publica y no contrata. Tus Connects se van a una oferta que probablemente no se llene nunca.
20
+
21
+ No confundir con hire rate bajo y pocos posts: alguien que publicó dos veces y contrató una vez no tiene un patrón, tiene dos datos.
22
+
23
+ ### Total gastado en cero, cuenta nueva
24
+
25
+ No es descalificante por sí solo - todos empezaron alguna vez. Pero **sube un nivel el peso de todo lo demás**: sin historial, el post y el alcance son la única evidencia que hay.
26
+
27
+ ### Promedio pagado por hora muy bajo
28
+
29
+ Dato más útil que el presupuesto declarado: dice lo que este cliente **efectivamente paga**, no lo que dice estar dispuesto a pagar. Si su promedio está lejos abajo de tu piso, la negociación empieza perdida.
30
+
31
+ ### Reviews que el cliente dejó a otros freelancers
32
+
33
+ La señal más subestimada. Un cliente que calificó mal a casi todos los que contrató es el patrón, no la excepción, y vos vas a ser el próximo.
34
+
35
+ Leer también **cómo** escribe las reviews: quejas sobre "no entendió lo que quería" repetidas en varios freelancers distintos suelen indicar un problema de especificación del lado del cliente.
36
+
37
+ ### Muchos contratos abiertos y ninguno cerrado
38
+
39
+ Proyectos que no terminan, o que no cierra para no tener que calificar. En los dos casos, tu contrato puede quedar en el limbo - y un contrato sin cerrar no aporta al JSS.
40
+
41
+ ## Señales del texto del post
42
+
43
+ ### Presupuesto incoherente con el alcance
44
+
45
+ La más común y la más caro de ignorar. "E-commerce completo con panel de admin, 250 USD" no es una oportunidad de negociar: es una expectativa que ya está calibrada en otro orden de magnitud.
46
+
47
+ Cuantificar siempre: horas estimadas contra presupuesto ofrecido, en dinero por hora, neto de comisión. El número decide mejor que la lectura de tono.
48
+
49
+ ### Alcance difuso
50
+
51
+ "Y otras tareas relacionadas", "iremos definiendo en el camino", "algo simple, ya te vas a dar cuenta". Sin límite escrito no hay límite, y el límite lo vas a pagar vos en horas.
52
+
53
+ Difuso **más** precio fijo es la combinación que más plata pierde de todas.
54
+
55
+ ### Pedido de trabajo real gratis
56
+
57
+ Distinguir dos cosas:
58
+
59
+ - **Test acotado y razonable** (una hora, un ejercicio sintético, o pagado): legítimo, es común.
60
+ - **El trabajo real disfrazado de prueba** ("mandame el diseño de la home para ver tu estilo"): es trabajo gratis y suele repetirse con diez candidatos.
61
+
62
+ Si lo que piden es entregable, es trabajo.
63
+
64
+ ### Urgencia artificial
65
+
66
+ "Lo necesito hoy", "arrancamos ya". Casi nunca viene con organización; viene con caos, cambios de último momento y decisiones tomadas encima tuyo.
67
+
68
+ Urgencia real existe y a veces paga mejor. La diferencia se nota en si el cliente tiene el alcance claro: urgente y específico es una cosa, urgente y difuso es otra.
69
+
70
+ ### Presión para salirse de la plataforma
71
+
72
+ Pagar por fuera, mover la conversación a otro canal antes de contratar, pedir contacto directo. Es violación de ToS **de las dos partes**: te deja sin protección de pago y pone en riesgo la cuenta del usuario, no solo la del cliente.
73
+
74
+ Es red flag y a la vez motivo de no avanzar, independientemente de lo bueno que suene el proyecto.
75
+
76
+ ### Post genérico o reutilizado
77
+
78
+ El mismo texto en varias ofertas, o una descripción que no dice nada del proyecto concreto. Suele indicar un cliente que está tirando la red a ver qué pesca, y que va a tratar tu propuesta con la misma atención con la que escribió el post.
79
+
80
+ ### Requisitos contradictorios
81
+
82
+ Senior con presupuesto junior. Full-stack más diseño más marketing en un solo rol. Diez años de experiencia en una tecnología que tiene cinco.
83
+
84
+ No siempre es mala fe: muchas veces es un cliente que no sabe lo que necesita. Igual es riesgo, porque lo va a descubrir durante tu contrato.
85
+
86
+ ## Cómo pesar todo junto
87
+
88
+ No hay puntaje mecánico, y no conviene inventarlo: el peso depende de la combinación.
89
+
90
+ Dos guías que sí sirven:
91
+
92
+ 1. **Una señal dura sin verificar de pago pesa más que tres señales blandas de tono.** Lo primero te puede costar el pago entero.
93
+ 2. **La brecha presupuesto/alcance manda.** Si no cierra por un factor de 5, ninguna otra cosa lo compensa: no hay propuesta lo bastante buena para arreglar una expectativa desalineada en un orden de magnitud.
94
+
95
+ Cuando el veredicto sea no postularse, **registrar el descarte con el motivo**. Es la mitad de los datos que `/freelance-pipeline` necesita para decirte si tu filtro funciona.