claude-code-arcane 2.2.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 (47) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +56 -55
  3. package/agents/career/career-strategist.md +88 -0
  4. package/agents/career/cv-reviewer.md +81 -0
  5. package/agents/career/hiring-manager.md +68 -0
  6. package/agents/career/mock-interviewer.md +76 -0
  7. package/agents/freelance/client-screener.md +84 -0
  8. package/agents/freelance/discovery-call.md +65 -0
  9. package/agents/freelance/pipeline-strategist.md +99 -0
  10. package/agents/freelance/proposal-reviewer.md +82 -0
  11. package/dist/cli.js +52 -13
  12. package/docs/RELEASE-SETUP.md +1 -1
  13. package/docs/SKILLS-CATALOG.md +27 -3
  14. package/docs/USER-GUIDE.md +77 -72
  15. package/docs/agent-hierarchy.md +21 -1
  16. package/docs/presentations/build_arcane_deck.py +2 -2
  17. package/docs/presentations/build_arcane_deck_12.py +2 -2
  18. package/package.json +2 -2
  19. package/profiles/freelance.yaml +43 -0
  20. package/profiles/job-hunt.yaml +5 -2
  21. package/rules/drafter-reviewer.md +16 -3
  22. package/rules/freelance-guardrails.md +53 -0
  23. package/skills/career-registry/SKILL.md +95 -0
  24. package/skills/career-registry/scripts/career_registry.py +488 -0
  25. package/skills/client-screen/SKILL.md +83 -0
  26. package/skills/client-screen/references/red-flags.md +95 -0
  27. package/skills/cover-letter/SKILL.md +4 -3
  28. package/skills/cv-tailor/SKILL.md +11 -2
  29. package/skills/freelance-deliver/SKILL.md +90 -0
  30. package/skills/freelance-hunt/SKILL.md +126 -0
  31. package/skills/freelance-hunt/references/templates/Connects.md +39 -0
  32. package/skills/freelance-hunt/references/templates/Contrato.md +60 -0
  33. package/skills/freelance-hunt/references/templates/Propuesta.md +66 -0
  34. package/skills/freelance-kickoff/SKILL.md +94 -0
  35. package/skills/freelance-pipeline/SKILL.md +109 -0
  36. package/skills/freelance-pipeline/scripts/import_history.py +425 -0
  37. package/skills/freelance-profile/SKILL.md +93 -0
  38. package/skills/freelance-proposal/SKILL.md +112 -0
  39. package/skills/freelance-scan/SKILL.md +196 -0
  40. package/skills/freelance-scan/references/dedup.md +35 -0
  41. package/skills/freelance-scan/references/platforms.md +167 -0
  42. package/skills/freelance-scan/scripts/freelance_search.py +1077 -0
  43. package/skills/interview-prep/SKILL.md +11 -3
  44. package/skills/job-aplicar/SKILL.md +5 -1
  45. package/skills/job-hunt/SKILL.md +17 -0
  46. package/skills/job-outcome/SKILL.md +15 -1
  47. package/skills/job-upskill/SKILL.md +10 -2
