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,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.