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.
- package/CHANGELOG.md +22 -0
- package/README.md +56 -55
- package/agents/career/career-strategist.md +88 -0
- package/agents/career/cv-reviewer.md +81 -0
- package/agents/career/hiring-manager.md +68 -0
- package/agents/career/mock-interviewer.md +76 -0
- package/agents/freelance/client-screener.md +84 -0
- package/agents/freelance/discovery-call.md +65 -0
- package/agents/freelance/pipeline-strategist.md +99 -0
- package/agents/freelance/proposal-reviewer.md +82 -0
- package/dist/cli.js +52 -13
- package/docs/RELEASE-SETUP.md +1 -1
- package/docs/SKILLS-CATALOG.md +27 -3
- package/docs/USER-GUIDE.md +77 -72
- package/docs/agent-hierarchy.md +21 -1
- package/docs/presentations/build_arcane_deck.py +2 -2
- package/docs/presentations/build_arcane_deck_12.py +2 -2
- package/package.json +2 -2
- package/profiles/freelance.yaml +43 -0
- package/profiles/job-hunt.yaml +5 -2
- package/rules/drafter-reviewer.md +16 -3
- package/rules/freelance-guardrails.md +53 -0
- package/skills/career-registry/SKILL.md +95 -0
- package/skills/career-registry/scripts/career_registry.py +488 -0
- package/skills/client-screen/SKILL.md +83 -0
- package/skills/client-screen/references/red-flags.md +95 -0
- package/skills/cover-letter/SKILL.md +4 -3
- package/skills/cv-tailor/SKILL.md +11 -2
- package/skills/freelance-deliver/SKILL.md +90 -0
- package/skills/freelance-hunt/SKILL.md +126 -0
- package/skills/freelance-hunt/references/templates/Connects.md +39 -0
- package/skills/freelance-hunt/references/templates/Contrato.md +60 -0
- package/skills/freelance-hunt/references/templates/Propuesta.md +66 -0
- package/skills/freelance-kickoff/SKILL.md +94 -0
- package/skills/freelance-pipeline/SKILL.md +109 -0
- package/skills/freelance-pipeline/scripts/import_history.py +425 -0
- package/skills/freelance-profile/SKILL.md +93 -0
- package/skills/freelance-proposal/SKILL.md +112 -0
- package/skills/freelance-scan/SKILL.md +196 -0
- package/skills/freelance-scan/references/dedup.md +35 -0
- package/skills/freelance-scan/references/platforms.md +167 -0
- package/skills/freelance-scan/scripts/freelance_search.py +1077 -0
- package/skills/interview-prep/SKILL.md +11 -3
- package/skills/job-aplicar/SKILL.md +5 -1
- package/skills/job-hunt/SKILL.md +17 -0
- package/skills/job-outcome/SKILL.md +15 -1
- 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.
|