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,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.
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: freelance-proposal
|
|
3
|
+
description: "Write an Upwork proposal that survives the list view and price it above your net floor rate: hook in the first two lines, proof instead of adjectives, fixed vs hourly, risk buffer and platform fee. Reviewed by a fresh-context agent before spending Connects. Triggers: escribir propuesta upwork, cover letter upwork, cuanto cobrar por este proyecto, bid, cotizar proyecto freelance, propuesta freelance."
|
|
4
|
+
argument-hint: "[nota de aplicación | job-url]"
|
|
5
|
+
category: "career"
|
|
6
|
+
user-invocable: true
|
|
7
|
+
allowed-tools: Read, Glob, Grep, Write, Edit, WebFetch, Task
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
# Upwork Proposal - Propuesta y bid
|
|
11
|
+
|
|
12
|
+
Escribís la propuesta y le ponés precio. Dos cosas en una skill porque en Upwork van juntas: el bid es parte de la propuesta y una no se defiende sin la otra.
|
|
13
|
+
|
|
14
|
+
Rutas relativas al career workspace (`--workspace`, env `CAREER_WORKSPACE`, o `./career-workspace/`).
|
|
15
|
+
|
|
16
|
+
Oferta indicada: `$ARGUMENTS`
|
|
17
|
+
|
|
18
|
+
## Precondición: el screen
|
|
19
|
+
|
|
20
|
+
**No redactar una propuesta sin la nota de `/client-screen`.** Si no existe, pedirla y correr ese skill primero.
|
|
21
|
+
|
|
22
|
+
Si el screen dio riesgo alto o `NO POSTULARSE` y el usuario quiere postularse igual, se puede - **pero se le dice qué falló y se registra en la nota** (regla 1 de `freelance-guardrails`). Los Connects son plata: gastarlos a ciegas es distinto de gastarlos con los ojos abiertos.
|
|
23
|
+
|
|
24
|
+
## Inputs
|
|
25
|
+
|
|
26
|
+
- **El job post completo** y la nota de `03-Aplicaciones/` con el screen.
|
|
27
|
+
- **El perfil base** de `01-Perfiles/` - de ahí salen la prueba y el **piso de tarifa neto**.
|
|
28
|
+
- **La estimación** de `/estimate` si el alcance es grande.
|
|
29
|
+
- Trabajos previos comparables (`08-Contratos/` cerrados) para calibrar precio contra realidad, no contra deseo.
|
|
30
|
+
|
|
31
|
+
## Parte 1 - La propuesta
|
|
32
|
+
|
|
33
|
+
### Cómo la lee el cliente
|
|
34
|
+
|
|
35
|
+
Tiene 30 propuestas en una lista y ve **las dos primeras líneas de cada una**. Todo lo que no esté ahí, no existe todavía. Esa es la única restricción de formato que importa.
|
|
36
|
+
|
|
37
|
+
### Estructura
|
|
38
|
+
|
|
39
|
+
1. **Dos primeras líneas (el 80% del trabajo).** Algo que solo se sabe habiendo leído *este* post: el problema real detrás del pedido, una observación técnica concreta, o el resultado que podés traer. Si la apertura serviría para cualquier otro trabajo del rubro, está muerta.
|
|
40
|
+
2. **Prueba, 2-3 oraciones.** Un proyecto comparable con número o link. No adjetivos: "expert in React" es ruido, "migré un checkout de 40k usuarios a React, bajé el tiempo de carga 60%" es prueba.
|
|
41
|
+
3. **Cómo lo harías, 3-4 puntos.** Suficiente para mostrar que ya lo pensaste, no tanto que regales la consultoría.
|
|
42
|
+
4. **El precio, justificado.** Ver Parte 2. Un número suelto invita a regatear.
|
|
43
|
+
5. **Una pregunta concreta** sobre el proyecto. Sin pregunta no arranca conversación, y demuestra que leíste el post.
|
|
44
|
+
|
|
45
|
+
**150-300 palabras.** Arriba de 300 casi nunca se lee entera.
|
|
46
|
+
|
|
47
|
+
### Requisitos explícitos del post
|
|
48
|
+
|
|
49
|
+
Muchos posts piden algo puntual: empezar con una palabra clave, responder una pregunta específica, mandar un link. **Buscalo siempre y cumplilo.** Es descarte automático y es el error más barato de evitar.
|
|
50
|
+
|
|
51
|
+
### Anti-patrones (cortar siempre)
|
|
52
|
+
|
|
53
|
+
- "I have X years of experience in..." como apertura. El cliente no contrata experiencia, contrata la solución de un problema que ya tiene.
|
|
54
|
+
- "Dear Sir/Madam", "I hope this message finds you well".
|
|
55
|
+
- Recitar el perfil en prosa.
|
|
56
|
+
- Adjetivos sin evidencia: passionate, detail-oriented, fast learner.
|
|
57
|
+
- Frases que delatan plantilla o generación automática. Los clientes de Upwork las reconocen al instante y es la razón número uno de descarte.
|
|
58
|
+
- Prometer plazos que no podés cumplir para ganar el contrato.
|
|
59
|
+
|
|
60
|
+
## Parte 2 - El bid
|
|
61
|
+
|
|
62
|
+
### El piso es neto
|
|
63
|
+
|
|
64
|
+
El piso de tarifa de `01-Perfiles/` es **lo que te queda**, no lo que facturás. Upwork se queda una comisión, así que el bid tiene que cubrirla: con un piso de 40 USD/h y 10% de comisión, el bid mínimo es ~45.
|
|
65
|
+
|
|
66
|
+
**Si el bid calculado queda abajo del piso, decilo fuerte y no lo maquilles.** El trabajo correcto ahí es no postularse o cotizar lo que corresponde y perder la oferta - no bajar el piso. Bajar la tarifa es la palanca más fácil de ejecutar y la más difícil de revertir: baja el techo de todos los contratos siguientes.
|
|
67
|
+
|
|
68
|
+
### Fijo u hora
|
|
69
|
+
|
|
70
|
+
| Elegí fijo cuando | Elegí hora cuando |
|
|
71
|
+
|---|---|
|
|
72
|
+
| El alcance está escrito y acotado | El alcance es exploratorio o va a cambiar |
|
|
73
|
+
| Podés estimar con confianza media o alta | Hay dependencias del cliente que no controlás |
|
|
74
|
+
| Querés capturar valor arriba de tus horas | El cliente pide "iremos viendo" |
|
|
75
|
+
|
|
76
|
+
Alcance difuso + precio fijo es la combinación que más plata pierde. Si el screen marcó alcance difuso y el cliente insiste en fijo, el precio tiene que incluir el riesgo o el trabajo correcto es no tomarlo.
|
|
77
|
+
|
|
78
|
+
### El cálculo
|
|
79
|
+
|
|
80
|
+
1. Esfuerzo base de `/estimate`, en horas.
|
|
81
|
+
2. **Buffer de riesgo** según lo que dijo el screen: alcance claro y cliente con historial, +15%; alcance difuso o cliente nuevo, +40% o más. El buffer no es grasa, es el precio de la incertidumbre que el cliente te transfiere.
|
|
82
|
+
3. Horas no facturables del proyecto: reuniones, revisiones, ida y vuelta.
|
|
83
|
+
4. Multiplicar por la tarifa objetivo (no por el piso: el piso es el mínimo, no la meta).
|
|
84
|
+
5. Sumar la comisión de la plataforma.
|
|
85
|
+
6. Comparar contra el presupuesto del cliente. Si no cierra, es dato para la decisión, no para bajar el número.
|
|
86
|
+
|
|
87
|
+
Mostrar el desglose al usuario **antes** de escribirlo en la propuesta. Al cliente se le muestra el total y su razón, no la planilla.
|
|
88
|
+
|
|
89
|
+
## Proceso
|
|
90
|
+
|
|
91
|
+
1. Leer el post, el screen y el perfil. Verificar que el screen exista.
|
|
92
|
+
2. Calcular el bid (Parte 2) y mostrar el desglose. Si queda abajo del piso, avisar y parar a decidir.
|
|
93
|
+
3. Draftear la propuesta (Parte 1). Después cortar sin piedad todo lo genérico.
|
|
94
|
+
4. **Review con `proposal-reviewer`** (contexto fresco): pasale inline el post, el borrador y el bid. Aplicar sus reemplazos exactos e incorporar lo que corresponda. Una sola ronda. El que escribió leyó el post entero y ya está convencido de su propio gancho; el cliente ve dos líneas.
|
|
95
|
+
5. Con approval, guardar en la nota de `03-Aplicaciones/`, sección `## Propuesta`, con el bid y su justificación en `## Cotización`.
|
|
96
|
+
6. Si el usuario quiere ensayar la llamada, ofrecer `discovery-call`.
|
|
97
|
+
|
|
98
|
+
## Idioma
|
|
99
|
+
|
|
100
|
+
En el idioma del post. Si el post está en inglés, la propuesta va en inglés aunque la charla sea en español.
|
|
101
|
+
|
|
102
|
+
## Reglas
|
|
103
|
+
|
|
104
|
+
- Nunca inventar experiencia, clientes, plazos ni métricas.
|
|
105
|
+
- Nunca bidear abajo del piso neto sin decirlo explícitamente.
|
|
106
|
+
- Nunca enviar la propuesta: se muestra y el usuario copia y manda (regla 4 de `freelance-guardrails`).
|
|
107
|
+
- Nunca marcar `propuesta_enviada`: eso lo hace `/job-outcome` después del envío.
|
|
108
|
+
- Sin guiones largos en el texto de la propuesta ni de las notas.
|
|
109
|
+
|
|
110
|
+
## Handoff
|
|
111
|
+
|
|
112
|
+
Pedí aprobación (approval) antes de escribir la propuesta en la nota. Propuesta READY cuando pasó el review y el bid está sobre el piso. El siguiente paso es enviarla a mano y correr `/job-outcome` para registrar el envío y el gasto de Connects; si el cliente responde, `discovery-call` para ensayar y después `/contract-and-proposal-writer` para el SOW.
|