@@ -0,0 +1,95 @@
1
+ ---
2
+ name: career-registry
3
+ description: "Traceability across the whole career workspace: check whether you already applied somewhere before spending another application, export everything to CSV, and audit the registry for gaps and drift. Covers job applications, freelance proposals and contracts. Triggers: ya me postule a, ya aplique aca, exportar postulaciones a csv, registro de aplicaciones, trazabilidad, historial de postulaciones, cuantas mande, planilla de postulaciones, auditar el registro."
4
+ argument-hint: "[check <empresa|url> | export | stats | audit]"
5
+ category: "career"
6
+ user-invocable: true
7
+ allowed-tools: Read, Glob, Grep, Bash, Write, Edit
8
+ ---
9
+
10
+ # Career Registry - Trazabilidad y registro
11
+
12
+ Contestás la pregunta que grepear a mano contesta mal: **¿ya me postulé acá?** Y das el registro completo en CSV para mirarlo en una planilla.
13
+
14
+ Sirve a los dos perfiles: `+job-hunt` (empleo) y `+freelance` (proyectos). Rutas relativas al career workspace (`--workspace`, env `CAREER_WORKSPACE`, o `./career-workspace/`).
15
+
16
+ Modo: `$ARGUMENTS`
17
+
18
+ ## La regla que sostiene todo esto
19
+
20
+ **Las notas son la fuente de verdad. El CSV se deriva de ellas, siempre.**
21
+
22
+ No hay una segunda base que mantener sincronizada, y no la va a haber: una segunda base diverge en dos semanas y después nadie sabe cuál miente. Es el mismo flujo unidireccional que el dedup de `/job-scrape` (`seen_jobs` → nota → vista, nunca de vuelta).
23
+
24
+ Consecuencia práctica: si el CSV está mal, se arregla la nota, no el CSV. Y si el usuario pide "guardame el CSV para editarlo a mano", avisale que en la próxima corrida se pisa.
25
+
26
+ ## Los cuatro modos
27
+
28
+ ```bash
29
+ python .claude/skills/career-registry/scripts/career_registry.py check "Acme"
30
+ python .claude/skills/career-registry/scripts/career_registry.py check "https://www.linkedin.com/jobs/view/123"
31
+ python .claude/skills/career-registry/scripts/career_registry.py export --csv registro.csv
32
+ python .claude/skills/career-registry/scripts/career_registry.py stats
33
+ python .claude/skills/career-registry/scripts/career_registry.py audit
34
+ ```
35
+
36
+ ### `check` - antes de gastar otra postulación
37
+
38
+ Busca por **tres claves** porque el usuario pega cualquiera de las tres: nombre de la contraparte, URL de la oferta, o título de la nota.
39
+
40
+ Es tolerante donde importa: `acme` encuentra a "Acme Inc" (ignora sufijos legales), `tecnologia latam` encuentra a "Tecnología Latam SA" (ignora acentos), y una URL con `?utm_source=...` distinto encuentra la misma oferta (canoniza el link, igual que el dedup).
41
+
42
+ Devuelve un veredicto de tres valores, y la diferencia entre los dos primeros importa:
43
+
44
+ | Veredicto | Qué significa |
45
+ |---|---|
46
+ | `YA TE POSTULASTE` | Hay una nota en estado enviado. No repitas |
47
+ | `YA LO VISTE, NO TE POSTULASTE` | Hay nota o entrada de dedup, pero nunca se envió. Vale revisarla antes de descartarla de nuevo |
48
+ | `SIN REGISTRO` | Nunca apareció |
49
+
50
+ Cuándo correrlo: **antes de `/client-screen` o `/job-aplicar`**, cuando el nombre suena conocido. Un screen sobre algo que ya descartaste hace tres meses es trabajo repetido, y peor, te hace repetir el descarte sin recordar por qué.
51
+
52
+ ### `export` - el CSV
53
+
54
+ Una fila por nota, con `tipo` discriminando empleo / freelance / contrato. Columnas: contraparte, título, estado, si está abierta, plataforma, `match_score`, riesgo del cliente, perfil usado, bid o monto, costo de postularse, fechas, **días sin movimiento**, link y la ruta de la nota.
55
+
56
+ Sin `--csv` va a stdout; con `--csv <archivo>` lo escribe en UTF-8 con BOM para que Excel no rompa los acentos. `--tipo` filtra a un solo tipo.
57
+
58
+ `dias_sin_movimiento` es la columna más útil de la planilla: ordenando por ahí aparece lo que quedó colgado.
59
+
60
+ ### `stats` - conteos
61
+
62
+ Total y abiertas por tipo, desglose por estado, cuántas se enviaron y el costo de postulaciones registrado.
63
+
64
+ Trae `muestra_suficiente_para_tasas`: **con menos de 10 enviadas es `false`, y ahí no hay que calcular tasas de conversión**. Diez rechazos no son un patrón, y tratarlos como tal manda al usuario a arreglar lo que no estaba roto.
65
+
66
+ ### `audit` - las divergencias
67
+
68
+ Lo que hace que la trazabilidad sea real y no una intención:
69
+
70
+ - Notas sin `estado` o sin contraparte
71
+ - Enviadas sin fecha de envío (rompe cualquier cálculo de tiempos)
72
+ - **Abiertas frenadas hace 21+ días** - candidatas a follow-up con `/cold-outreach` o a cerrar como `sin_respuesta`
73
+ - Posibles duplicados: misma contraparte, mismo título, dos notas
74
+ - **Contratos con horas reales arriba de lo estimado**, con el exceso en horas y en porcentaje. Cada uno es trabajo regalado si no hubo change order
75
+ - Punteros del dedup que apuntan a notas que ya no existen
76
+
77
+ ## Proceso
78
+
79
+ 1. Correr el modo pedido. Si el workspace no existe, el script lo dice y sugiere `/freelance-hunt setup` o `/job-hunt setup`.
80
+ 2. **Mostrar el resultado en el chat, no solo el JSON crudo.** Para `check`, el veredicto y una línea por coincidencia con su estado. Para `audit`, agrupado por severidad: primero lo que cuesta plata (horas de más, enviadas sin fecha), después lo cosmético.
81
+ 3. Para cada hallazgo de `audit`, ofrecer la acción concreta: follow-up, cerrar como `sin_respuesta`, change order, o completar el campo que falta.
82
+ 4. Los arreglos se hacen **en la nota** con approval del usuario, una por una. Nunca en masa.
83
+
84
+ ## Reglas
85
+
86
+ - **Nunca editar el CSV como si fuera la fuente de verdad.** Se regenera; los cambios van a la nota.
87
+ - **No editar notas en masa.** Cada arreglo se muestra y se aprueba.
88
+ - Nunca inventar un dato que falta: si una nota no tiene fecha de envío, se pregunta o se deja el hueco reportado.
89
+ - No cambiar `estado`: eso lo hace `/job-outcome`, que es el único que mueve el pipeline.
90
+ - El CSV puede tener nombres de clientes y montos: **no commitearlo a un remoto sin confirmación explícita**.
91
+ - Sin guiones largos en el texto agregado a las notas.
92
+
93
+ ## Handoff
94
+
95
+ Pedí aprobación (approval) antes de escribir el CSV o tocar una nota. Registro COMPLETE cuando el modo corrió y el usuario sabe qué hacer con cada hallazgo. Según el caso, el siguiente paso es `/cold-outreach` (follow-up de lo frenado), `/job-outcome` (cerrar lo que no va a responder), `/contract-and-proposal-writer` (change order de las horas de más) o `/freelance-pipeline` (si hay 10+ resueltas y querés el diagnóstico del embudo).
@@ -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.