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,109 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: freelance-pipeline
|
|
3
|
+
description: "Diagnose the freelance business, not one proposal: Connects ROI, proposal funnel, real effective hourly rate net of unbilled hours, utilization, client concentration and scope drift on active contracts. Triggers: como va mi pipeline freelance, cuanto gano por hora de verdad, roi de connects, por que no me responden las propuestas, utilizacion, scope creep de un contrato."
|
|
4
|
+
argument-hint: "[--desde YYYY-MM-DD]"
|
|
5
|
+
category: "career"
|
|
6
|
+
user-invocable: true
|
|
7
|
+
allowed-tools: Read, Glob, Grep, Write, Edit, WebSearch, Task
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
# Freelance Pipeline - Diagnóstico del negocio
|
|
11
|
+
|
|
12
|
+
Mirás el negocio entero, no la propuesta de mañana. La pregunta que contestás es si esto funciona como negocio, y si no, qué **una** cosa cambiar.
|
|
13
|
+
|
|
14
|
+
Rutas relativas al career workspace (`--workspace`, env `CAREER_WORKSPACE`, o `./career-workspace/`).
|
|
15
|
+
|
|
16
|
+
Ventana: `$ARGUMENTS` (vacío = todo el historial).
|
|
17
|
+
|
|
18
|
+
## Inputs
|
|
19
|
+
|
|
20
|
+
- `03-Aplicaciones/` con `tipo: freelance` - **todas**, incluidas las `descartado`. Los descartes dicen si tu criterio de selección funciona; sin ellos el diagnóstico está sesgado a las que sí mandaste.
|
|
21
|
+
- `08-Contratos/` - activos y cerrados: estimado contra real, milestones, horas, change orders.
|
|
22
|
+
- El ledger de Connects en `07-Recursos/` - gasto por propuesta y resultado.
|
|
23
|
+
- `01-Perfiles/` - el piso de tarifa neto y la capacidad semanal declarada.
|
|
24
|
+
|
|
25
|
+
### Los datos duros salen del export, no de la memoria
|
|
26
|
+
|
|
27
|
+
Si el usuario tiene historial en una plataforma, **no le preguntes los montos y las horas**: que baje el CSV y usamos el importador. Nadie recuerda bien cuánto facturó.
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
python .claude/skills/freelance-pipeline/scripts/import_history.py inspect historial.csv
|
|
31
|
+
python .claude/skills/freelance-pipeline/scripts/import_history.py summary historial.csv
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Mismo patrón que `network_map.py` de `+job-hunt`: sin credenciales, sin tocar la cuenta. El usuario exporta, el script lee.
|
|
35
|
+
|
|
36
|
+
**Correr `inspect` primero, siempre, y mostrarle el mapeo.** El script detecta las columnas porque los exports no tienen esquema estable, y reporta `columnas_sin_mapear` y `columnas_ambiguas`. Antes de creerle a un total hay que ver qué columna se usó; si eligió mal, se corrige con `--map "Columna=campo"`.
|
|
37
|
+
|
|
38
|
+
Dos cosas que el summary te dice y hay que respetar: `tarifa_efectiva_calculable: false` significa que el export no trae horas y **la tarifa efectiva no se estima**, se pide el dato; y `muestra_suficiente: false` significa que no alcanza para hablar de tasas.
|
|
39
|
+
|
|
40
|
+
## El guard de volumen (primero, obligatorio)
|
|
41
|
+
|
|
42
|
+
Contar antes de afirmar.
|
|
43
|
+
|
|
44
|
+
- **Menos de 10 propuestas resueltas:** no hay embudo. Entregar solo lo descriptivo (cuántas, Connects gastados, tiempo de respuesta) y nombrar la hipótesis más barata de testear. **Nada de patrones.**
|
|
45
|
+
- **10 a 25:** tendencias, con la muestra a la vista en cada afirmación ("3 de 14").
|
|
46
|
+
- **25+:** tasas de conversión.
|
|
47
|
+
|
|
48
|
+
Tres propuestas sin respuesta no son un patrón. Tratarlas como tal manda al usuario a bajar la tarifa, que es el peor movimiento posible y el más difícil de revertir.
|
|
49
|
+
|
|
50
|
+
## Las cinco métricas
|
|
51
|
+
|
|
52
|
+
### 1. ROI de Connects
|
|
53
|
+
|
|
54
|
+
`Connects gastados / contratos ganados` = costo de adquisición. Y el número que más duele: **cuántos Connects se fueron en ofertas que nunca contrataron a nadie**. Esa pérdida es evitable con mejor screening, así que se reporta aparte.
|
|
55
|
+
|
|
56
|
+
### 2. Embudo
|
|
57
|
+
|
|
58
|
+
```
|
|
59
|
+
oferta vista → screen pasado → propuesta enviada → cliente responde → llamada → contrato
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
La etapa clave es **enviada → responde**. Cruzarla con el score cambia el diagnóstico:
|
|
63
|
+
|
|
64
|
+
| Sin respuesta + score alto | Sin respuesta + score bajo |
|
|
65
|
+
|---|---|
|
|
66
|
+
| Problema de propuesta: apertura, precio, plantilla detectable | Problema de targeting: te postulás a lo que no te corresponde |
|
|
67
|
+
| Va a `/freelance-proposal` | Va a `/freelance-scan` y `/freelance-profile` |
|
|
68
|
+
|
|
69
|
+
### 3. Tarifa efectiva real
|
|
70
|
+
|
|
71
|
+
`total cobrado / (horas facturadas + horas de propuestas + llamadas + admin)`, **neto de comisión**.
|
|
72
|
+
|
|
73
|
+
No es la tarifa del contrato y casi siempre es bastante menor. Es el número que dice si el negocio funciona. **Si quedó abajo del piso declarado, ese es el hallazgo principal** y todo lo demás es secundario.
|
|
74
|
+
|
|
75
|
+
### 4. Utilización y concentración
|
|
76
|
+
|
|
77
|
+
- Horas facturables contra capacidad declarada. Muy baja = problema de pipeline. Muy alta sostenida = riesgo de burnout y de no tener tiempo para vender, que es cómo se llega al mes siguiente sin nada.
|
|
78
|
+
- **Concentración**: porcentaje de ingresos del cliente más grande. Arriba de la mitad, no es un negocio: es un empleo sin sus protecciones y sin su estabilidad.
|
|
79
|
+
|
|
80
|
+
### 5. Scope drift en contratos activos
|
|
81
|
+
|
|
82
|
+
Contratos donde las horas reales superan lo estimado **sin change order**. Cada uno es trabajo regalado.
|
|
83
|
+
|
|
84
|
+
El patrón importa más que el caso: si pasa en casi todos, el problema está en cómo se escriben los alcances (va a `/contract-and-proposal-writer`), no en los clientes. Por cada contrato con drift, ofrecer redactar el change order - alcance nuevo, precio nuevo (regla 3 de `freelance-guardrails`).
|
|
85
|
+
|
|
86
|
+
## Proceso
|
|
87
|
+
|
|
88
|
+
1. **Recolectar y contar.** Aplicar el guard de volumen y decir en qué banda estamos.
|
|
89
|
+
2. **Calcular las cinco métricas.** Mostrarlas en el chat con la muestra explícita, antes de interpretar nada.
|
|
90
|
+
3. **Lanzar `pipeline-strategist`** (contexto fresco, read-only) si hay 10+ propuestas resueltas. Devuelve punto de fuga, hipótesis con cómo testearlas, UN cambio recomendado y qué no tocar. Con menos volumen, saltear: el agente lo va a rechazar igual.
|
|
91
|
+
4. **Reporte** en `07-Recursos/Pipeline - YYYY-MM-DD.md`, con approval del usuario sobre las métricas:
|
|
92
|
+
- Frontmatter: `tipo: pipeline`, `fecha`, `propuestas_analizadas`, `contratos_analizados`, `ventana`.
|
|
93
|
+
- Las cinco métricas con su muestra.
|
|
94
|
+
- El punto de fuga y su evidencia, con wikilinks a las notas que lo sostienen.
|
|
95
|
+
- El cambio recomendado y a qué skill se le pasa.
|
|
96
|
+
- Si existe un reporte anterior, sección "Desde el último reporte": qué se movió y qué no.
|
|
97
|
+
5. **No editar** notas de propuestas ni de contratos. Solo crear el reporte y, si el usuario lo pide, los change orders de los contratos con drift.
|
|
98
|
+
|
|
99
|
+
## Reglas
|
|
100
|
+
|
|
101
|
+
- **Datos antes que consejos.** Cada afirmación se apoya en un conteo o en una cita del feedback registrado.
|
|
102
|
+
- **UN cambio recomendado, no un plan de 8 puntos.** Si se cambian cinco cosas no se sabe cuál funcionó.
|
|
103
|
+
- **Bajar la tarifa nunca es la primera palanca.** Solo entra si la evidencia dice que el posicionamiento está mal, y con ese fundamento explícito.
|
|
104
|
+
- Nunca fabricar métricas: si falta el dato (horas no registradas, Connects sin anotar), reportar el hueco y qué habría que empezar a registrar.
|
|
105
|
+
- Sin guiones largos, con wikilinks a las notas fuente.
|
|
106
|
+
|
|
107
|
+
## Handoff
|
|
108
|
+
|
|
109
|
+
Reporte COMPLETE en `07-Recursos/`. El siguiente paso es el que salga del punto de fuga: `/freelance-proposal` (propuesta), `/freelance-scan` o `/freelance-profile` (targeting y posicionamiento), `/client-screen` (criterios de descarte), o `/contract-and-proposal-writer` (alcances y change orders).
|
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Importa historial de facturacion freelance desde un CSV que exportaste vos.
|
|
4
|
+
|
|
5
|
+
Mismo patron que job-scrape/network_map.py: no toca tu cuenta ni pide
|
|
6
|
+
credenciales. Vos bajas el CSV desde la plataforma y este script lo lee. Sin API
|
|
7
|
+
key, sin login, sin scrapear.
|
|
8
|
+
|
|
9
|
+
Por que detecta las columnas en vez de asumirlas: Upwork tiene al menos dos
|
|
10
|
+
exports distintos (Transaction History y Contract History), sus nombres de
|
|
11
|
+
columna no estan documentados publicamente, y cambian. Hardcodear un esquema que
|
|
12
|
+
no se puede verificar produce justo el codigo que falla en la primera corrida
|
|
13
|
+
real del usuario. Ademas asi sirve para cualquier otra fuente: Freelancer.com, un
|
|
14
|
+
export de tu banco, o la planilla que ya venias llevando a mano.
|
|
15
|
+
|
|
16
|
+
El contrato con el usuario es explicito: `inspect` muestra que columna se mapeo a
|
|
17
|
+
que campo y **que columnas no se pudieron mapear**, para que el usuario corrija
|
|
18
|
+
con --map antes de creerle a los numeros.
|
|
19
|
+
|
|
20
|
+
Uso:
|
|
21
|
+
python import_history.py inspect transacciones.csv
|
|
22
|
+
python import_history.py summary transacciones.csv
|
|
23
|
+
python import_history.py summary transacciones.csv --map "Importe=monto" --map "Horas trabajadas=horas"
|
|
24
|
+
python import_history.py import transacciones.csv
|
|
25
|
+
|
|
26
|
+
Read-only: nunca escribe el CSV ni las notas. Solo stdlib. Python 3.9.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import argparse
|
|
32
|
+
import csv
|
|
33
|
+
import json
|
|
34
|
+
import re
|
|
35
|
+
import sys
|
|
36
|
+
import unicodedata
|
|
37
|
+
from collections import OrderedDict
|
|
38
|
+
from datetime import datetime
|
|
39
|
+
|
|
40
|
+
# Campos normalizados y los nombres reales con los que aparecen. El orden importa:
|
|
41
|
+
# se prueba coincidencia exacta primero y despues por substring, asi que los
|
|
42
|
+
# alias mas especificos van antes.
|
|
43
|
+
FIELD_ALIASES = OrderedDict([
|
|
44
|
+
("fecha", ["date", "fecha", "transaction date", "date/time", "period",
|
|
45
|
+
"start date", "created", "week"]),
|
|
46
|
+
("tipo", ["type", "tipo", "transaction type", "category", "categoria"]),
|
|
47
|
+
("cliente", ["client", "cliente", "company", "empresa", "team",
|
|
48
|
+
"contract team", "buyer", "payer"]),
|
|
49
|
+
("contrato", ["contract title", "contract", "contrato", "description",
|
|
50
|
+
"descripcion", "memo", "project", "proyecto", "job title"]),
|
|
51
|
+
# `fee` y `neto` van antes de `monto`: "amount" hace substring con casi todo,
|
|
52
|
+
# y sin este orden una columna de fee cae en monto.
|
|
53
|
+
("fee", ["freelancer service fee", "service fee", "upwork fee", "fee",
|
|
54
|
+
"comision", "commission", "marketplace fee"]),
|
|
55
|
+
("neto", ["net amount", "net", "neto", "take home", "payout", "liquido"]),
|
|
56
|
+
("monto", ["amount", "monto", "importe", "total", "gross", "bruto",
|
|
57
|
+
"earnings", "ganancia"]),
|
|
58
|
+
("horas", ["hours", "horas", "quantity", "qty", "cantidad", "duration",
|
|
59
|
+
"time", "hs"]),
|
|
60
|
+
("moneda", ["currency", "moneda", "curr"]),
|
|
61
|
+
])
|
|
62
|
+
|
|
63
|
+
# Tipos de transaccion que NO son ingreso. Un export trae de todo mezclado y
|
|
64
|
+
# sumar sin filtrar da un total inflado.
|
|
65
|
+
NON_INCOME_HINTS = ("withdrawal", "retiro", "transfer", "transferencia",
|
|
66
|
+
"membership", "connects", "refund", "reembolso",
|
|
67
|
+
"adjustment", "ajuste", "tax", "impuesto")
|
|
68
|
+
|
|
69
|
+
# En el transaction history de Upwork la comision NO es una columna: es una fila
|
|
70
|
+
# aparte con monto negativo y tipo "Service Fee". Sin detectarlas por fila, el
|
|
71
|
+
# total sale correcto pero etiquetado como bruto, y comisiones queda en cero -
|
|
72
|
+
# o sea, el reporte miente sobre de donde salio el numero.
|
|
73
|
+
FEE_ROW_HINTS = ("service fee", "freelancer fee", "upwork fee", "comision",
|
|
74
|
+
"commission", "marketplace fee", "processing fee")
|
|
75
|
+
|
|
76
|
+
DATE_FORMATS = ("%Y-%m-%d", "%d/%m/%Y", "%m/%d/%Y", "%Y/%m/%d",
|
|
77
|
+
"%b %d, %Y", "%d %b %Y", "%Y-%m-%d %H:%M:%S", "%m/%d/%y")
|
|
78
|
+
|
|
79
|
+
MIN_ROWS_FOR_RATES = 5
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def fold(text: str) -> str:
|
|
83
|
+
decomposed = unicodedata.normalize("NFD", str(text or "").lower())
|
|
84
|
+
return "".join(c for c in decomposed if unicodedata.category(c) != "Mn")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def normalize_header(name: str) -> str:
|
|
88
|
+
return re.sub(r"\s+", " ", re.sub(r"[^a-z0-9 ]+", " ", fold(name))).strip()
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def parse_money(raw):
|
|
92
|
+
"""
|
|
93
|
+
Plata en cualquiera de los formatos que aparecen en un export real.
|
|
94
|
+
|
|
95
|
+
Casos que se vieron y hay que aguantar: "$1,234.56", "1.234,56" (europeo),
|
|
96
|
+
"(45.00)" negativo entre parentesis (convencion contable), "-", vacio, y
|
|
97
|
+
sufijos de moneda tipo "120.00 USD".
|
|
98
|
+
"""
|
|
99
|
+
text = str(raw or "").strip()
|
|
100
|
+
if not text or text in ("-", "--", "n/a", "N/A"):
|
|
101
|
+
return None
|
|
102
|
+
negative = text.startswith("(") and text.endswith(")")
|
|
103
|
+
text = re.sub(r"[()]", "", text)
|
|
104
|
+
text = re.sub(r"[^\d.,\-]", "", text)
|
|
105
|
+
if not text or text in ("-", ".", ","):
|
|
106
|
+
return None
|
|
107
|
+
# Formato europeo: la coma es el decimal si es el ultimo separador.
|
|
108
|
+
if "," in text and "." in text:
|
|
109
|
+
if text.rfind(",") > text.rfind("."):
|
|
110
|
+
text = text.replace(".", "").replace(",", ".")
|
|
111
|
+
else:
|
|
112
|
+
text = text.replace(",", "")
|
|
113
|
+
elif "," in text:
|
|
114
|
+
# Coma sola: decimal si deja 1 o 2 digitos atras, si no es de miles.
|
|
115
|
+
tail = text.split(",")[-1]
|
|
116
|
+
text = text.replace(",", "." if len(tail) in (1, 2) else "")
|
|
117
|
+
try:
|
|
118
|
+
value = float(text)
|
|
119
|
+
except ValueError:
|
|
120
|
+
return None
|
|
121
|
+
return -value if negative else value
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def parse_hours(raw):
|
|
125
|
+
"""Horas: acepta decimal y tambien "3:30" que algunos exports usan."""
|
|
126
|
+
text = str(raw or "").strip()
|
|
127
|
+
if not text:
|
|
128
|
+
return None
|
|
129
|
+
match = re.match(r"^(\d+):([0-5]\d)$", text)
|
|
130
|
+
if match:
|
|
131
|
+
return int(match.group(1)) + int(match.group(2)) / 60.0
|
|
132
|
+
return parse_money(text)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def parse_date(raw):
|
|
136
|
+
text = str(raw or "").strip()
|
|
137
|
+
if not text:
|
|
138
|
+
return None
|
|
139
|
+
for fmt in DATE_FORMATS:
|
|
140
|
+
try:
|
|
141
|
+
return datetime.strptime(text, fmt).date()
|
|
142
|
+
except ValueError:
|
|
143
|
+
continue
|
|
144
|
+
return None
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def map_columns(headers, overrides=None):
|
|
148
|
+
"""
|
|
149
|
+
Devuelve (mapeo, sin_mapear, ambiguos).
|
|
150
|
+
|
|
151
|
+
Ambiguo = dos columnas quieren el mismo campo. No se elige en silencio: se
|
|
152
|
+
reporta, porque elegir mal ahi cambia todos los numeros de abajo.
|
|
153
|
+
"""
|
|
154
|
+
overrides = overrides or {}
|
|
155
|
+
mapping, claims = {}, {}
|
|
156
|
+
|
|
157
|
+
# Los overrides se procesan primero y bloquean el campo: `--map` existe para
|
|
158
|
+
# ganarle a la deteccion, y si se mezclaran en el mismo recorrido ganaria la
|
|
159
|
+
# columna que aparece antes en el header, que es lo contrario de lo pedido.
|
|
160
|
+
forced = set()
|
|
161
|
+
for header in headers:
|
|
162
|
+
if header in overrides:
|
|
163
|
+
field = overrides[header]
|
|
164
|
+
claims[field] = [header]
|
|
165
|
+
forced.add(field)
|
|
166
|
+
|
|
167
|
+
for header in headers:
|
|
168
|
+
if header in overrides:
|
|
169
|
+
continue
|
|
170
|
+
norm = normalize_header(header)
|
|
171
|
+
if not norm:
|
|
172
|
+
continue
|
|
173
|
+
chosen = None
|
|
174
|
+
for field, aliases in FIELD_ALIASES.items():
|
|
175
|
+
if field in forced:
|
|
176
|
+
continue
|
|
177
|
+
if norm in [normalize_header(a) for a in aliases]:
|
|
178
|
+
chosen = field
|
|
179
|
+
break
|
|
180
|
+
if not chosen:
|
|
181
|
+
for field, aliases in FIELD_ALIASES.items():
|
|
182
|
+
if field in forced:
|
|
183
|
+
continue
|
|
184
|
+
if any(normalize_header(a) in norm for a in aliases):
|
|
185
|
+
chosen = field
|
|
186
|
+
break
|
|
187
|
+
if chosen:
|
|
188
|
+
claims.setdefault(chosen, []).append(header)
|
|
189
|
+
|
|
190
|
+
ambiguous = {}
|
|
191
|
+
for field, columns in claims.items():
|
|
192
|
+
if len(columns) > 1:
|
|
193
|
+
ambiguous[field] = columns
|
|
194
|
+
mapping[field] = columns[0]
|
|
195
|
+
|
|
196
|
+
mapped = set(mapping.values())
|
|
197
|
+
unmapped = [h for h in headers if h not in mapped and normalize_header(h)]
|
|
198
|
+
return mapping, unmapped, ambiguous
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def read_csv(path):
|
|
202
|
+
"""Lee el CSV saltando el preambulo de aviso que algunos exports ponen arriba."""
|
|
203
|
+
with open(path, "r", encoding="utf-8-sig", errors="replace", newline="") as handle:
|
|
204
|
+
lines = handle.read().splitlines()
|
|
205
|
+
if not lines:
|
|
206
|
+
raise ValueError("el archivo esta vacio")
|
|
207
|
+
|
|
208
|
+
# Heuristica: la fila de header es la primera con al menos 3 separadores.
|
|
209
|
+
start = 0
|
|
210
|
+
for index, line in enumerate(lines[:20]):
|
|
211
|
+
if line.count(",") >= 2 or line.count(";") >= 2:
|
|
212
|
+
start = index
|
|
213
|
+
break
|
|
214
|
+
sample = "\n".join(lines[start:start + 5])
|
|
215
|
+
try:
|
|
216
|
+
dialect = csv.Sniffer().sniff(sample, delimiters=",;\t")
|
|
217
|
+
except csv.Error:
|
|
218
|
+
dialect = csv.excel
|
|
219
|
+
reader = csv.DictReader(lines[start:], dialect=dialect)
|
|
220
|
+
headers = [h for h in (reader.fieldnames or []) if h is not None]
|
|
221
|
+
return headers, list(reader), start
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def normalize_rows(rows, mapping):
|
|
225
|
+
out = []
|
|
226
|
+
for raw in rows:
|
|
227
|
+
def get(field):
|
|
228
|
+
column = mapping.get(field)
|
|
229
|
+
return raw.get(column) if column else None
|
|
230
|
+
|
|
231
|
+
monto = parse_money(get("monto"))
|
|
232
|
+
fee = parse_money(get("fee"))
|
|
233
|
+
neto = parse_money(get("neto"))
|
|
234
|
+
# Si el export trae bruto y fee pero no neto, se deriva. El fee suele
|
|
235
|
+
# venir negativo, asi que se usa el valor absoluto.
|
|
236
|
+
if neto is None and monto is not None and fee is not None:
|
|
237
|
+
neto = monto - abs(fee)
|
|
238
|
+
|
|
239
|
+
tipo = str(get("tipo") or "").strip()
|
|
240
|
+
folded = fold(tipo)
|
|
241
|
+
es_fila_de_fee = any(h in folded for h in FEE_ROW_HINTS)
|
|
242
|
+
out.append({
|
|
243
|
+
"fecha": parse_date(get("fecha")),
|
|
244
|
+
"tipo": tipo,
|
|
245
|
+
"es_fila_de_fee": es_fila_de_fee,
|
|
246
|
+
"es_ingreso": not es_fila_de_fee and not any(h in folded for h in NON_INCOME_HINTS),
|
|
247
|
+
"cliente": str(get("cliente") or "").strip(),
|
|
248
|
+
"contrato": str(get("contrato") or "").strip(),
|
|
249
|
+
"monto": monto,
|
|
250
|
+
"fee": fee,
|
|
251
|
+
"neto": neto if neto is not None else monto,
|
|
252
|
+
"horas": parse_hours(get("horas")),
|
|
253
|
+
"moneda": str(get("moneda") or "").strip(),
|
|
254
|
+
})
|
|
255
|
+
return out
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def summarize(rows):
|
|
259
|
+
income = [r for r in rows if r["es_ingreso"]]
|
|
260
|
+
fee_rows = [r for r in rows if r.get("es_fila_de_fee")]
|
|
261
|
+
montos = [r["monto"] for r in income if r["monto"] is not None]
|
|
262
|
+
# Las comisiones pueden venir por columna (una por movimiento) o por fila
|
|
263
|
+
# aparte. Se suman las dos formas; en un export dado hay una sola.
|
|
264
|
+
fees = [abs(r["fee"]) for r in income if r["fee"] is not None]
|
|
265
|
+
fees += [abs(r["monto"]) for r in fee_rows if r["monto"] is not None]
|
|
266
|
+
# El neto se deriva del bruto menos comisiones cuando estas vienen por fila,
|
|
267
|
+
# porque en ese caso la columna `neto` de cada movimiento no las conoce.
|
|
268
|
+
netos = [r["neto"] for r in income if r["neto"] is not None]
|
|
269
|
+
horas = [r["horas"] for r in income if r["horas"] is not None]
|
|
270
|
+
fechas = sorted(r["fecha"] for r in rows if r["fecha"])
|
|
271
|
+
|
|
272
|
+
por_cliente = {}
|
|
273
|
+
for row in income:
|
|
274
|
+
key = row["cliente"] or "(sin cliente)"
|
|
275
|
+
bucket = por_cliente.setdefault(key, {"neto": 0.0, "horas": 0.0, "movimientos": 0})
|
|
276
|
+
bucket["neto"] += row["neto"] or 0
|
|
277
|
+
bucket["horas"] += row["horas"] or 0
|
|
278
|
+
bucket["movimientos"] += 1
|
|
279
|
+
|
|
280
|
+
total_bruto = sum(montos)
|
|
281
|
+
total_fees = sum(fees)
|
|
282
|
+
total_neto = (total_bruto - total_fees) if fee_rows else sum(netos)
|
|
283
|
+
ranking = sorted(por_cliente.items(), key=lambda kv: kv[1]["neto"], reverse=True)
|
|
284
|
+
concentracion = None
|
|
285
|
+
if total_neto and ranking:
|
|
286
|
+
concentracion = {
|
|
287
|
+
"cliente_mas_grande": ranking[0][0],
|
|
288
|
+
"porcentaje_del_ingreso": round(ranking[0][1]["neto"] / total_neto * 100, 1),
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
# La tarifa efectiva es el numero que dice si el negocio funciona, pero
|
|
292
|
+
# solo se puede calcular si el export trae horas. Sin horas se dice que
|
|
293
|
+
# falta el dato, no se estima.
|
|
294
|
+
tarifa_efectiva = None
|
|
295
|
+
if horas and sum(horas) > 0 and total_neto:
|
|
296
|
+
tarifa_efectiva = round(total_neto / sum(horas), 2)
|
|
297
|
+
|
|
298
|
+
return {
|
|
299
|
+
"movimientos_leidos": len(rows),
|
|
300
|
+
"movimientos_de_ingreso": len(income),
|
|
301
|
+
"filas_de_comision": len(fee_rows),
|
|
302
|
+
"excluidos_por_tipo": len(rows) - len(income) - len(fee_rows),
|
|
303
|
+
"rango_de_fechas": [str(fechas[0]), str(fechas[-1])] if fechas else None,
|
|
304
|
+
"bruto": round(total_bruto, 2) if montos else None,
|
|
305
|
+
"comisiones": round(total_fees, 2) if fees else None,
|
|
306
|
+
"neto": round(total_neto, 2) if (netos or fee_rows) else None,
|
|
307
|
+
"horas_registradas": round(sum(horas), 2) if horas else None,
|
|
308
|
+
"tarifa_efectiva_por_hora": tarifa_efectiva,
|
|
309
|
+
"tarifa_efectiva_calculable": tarifa_efectiva is not None,
|
|
310
|
+
"por_cliente": OrderedDict(
|
|
311
|
+
(name, {"neto": round(v["neto"], 2), "horas": round(v["horas"], 2),
|
|
312
|
+
"movimientos": v["movimientos"]})
|
|
313
|
+
for name, v in ranking),
|
|
314
|
+
"concentracion": concentracion,
|
|
315
|
+
"muestra_suficiente": len(income) >= MIN_ROWS_FOR_RATES,
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
# --------------------------------------------------------------------------- #
|
|
320
|
+
|
|
321
|
+
def load(args):
|
|
322
|
+
headers, rows, skipped = read_csv(args.csv)
|
|
323
|
+
overrides = {}
|
|
324
|
+
for item in (args.map or []):
|
|
325
|
+
if "=" not in item:
|
|
326
|
+
raise ValueError("--map espera 'Columna=campo', recibi: {0}".format(item))
|
|
327
|
+
column, field = item.split("=", 1)
|
|
328
|
+
field = field.strip()
|
|
329
|
+
if field not in FIELD_ALIASES:
|
|
330
|
+
raise ValueError("campo desconocido '{0}'; validos: {1}".format(
|
|
331
|
+
field, ", ".join(FIELD_ALIASES)))
|
|
332
|
+
overrides[column.strip()] = field
|
|
333
|
+
mapping, unmapped, ambiguous = map_columns(headers, overrides)
|
|
334
|
+
return headers, rows, skipped, mapping, unmapped, ambiguous
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def run_inspect(args) -> int:
|
|
338
|
+
headers, rows, skipped, mapping, unmapped, ambiguous = load(args)
|
|
339
|
+
faltantes = [f for f in ("fecha", "monto") if f not in mapping]
|
|
340
|
+
emit({
|
|
341
|
+
"archivo": args.csv,
|
|
342
|
+
"filas_de_preambulo_salteadas": skipped,
|
|
343
|
+
"columnas_encontradas": headers,
|
|
344
|
+
"mapeo": mapping,
|
|
345
|
+
"columnas_sin_mapear": unmapped,
|
|
346
|
+
"columnas_ambiguas": ambiguous,
|
|
347
|
+
"campos_criticos_faltantes": faltantes,
|
|
348
|
+
"usable": not faltantes,
|
|
349
|
+
"ejemplo_normalizado": normalize_rows(rows[:2], mapping),
|
|
350
|
+
"como_corregir": "--map \"Nombre exacto de la columna=campo\"; campos: {0}".format(
|
|
351
|
+
", ".join(FIELD_ALIASES)),
|
|
352
|
+
})
|
|
353
|
+
return 0 if not faltantes else 1
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def run_summary(args) -> int:
|
|
357
|
+
_, rows, _, mapping, unmapped, ambiguous = load(args)
|
|
358
|
+
if "monto" not in mapping:
|
|
359
|
+
emit({"error": "no encontre la columna de monto; corre `inspect` y usa --map",
|
|
360
|
+
"code": "NO_AMOUNT_COLUMN"})
|
|
361
|
+
return 1
|
|
362
|
+
payload = summarize(normalize_rows(rows, mapping))
|
|
363
|
+
payload["mapeo_usado"] = mapping
|
|
364
|
+
# Se repiten aca a proposito: quien mira el summary tiene que ver si el
|
|
365
|
+
# mapeo dejo algo afuera antes de creerle a los totales.
|
|
366
|
+
payload["columnas_sin_mapear"] = unmapped
|
|
367
|
+
payload["columnas_ambiguas"] = ambiguous
|
|
368
|
+
emit(payload)
|
|
369
|
+
return 0
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def run_import(args) -> int:
|
|
373
|
+
_, rows, _, mapping, unmapped, ambiguous = load(args)
|
|
374
|
+
emit({
|
|
375
|
+
"mapeo_usado": mapping,
|
|
376
|
+
"columnas_sin_mapear": unmapped,
|
|
377
|
+
"columnas_ambiguas": ambiguous,
|
|
378
|
+
"movimientos": normalize_rows(rows, mapping),
|
|
379
|
+
})
|
|
380
|
+
return 0
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def emit(payload) -> None:
|
|
384
|
+
json.dump(payload, sys.stdout, ensure_ascii=False, indent=2, default=str)
|
|
385
|
+
sys.stdout.write("\n")
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def force_utf8_streams() -> None:
|
|
389
|
+
"""En Windows stdout es cp1252 y un nombre con acento revienta al pipear."""
|
|
390
|
+
for stream in (sys.stdout, sys.stderr):
|
|
391
|
+
if hasattr(stream, "reconfigure"):
|
|
392
|
+
stream.reconfigure(encoding="utf-8")
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def build_parser():
|
|
396
|
+
parser = argparse.ArgumentParser(
|
|
397
|
+
description="Importa historial de facturacion freelance desde un CSV exportado a mano.")
|
|
398
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
399
|
+
for name, func, help_text in (
|
|
400
|
+
("inspect", run_inspect, "mostrar que columnas detecto y cuales no pudo mapear"),
|
|
401
|
+
("summary", run_summary, "totales, tarifa efectiva y concentracion de clientes"),
|
|
402
|
+
("import", run_import, "volcar los movimientos normalizados en JSON"),
|
|
403
|
+
):
|
|
404
|
+
cmd = sub.add_parser(name, help=help_text)
|
|
405
|
+
cmd.add_argument("csv", help="ruta del CSV exportado")
|
|
406
|
+
cmd.add_argument("--map", action="append",
|
|
407
|
+
help="forzar un mapeo: \"Columna=campo\" (repetible)")
|
|
408
|
+
cmd.set_defaults(func=func)
|
|
409
|
+
return parser
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def main(argv=None) -> int:
|
|
413
|
+
force_utf8_streams()
|
|
414
|
+
args = build_parser().parse_args(argv)
|
|
415
|
+
try:
|
|
416
|
+
return args.func(args)
|
|
417
|
+
except (OSError, ValueError) as e:
|
|
418
|
+
emit({"error": str(e), "code": "BAD_INPUT"})
|
|
419
|
+
return 1
|
|
420
|
+
except KeyboardInterrupt:
|
|
421
|
+
return 130
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
if __name__ == "__main__":
|
|
425
|
+
sys.exit(main())
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: freelance-profile
|
|
3
|
+
description: "Build the Upwork profile that decides whether clients ever open your proposal: title, the first 250 visible characters of the overview, specialized profiles, portfolio items and rate. Triggers: perfil de upwork, optimizar perfil freelance, titulo de upwork, overview upwork, specialized profile, que tarifa pongo en upwork."
|
|
4
|
+
argument-hint: "[audit | title | overview | portfolio | rate]"
|
|
5
|
+
category: "career"
|
|
6
|
+
user-invocable: true
|
|
7
|
+
allowed-tools: Read, Glob, Grep, Write, Edit, WebFetch
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
# Upwork Profile - El perfil de la plataforma
|
|
11
|
+
|
|
12
|
+
Construís el perfil de Upwork desde el perfil maestro. No es un CV: el CV lo lee alguien que ya decidió considerarte, el perfil de Upwork lo lee alguien que está barriendo una lista de 40 freelancers o revisando propuestas.
|
|
13
|
+
|
|
14
|
+
Rutas relativas al career workspace (`--workspace`, env `CAREER_WORKSPACE`, o `./career-workspace/`).
|
|
15
|
+
|
|
16
|
+
Modo: `$ARGUMENTS`
|
|
17
|
+
|
|
18
|
+
## Inputs
|
|
19
|
+
|
|
20
|
+
- **El perfil maestro** de `01-Perfiles/` - la fuente de verdad. Si no existe, correr `/master-profile` primero.
|
|
21
|
+
- **El perfil actual de Upwork**, si ya existe: URL pública → WebFetch, o pegado.
|
|
22
|
+
- **Contratos cerrados** de `08-Contratos/` para los portfolio items con resultados reales.
|
|
23
|
+
|
|
24
|
+
## Modos
|
|
25
|
+
|
|
26
|
+
### `audit` - Revisar el perfil actual
|
|
27
|
+
|
|
28
|
+
Recorrer los cinco elementos de abajo y reportar qué falta o qué está flojo, priorizado por impacto. Empezar siempre por título y las primeras 250 caracteres del overview: son los únicos que se ven sin hacer click.
|
|
29
|
+
|
|
30
|
+
### `title` - El título
|
|
31
|
+
|
|
32
|
+
Lo primero y a veces lo único que se lee. Reglas:
|
|
33
|
+
|
|
34
|
+
- **Nicho específico, no rol genérico.** "Full Stack Developer" compite con 200 mil personas. "Unity Developer - Gameplay & Multiplayer for Mobile" compite con pocas y aparece en las búsquedas que importan.
|
|
35
|
+
- Incluir la tecnología o el resultado que el cliente busca en sus palabras, no en las tuyas.
|
|
36
|
+
- Sin "Ninja", "Rockstar", "Guru".
|
|
37
|
+
- Sin declaraciones de disponibilidad ("Available now") - ocupan lugar y caducan.
|
|
38
|
+
|
|
39
|
+
### `overview` - Los primeros 250 caracteres
|
|
40
|
+
|
|
41
|
+
El overview se corta en la vista previa. Todo lo que importa va en el primer bloque.
|
|
42
|
+
|
|
43
|
+
Estructura:
|
|
44
|
+
|
|
45
|
+
1. **Primera línea**: a quién ayudás y con qué resultado. No "I am a developer with 5 years of experience" - eso describe tu currículum, no el problema del cliente.
|
|
46
|
+
2. **Segundo bloque**: la prueba más fuerte, con número.
|
|
47
|
+
3. **Después del corte**: stack, cómo trabajás, tipos de proyecto que tomás, y qué **no** tomás. Decir qué no hacés filtra clientes malos y sube la tasa de respuesta de los buenos.
|
|
48
|
+
4. **Cierre**: un CTA simple.
|
|
49
|
+
|
|
50
|
+
Escribir en primera persona y en el idioma de tu mercado objetivo. Si apuntás a clientes de EEUU, en inglés.
|
|
51
|
+
|
|
52
|
+
### `portfolio` - Los items
|
|
53
|
+
|
|
54
|
+
- Cada item es **un problema, lo que hiciste y el resultado con número**. No una captura de pantalla linda sin contexto.
|
|
55
|
+
- Priorizar los que se parecen a los trabajos que querés conseguir, no los que más te gustaron.
|
|
56
|
+
- Sacar los que no representan el nicho del título: un portfolio disperso comunica "hago cualquier cosa", que es lo contrario de lo que querés.
|
|
57
|
+
- Nunca subir trabajo de un cliente sin permiso, ni material bajo NDA. Si no se puede mostrar, se describe el problema y el resultado sin identificar al cliente.
|
|
58
|
+
|
|
59
|
+
### `rate` - La tarifa publicada
|
|
60
|
+
|
|
61
|
+
- Se deriva del **piso neto** de `01-Perfiles/` más la comisión de la plataforma, no de lo que cobran otros.
|
|
62
|
+
- La tarifa publicada es un filtro, no un compromiso: filtra el tráfico que llega, y podés cotizar distinto por proyecto en cada propuesta.
|
|
63
|
+
- Poner una tarifa muy baja para "arrancar" atrae clientes que buscan precio bajo, que son los que más problemas dan y peor califican. Subirla después es más difícil que empezar bien.
|
|
64
|
+
- Si el usuario está arrancando sin historial, la palanca correcta es acotar el nicho, no bajar el precio.
|
|
65
|
+
|
|
66
|
+
## Specialized profiles
|
|
67
|
+
|
|
68
|
+
Upwork permite perfiles especializados además del general. Sirven cuando el usuario tiene dos nichos con vocabulario distinto (ej. "Unity gameplay" y "backend .NET"): cada uno se presenta a su búsqueda sin diluir al otro.
|
|
69
|
+
|
|
70
|
+
Si hay un solo nicho, no crear especializados: sumar superficie vacía baja la señal.
|
|
71
|
+
|
|
72
|
+
## Lo que el perfil no controla
|
|
73
|
+
|
|
74
|
+
El **Job Success Score** y el badge no se editan: salen de cómo terminaron los contratos. Por eso `freelance-guardrails` dice que rechazar es una acción del pipeline - la protección del JSS se hace eligiendo trabajos, no escribiendo el perfil. Si el usuario está arrancando, no tiene JSS, y ahí el portfolio y el nicho cargan todo el peso.
|
|
75
|
+
|
|
76
|
+
## Proceso
|
|
77
|
+
|
|
78
|
+
1. Leer el perfil maestro y el perfil actual de Upwork si existe.
|
|
79
|
+
2. Correr el modo pedido (o `audit` si no se indicó ninguno).
|
|
80
|
+
3. Proponer los textos como borradores en el chat, con **2 variantes de título** para elegir.
|
|
81
|
+
4. Con approval, guardar el resultado en `01-Perfiles/Upwork.md` (derivado del maestro, no reemplazo). El usuario copia y pega en la plataforma - este skill nunca toca la cuenta.
|
|
82
|
+
5. Registrar la fecha del último ajuste, para poder correlacionar cambios de perfil con cambios en la tasa de respuesta desde `/freelance-pipeline`.
|
|
83
|
+
|
|
84
|
+
## Reglas
|
|
85
|
+
|
|
86
|
+
- Nunca inventar experiencia, clientes ni números.
|
|
87
|
+
- Nunca publicar trabajo bajo NDA ni de un cliente sin permiso.
|
|
88
|
+
- La tarifa nunca se deriva de la competencia; se deriva del piso.
|
|
89
|
+
- Sin guiones largos en los textos generados.
|
|
90
|
+
|
|
91
|
+
## Handoff
|
|
92
|
+
|
|
93
|
+
Pedí aprobación (approval) antes de escribir `01-Perfiles/Upwork.md`. Perfil READY cuando título, overview y al menos dos portfolio items están completos. El siguiente paso es `/freelance-scan` para armar la primera cola de ofertas, o `/portfolio-site` si el usuario quiere un portfolio propio fuera de la plataforma.
|