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