claude-code-arcane 1.3.0 → 2.0.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 (67) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/README.md +5 -4
  3. package/agents/ecommerce/ecommerce-architect.md +60 -0
  4. package/dist/cli.js +2 -0
  5. package/docs/SKILLS-CATALOG.md +25 -2
  6. package/docs/USER-GUIDE.md +2 -2
  7. package/docs/coding-standards.md +26 -0
  8. package/package.json +2 -1
  9. package/profiles/ecommerce.yaml +48 -0
  10. package/profiles/job-hunt.yaml +12 -2
  11. package/rules/drafter-reviewer.md +39 -0
  12. package/skills/cart-checkout/SKILL.md +85 -0
  13. package/skills/cart-checkout/references/checkout-flow-spec.md +50 -0
  14. package/skills/cold-outreach/SKILL.md +10 -10
  15. package/skills/cold-outreach/references/recruiter-playbook.md +5 -5
  16. package/skills/commerce-analytics/SKILL.md +85 -0
  17. package/skills/commerce-analytics/references/ga4-ecommerce-events.md +63 -0
  18. package/skills/commerce-analytics/scripts/ga4_ecommerce_validate.py +134 -0
  19. package/skills/commerce-data-model/SKILL.md +84 -0
  20. package/skills/commerce-data-model/references/data-model-spec.md +79 -0
  21. package/skills/cover-letter/SKILL.md +8 -8
  22. package/skills/cv-ats-export/SKILL.md +19 -6
  23. package/skills/cv-ats-export/scripts/cv_export.py +49 -4
  24. package/skills/cv-ats-export/scripts/verify_pdf.py +90 -0
  25. package/skills/cv-tailor/SKILL.md +7 -7
  26. package/skills/cv-tailor/references/ats-keywords.md +9 -9
  27. package/skills/interview-prep/SKILL.md +7 -7
  28. package/skills/interview-prep/references/star-framework.md +10 -10
  29. package/skills/inventory-stock/SKILL.md +86 -0
  30. package/skills/inventory-stock/scripts/restock_forecast.py +173 -0
  31. package/skills/job-aplicar/SKILL.md +88 -0
  32. package/skills/job-hunt/SKILL.md +20 -13
  33. package/skills/job-hunt/references/templates/Aplicacion.md +6 -2
  34. package/skills/job-hunt/references/templates/CV Custom.md +1 -1
  35. package/skills/job-hunt/references/templates/Contacto.md +1 -1
  36. package/skills/job-hunt/references/templates/Dashboard.md +19 -15
  37. package/skills/job-hunt/references/templates/Perfil.md +2 -2
  38. package/skills/job-outcome/SKILL.md +55 -0
  39. package/skills/job-scrape/SKILL.md +95 -0
  40. package/skills/job-scrape/references/dedup-playbook.md +25 -0
  41. package/skills/job-scrape/references/getonbrd-cli.md +62 -0
  42. package/skills/job-scrape/references/getonbrd-urls.md +37 -0
  43. package/skills/job-scrape/references/linkedin-cli.md +64 -0
  44. package/skills/job-scrape/references/linkedin-urls.md +42 -0
  45. package/skills/job-scrape/references/seen_jobs.schema.json +68 -0
  46. package/skills/job-scrape/scripts/getonbrd_search.py +501 -0
  47. package/skills/job-scrape/scripts/linkedin_search.py +428 -0
  48. package/skills/job-search/SKILL.md +22 -19
  49. package/skills/job-search/references/scoring-rubric.md +8 -8
  50. package/skills/job-upskill/SKILL.md +58 -0
  51. package/skills/linkedin-optimize/SKILL.md +5 -5
  52. package/skills/master-profile/SKILL.md +17 -17
  53. package/skills/merchant-feed/SKILL.md +85 -0
  54. package/skills/merchant-feed/references/feed-attribute-map.md +47 -0
  55. package/skills/merchant-feed/scripts/feed_validate.py +140 -0
  56. package/skills/network-map/SKILL.md +7 -7
  57. package/skills/order-lifecycle/SKILL.md +86 -0
  58. package/skills/order-lifecycle/references/order-state-machine.md +53 -0
  59. package/skills/payments-architecture/SKILL.md +80 -0
  60. package/skills/personal-brand/SKILL.md +10 -10
  61. package/skills/personal-brand/references/post-pillars.md +14 -14
  62. package/skills/portfolio-site/SKILL.md +11 -11
  63. package/skills/product-listing/SKILL.md +84 -0
  64. package/skills/promotions-discounts/SKILL.md +80 -0
  65. package/skills/shipping-tax/SKILL.md +85 -0
  66. package/skills/storefront-architecture/SKILL.md +80 -0
  67. package/skills/subscription-billing/SKILL.md +81 -0
@@ -0,0 +1,428 @@
1
+ """
2
+ linkedin_search.py - Search jobs on LinkedIn's public jobs-guest endpoints.
3
+
4
+ Any country/region plus remote. No authentication, no API key, no dependencies
5
+ beyond the Python 3 standard library.
6
+
7
+ Usage:
8
+ python linkedin_search.py search -l "<place>" [flags]
9
+ python linkedin_search.py detail <id|url> [--format json|plain]
10
+
11
+ Ported from the TypeScript CLI vendored from MadsLorentzen/ai-job-search: skill
12
+ scripts run in the user's project without a build step, and TypeScript there
13
+ means requiring Node 24+ for native type stripping (see docs/coding-standards.md).
14
+
15
+ Personal use only. This reads LinkedIn's public job pages; automated access is
16
+ against LinkedIn's Terms of Service, so keep volume low and do not use it
17
+ commercially or for bulk data collection. Run it on your own responsibility.
18
+
19
+ Errors go to stderr as {"error": "...", "code": "..."} with exit code 1;
20
+ non-fatal diagnostics use the same shape with a "warning" key.
21
+ """
22
+
23
+ import argparse
24
+ import html
25
+ import json
26
+ import random
27
+ import re
28
+ import sys
29
+ import time
30
+ import urllib.error
31
+ import urllib.parse
32
+ import urllib.request
33
+
34
+ SEARCH_URL = "https://www.linkedin.com/jobs-guest/jobs/api/seeMoreJobPostings/search"
35
+ DETAIL_URL = "https://www.linkedin.com/jobs-guest/jobs/api/jobPosting"
36
+
37
+ UA = (
38
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
39
+ "(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
40
+ )
41
+
42
+ # Below this size a response is empty/an error page, not a markup change.
43
+ MARKUP_SANITY_BYTES = 5000
44
+
45
+ MAX_RETRIES = 6
46
+ JOBAGE_ALL = 9999
47
+
48
+
49
+ def write_error(error, code):
50
+ sys.stderr.write(json.dumps({"error": error, "code": code}) + "\n")
51
+
52
+
53
+ def write_warning(warning, code):
54
+ """Non-fatal diagnostic on stderr. Same JSON-line shape, `warning` key."""
55
+ sys.stderr.write(json.dumps({"warning": warning, "code": code}) + "\n")
56
+
57
+
58
+ def html_fetch(url):
59
+ """Fetch HTML with exponential backoff on 429/5xx. Returns "" on a 404."""
60
+ delay = 0.5
61
+ for attempt in range(MAX_RETRIES + 1):
62
+ request = urllib.request.Request(
63
+ url,
64
+ headers={
65
+ "User-Agent": UA,
66
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
67
+ "Accept-Language": "en-US,en;q=0.9",
68
+ "X-Requested-With": "XMLHttpRequest",
69
+ },
70
+ )
71
+ try:
72
+ with urllib.request.urlopen(request, timeout=30) as response:
73
+ return response.read().decode("utf-8", errors="replace")
74
+ except urllib.error.HTTPError as e:
75
+ if e.code == 404:
76
+ return ""
77
+ retryable = e.code == 429 or e.code >= 500
78
+ if not retryable or attempt == MAX_RETRIES:
79
+ raise RuntimeError(f"Request failed: {e.code} {e.reason}") from None
80
+ time.sleep(delay + random.random() * 0.5)
81
+ delay = min(delay * 2, 8)
82
+ raise RuntimeError("Request failed after max retries")
83
+
84
+
85
+ def unescape(markup):
86
+ """
87
+ html.unescape plus &nbsp; -> plain space. U+00A0 is invisible in a diff and
88
+ breaks keyword matching downstream, which is the whole point of pulling the JD.
89
+ """
90
+ return html.unescape(markup).replace("\xa0", " ")
91
+
92
+
93
+ def strip_tags(markup):
94
+ return re.sub(r"\s+", " ", re.sub(r"<[^>]+>", " ", markup)).strip()
95
+
96
+
97
+ def clean(markup):
98
+ return unescape(strip_tags(markup))
99
+
100
+
101
+ CARD_SPLIT_RE = re.compile(r'data-entity-urn="urn:li:jobPosting:')
102
+ CARD_ID_RE = re.compile(r"^(\d+)")
103
+ CARD_LINK_RE = re.compile(r'class="base-card__full-link[^"]*"[^>]*href="([^"]+)"', re.I)
104
+ CARD_TITLE_RE = re.compile(r'class="base-search-card__title"[^>]*>(.*?)</h3>', re.I | re.S)
105
+ CARD_SR_ONLY_RE = re.compile(r'class="sr-only"[^>]*>(.*?)</span>', re.I | re.S)
106
+ CARD_SUBTITLE_RE = re.compile(r'class="base-search-card__subtitle"[^>]*>(.*?)</h4>', re.I | re.S)
107
+ CARD_HREF_RE = re.compile(r'href="([^"]+)"', re.I)
108
+ CARD_LOCATION_RE = re.compile(r'class="job-search-card__location"[^>]*>(.*?)</span>', re.I | re.S)
109
+ CARD_DATE_RE = re.compile(r'class="job-search-card__listdate[^"]*"[^>]*datetime="([^"]+)"', re.I)
110
+
111
+
112
+ def parse_job_cards(markup):
113
+ """
114
+ Parse the search response: a flat list of <li> job cards. We split on the
115
+ job-posting URN and parse each chunk independently so one malformed card
116
+ cannot break the rest.
117
+ """
118
+ results = []
119
+
120
+ for chunk in CARD_SPLIT_RE.split(markup)[1:]:
121
+ id_match = CARD_ID_RE.match(chunk)
122
+ if not id_match:
123
+ continue
124
+ job_id = id_match.group(1)
125
+
126
+ # Full link + title (title lives in the sr-only span or the <h3> title).
127
+ link_match = CARD_LINK_RE.search(chunk)
128
+ url = unescape(link_match.group(1)).split("?")[0] if link_match else ""
129
+
130
+ title = None
131
+ h3 = CARD_TITLE_RE.search(chunk)
132
+ if h3:
133
+ title = clean(h3.group(1))
134
+ if not title:
135
+ sr = CARD_SR_ONLY_RE.search(chunk)
136
+ if sr:
137
+ title = clean(sr.group(1))
138
+ if not title:
139
+ continue
140
+
141
+ # Company (subtitle <h4> with optional inner <a>).
142
+ company = None
143
+ company_url = None
144
+ sub = CARD_SUBTITLE_RE.search(chunk)
145
+ if sub:
146
+ href = CARD_HREF_RE.search(sub.group(1))
147
+ if href:
148
+ company_url = unescape(href.group(1)).split("?")[0]
149
+ company = clean(sub.group(1)) or None
150
+
151
+ # Location + date.
152
+ loc = CARD_LOCATION_RE.search(chunk)
153
+ location = (clean(loc.group(1)) or None) if loc else None
154
+ date_match = CARD_DATE_RE.search(chunk)
155
+
156
+ results.append(
157
+ {
158
+ "id": job_id,
159
+ "title": title,
160
+ "company": company,
161
+ "companyUrl": company_url,
162
+ "location": location,
163
+ "date": date_match.group(1) if date_match else None,
164
+ "url": url or f"https://www.linkedin.com/jobs/view/{job_id}",
165
+ }
166
+ )
167
+
168
+ # Regex parsing degrades silently when LinkedIn changes its markup: a page
169
+ # full of content yields zero cards, which reads exactly like "no job matches
170
+ # the query". Warn so the caller can tell the two apart.
171
+ if not results and len(markup) > MARKUP_SANITY_BYTES:
172
+ write_warning(
173
+ f"parsed 0 job cards out of a {len(markup)}-byte response; "
174
+ "LinkedIn markup may have changed",
175
+ "NO_CARDS_PARSED",
176
+ )
177
+
178
+ return results
179
+
180
+
181
+ DETAIL_TITLE_RE = re.compile(
182
+ r'class="(?:top-card-layout__title|topcard__title)[^"]*"[^>]*>(.*?)</h[12]>', re.I | re.S
183
+ )
184
+ DETAIL_ORG_RE = re.compile(
185
+ r'class="topcard__org-name-link[^"]*"[^>]*href="([^"]+)"[^>]*>(.*?)</a>', re.I | re.S
186
+ )
187
+ DETAIL_LOCATION_RE = re.compile(
188
+ r'class="topcard__flavor topcard__flavor--bullet"[^>]*>(.*?)</span>', re.I | re.S
189
+ )
190
+ DETAIL_DESC_RE = re.compile(
191
+ r'class="(?:show-more-less-html__markup|description__text[^"]*)"[^>]*>(.*?)</div>', re.I | re.S
192
+ )
193
+ DETAIL_CRITERIA_RE = re.compile(
194
+ r'class="description__job-criteria-subheader"[^>]*>(.*?)</h3>'
195
+ r'.*?class="description__job-criteria-text[^"]*"[^>]*>(.*?)</span>',
196
+ re.I | re.S,
197
+ )
198
+ DETAIL_APPLY_RE = re.compile(r'class="topcard__link[^"]*"[^>]*href="([^"]+)"', re.I)
199
+ BR_RE = re.compile(r"<\s*br\s*/?>", re.I)
200
+ BLOCK_END_RE = re.compile(r"</(p|li|ul|ol|div|h\d)>", re.I)
201
+
202
+
203
+ def parse_job_detail(markup, job_id):
204
+ """Parse the single-job detail page."""
205
+ title_match = DETAIL_TITLE_RE.search(markup)
206
+ org_match = DETAIL_ORG_RE.search(markup)
207
+ company = (clean(org_match.group(2)) or None) if org_match else None
208
+ company_url = unescape(org_match.group(1)).split("?")[0] if org_match else None
209
+
210
+ loc_match = DETAIL_LOCATION_RE.search(markup)
211
+ location = (clean(loc_match.group(1)) or None) if loc_match else None
212
+
213
+ # Rich description block. Keep paragraph/line breaks as newlines.
214
+ description = None
215
+ desc_match = DETAIL_DESC_RE.search(markup)
216
+ if desc_match:
217
+ with_breaks = BLOCK_END_RE.sub("\n", BR_RE.sub("\n", desc_match.group(1)))
218
+ description = re.sub(r"\n{3,}", "\n\n", unescape(strip_tags(with_breaks))).strip() or None
219
+
220
+ # Job-criteria items: subheader label -> text value.
221
+ criteria = {
222
+ clean(label).lower(): clean(value)
223
+ for label, value in DETAIL_CRITERIA_RE.findall(markup)
224
+ }
225
+
226
+ apply_match = DETAIL_APPLY_RE.search(markup)
227
+
228
+ return {
229
+ "id": job_id,
230
+ "title": clean(title_match.group(1)) if title_match else "(untitled)",
231
+ "company": company,
232
+ "companyUrl": company_url,
233
+ "location": location,
234
+ "date": None,
235
+ "url": f"https://www.linkedin.com/jobs/view/{job_id}",
236
+ "description": description,
237
+ "seniority": criteria.get("seniority level"),
238
+ "employmentType": criteria.get("employment type"),
239
+ "jobFunction": criteria.get("job function"),
240
+ "industries": criteria.get("industries"),
241
+ "applyUrl": unescape(apply_match.group(1)).split("?")[0] if apply_match else None,
242
+ }
243
+
244
+
245
+ def jobage_to_tpr(days):
246
+ """Convert a job-age in days to LinkedIn's f_TPR seconds value."""
247
+ if not days or days <= 0 or days >= JOBAGE_ALL:
248
+ return None
249
+ return f"r{days * 86400}"
250
+
251
+
252
+ def work_type_flag(mode):
253
+ """Workplace-type flag: on-site=1, remote=2, hybrid=3."""
254
+ return {"remote": "2", "hybrid": "3", "onsite": "1", "on-site": "1"}.get((mode or "").lower())
255
+
256
+
257
+ def normalize_id(value):
258
+ """
259
+ Accept a raw job ID, a job-view URL, or a job URN. The ID may be followed by
260
+ a slash as well as a query string: the share button yields URLs shaped like
261
+ `/jobs/view/<id>/?refId=...`, so anchoring on `?` alone rejects the most
262
+ common paste.
263
+ """
264
+ urn = re.search(r"urn:li:jobPosting:(\d+)", value)
265
+ if urn:
266
+ return urn.group(1)
267
+ url = re.search(r"-(\d{6,})(?:[/?]|$)", value) or re.search(r"/(\d{6,})(?:[/?]|$)", value)
268
+ if url:
269
+ return url.group(1)
270
+ if re.fullmatch(r"\d{6,}", value):
271
+ return value
272
+ return None
273
+
274
+
275
+ def build_search_url(args):
276
+ params = []
277
+ if args.query:
278
+ params.append(("keywords", args.query))
279
+ if args.location:
280
+ params.append(("location", args.location))
281
+ tpr = jobage_to_tpr(args.jobage)
282
+ if tpr:
283
+ params.append(("f_TPR", tpr))
284
+ work_type = work_type_flag(args.remote)
285
+ if work_type:
286
+ params.append(("f_WT", work_type))
287
+ params.append(("start", str((args.page - 1) * 10)))
288
+ return f"{SEARCH_URL}?{urllib.parse.urlencode(params)}"
289
+
290
+
291
+ def render_table(cards):
292
+ if not cards:
293
+ return "No results."
294
+ header = (
295
+ "ID".ljust(11) + " " + "TITLE".ljust(42) + " " + "COMPANY".ljust(26) + " "
296
+ + "LOCATION".ljust(24) + " DATE"
297
+ )
298
+ rows = [
299
+ f"{c['id']:<11} {(c['title'] or '')[:42]:<42} {(c['company'] or '-')[:26]:<26} "
300
+ f"{(c['location'] or '-')[:24]:<24} {c['date'] or '-'}"
301
+ for c in cards
302
+ ]
303
+ return "\n".join([header, "-" * len(header), *rows])
304
+
305
+
306
+ def render_plain(cards):
307
+ return "\n\n".join(
308
+ f"{c['title']}\n {c['company'] or '-'} · {c['location'] or '-'} · {c['date'] or '-'}\n"
309
+ f" id: {c['id']}\n {c['url']}"
310
+ for c in cards
311
+ )
312
+
313
+
314
+ def run_search(args):
315
+ try:
316
+ cards = parse_job_cards(html_fetch(build_search_url(args)))
317
+ if args.limit is not None and args.limit >= 0:
318
+ cards = cards[: args.limit]
319
+
320
+ if args.format == "table":
321
+ sys.stdout.write(render_table(cards) + "\n")
322
+ elif args.format == "plain":
323
+ sys.stdout.write(render_plain(cards) + "\n")
324
+ else:
325
+ payload = {"meta": {"count": len(cards), "page": args.page}, "results": cards}
326
+ sys.stdout.write(json.dumps(payload, indent=2, ensure_ascii=False) + "\n")
327
+ return 0
328
+ except Exception as e:
329
+ write_error(str(e), "SEARCH_FAILED")
330
+ return 1
331
+
332
+
333
+ def run_detail(args):
334
+ job_id = normalize_id(args.id)
335
+ if not job_id:
336
+ write_error(f'Could not parse a job ID from "{args.id}"', "BAD_ID")
337
+ return 1
338
+ try:
339
+ markup = html_fetch(f"{DETAIL_URL}/{job_id}")
340
+ if not markup:
341
+ write_error("Job not found", "NOT_FOUND")
342
+ return 1
343
+ job = parse_job_detail(markup, job_id)
344
+
345
+ if args.format == "plain":
346
+ lines = [
347
+ job["title"],
348
+ f"{job['company'] or '-'} · {job['location'] or '-'}",
349
+ "",
350
+ f"Seniority: {job['seniority']}" if job["seniority"] else "",
351
+ f"Employment: {job['employmentType']}" if job["employmentType"] else "",
352
+ f"Function: {job['jobFunction']}" if job["jobFunction"] else "",
353
+ f"Industries: {job['industries']}" if job["industries"] else "",
354
+ "",
355
+ job["description"] or "(no description)",
356
+ "",
357
+ f"URL: {job['url']}",
358
+ f"Apply: {job['applyUrl']}" if job["applyUrl"] else "",
359
+ ]
360
+ sys.stdout.write("\n".join(line for line in lines if line != "") + "\n")
361
+ else:
362
+ sys.stdout.write(json.dumps(job, indent=2, ensure_ascii=False) + "\n")
363
+ return 0
364
+ except Exception as e:
365
+ write_error(str(e), "DETAIL_FAILED")
366
+ return 1
367
+
368
+
369
+ class JsonErrorParser(argparse.ArgumentParser):
370
+ """Keep the CLI's error contract: a JSON line on stderr and exit code 1."""
371
+
372
+ def error(self, message):
373
+ code = "NO_LOCATION" if "location" in message and "required" in message else "BAD_ARG"
374
+ write_error(message, code)
375
+ raise SystemExit(1)
376
+
377
+
378
+ def build_parser():
379
+ parser = JsonErrorParser(
380
+ prog="linkedin_search.py",
381
+ description="Search jobs on LinkedIn (any country/region, plus remote). "
382
+ "Personal use only - uses LinkedIn's public pages; keep volume low (LinkedIn ToS).",
383
+ )
384
+ sub = parser.add_subparsers(dest="command", required=True)
385
+
386
+ search = sub.add_parser("search", help="search job postings")
387
+ search.add_argument("-l", "--location", required=True,
388
+ help='REQUIRED. e.g. "Berlin, Germany", "Spain", "Remote"')
389
+ search.add_argument("-q", "--query", help="keywords (job title, skill, or role)")
390
+ search.add_argument("--jobage", type=int, default=JOBAGE_ALL,
391
+ help="posted within N days: 1, 7, 14, 30. Default: all")
392
+ search.add_argument("--remote", choices=["remote", "hybrid", "onsite", "on-site"],
393
+ help="filter by workplace type")
394
+ search.add_argument("--page", type=int, default=1, help="1-indexed page (10 results/page)")
395
+ search.add_argument("-n", "--limit", type=int,
396
+ help="cap results emitted (client-side; 0 emits none)")
397
+ search.add_argument("--format", choices=["json", "table", "plain"], default="json")
398
+ search.set_defaults(func=run_search)
399
+
400
+ detail = sub.add_parser("detail", help="full detail of one posting")
401
+ detail.add_argument("id", help="job ID, jobs/view URL (with or without trailing slash), or URN")
402
+ detail.add_argument("--format", choices=["json", "plain"], default="json")
403
+ detail.set_defaults(func=run_detail)
404
+
405
+ return parser
406
+
407
+
408
+ def force_utf8_streams():
409
+ """
410
+ On Windows stdout defaults to cp1252, so a bullet or an accent in a JD raises
411
+ UnicodeEncodeError the moment output is piped to a file. Node writes UTF-8
412
+ unconditionally; Python has to be told.
413
+ """
414
+ for stream in (sys.stdout, sys.stderr):
415
+ if hasattr(stream, "reconfigure"):
416
+ stream.reconfigure(encoding="utf-8")
417
+
418
+
419
+ def main():
420
+ force_utf8_streams()
421
+ args = build_parser().parse_args()
422
+ if getattr(args, "page", 1) < 1:
423
+ args.page = 1
424
+ sys.exit(args.func(args))
425
+
426
+
427
+ if __name__ == "__main__":
428
+ main()
@@ -7,7 +7,7 @@ user-invocable: true
7
7
  allowed-tools: Read, Glob, Grep, Write, Edit, WebFetch, WebSearch
8
8
  ---
9
9
 
10
- # Job Search — Búsqueda, scoring y plan
10
+ # Job Search - Búsqueda, scoring y plan
11
11
 
12
12
  Encontrás ofertas, las **scoreás** contra el perfil del usuario, priorizás, y armás un **plan de búsqueda de 7 días** con objetivos diarios. Alimentás el workspace con notas de aplicación y empresa para que el resto del flujo (`/cv-tailor`, `/cover-letter`, etc.) las consuma.
13
13
 
@@ -18,45 +18,48 @@ Encontrás ofertas, las **scoreás** contra el perfil del usuario, priorizás, y
18
18
 
19
19
  ## Modos
20
20
 
21
- ### `search <query>` — Encontrar ofertas
21
+ ### `search <query>` - Encontrar ofertas
22
+
23
+ > Si el skill `job-scrape` está instalado, para corridas recurrentes preferí `/job-scrape`: usa CLIs locales (LinkedIn + GetOnBoard) con dedup entre corridas. Este modo queda para búsquedas puntuales o fuentes que los CLIs no cubren.
24
+
22
25
  1. **Sourcing.** Conseguir ofertas frescas que matcheen el perfil (rol + stack + modalidad + mercado). Dos caminos:
23
- - **Apify MCP (opcional).** Si hay un MCP de Apify conectado, podés usar un actor de scraping de LinkedIn Jobs para traer ofertas estructuradas. Ejemplo de pedido: *"Scrapeá LinkedIn jobs para [rol] posteadas en los últimos 7 días en empresas de 50–5000 empleados; filtrá por match con mi background en [dominio]; devolvé tabla rankeada con empresa, rol, ubicación, salario, fecha de posteo y fit score 1–10 basado en mi resume; marcá el top 10 para aplicar hoy."* Buscá el actor con ToolSearch (`apify`) si el MCP está presente. **⚠️ OBLIGATORIO: antes de invocar Apify, mostrá el warning de abajo y pedí confirmación explícita.**
26
+ - **Apify MCP (opcional).** Si hay un MCP de Apify conectado, podés usar un actor de scraping de LinkedIn Jobs para traer ofertas estructuradas. Ejemplo de pedido: *"Scrapeá LinkedIn jobs para [rol] posteadas en los últimos 7 días en empresas de 50-5000 empleados; filtrá por match con mi background en [dominio]; devolvé tabla rankeada con empresa, rol, ubicación, salario, fecha de posteo y fit score 1-10 basado en mi resume; marcá el top 10 para aplicar hoy."* Buscá el actor con ToolSearch (`apify`) si el MCP está presente. **⚠️ OBLIGATORIO: antes de invocar Apify, mostrá el warning de abajo y pedí confirmación explícita.**
24
27
  - **WebSearch/WebFetch (default recomendado).** Camino sin Apify: buscar en job boards (muchos con feed/API pública: WeWorkRemotely, RemoteOK, etc.) y fetchear los JDs. Es el default seguro y gratis.
25
28
 
26
- > ### ⚠️ Warning Apify — mostralo SIEMPRE antes de usar esta parte
29
+ > ### ⚠️ Warning Apify - mostralo SIEMPRE antes de usar esta parte
27
30
  >
28
31
  > Antes de correr cualquier actor de Apify (especialmente scraping de LinkedIn), presentá estos puntos al usuario y esperá confirmación explícita. No invoques Apify "en silencio".
29
32
  >
30
33
  > 1. **No es gratis.** Apify cobra por uso (compute units / por resultado); un scrape grande consume crédito. El tier free es limitado.
31
34
  > 2. **Scrapear LinkedIn viola sus Términos de Servicio.** LinkedIn detecta y bloquea scraping activamente y puede **restringir o banear la cuenta**. La legalidad de scrapear datos públicos está en zona gris (caso hiQ vs LinkedIn); el riesgo de ToS/cuenta es real y es del usuario, no de Apify. Preferir actors sobre **job boards públicos** antes que LinkedIn directo; si igual se scrapea LinkedIn, hacerlo de forma consciente y no con una cuenta crítica.
32
- > 3. **El API token de Apify es sensible.** Autoriza correr actors (y gastar el plan) en nombre del usuario. Mantenerlo seguro; no exponerlo ni commitearlo. (Apify no recolecta conversaciones/tokens y trata a los actors como no confiables: les da un proxy y no les entrega las credenciales — un actor malicioso puede usar las tools habilitadas pero no exfiltrar el token.)
35
+ > 3. **El API token de Apify es sensible.** Autoriza correr actors (y gastar el plan) en nombre del usuario. Mantenerlo seguro; no exponerlo ni commitearlo. (Apify no recolecta conversaciones/tokens y trata a los actors como no confiables: les da un proxy y no les entrega las credenciales - un actor malicioso puede usar las tools habilitadas pero no exfiltrar el token.)
33
36
  > 4. **Calidad/estabilidad variable.** Los scrapers de LinkedIn se rompen seguido; los resultados pueden venir incompletos o desactualizados.
34
37
  >
35
38
  > Si el usuario no confirma, usá el camino WebSearch/WebFetch.
36
39
  2. Para cada oferta candidata: extraer rol, empresa, stack, modalidad, ubicación, rango salarial, link, fecha de cierre.
37
40
  3. Scorear (ver rúbrica) y filtrar por el umbral del perfil.
38
- 4. Crear nota en `03-Aplicaciones/` (template `Aplicacion`, estado `interesado`, con `score`) + nota en `04-Empresas/` (template `Empresa`) por cada una sobre el umbral.
41
+ 4. Crear nota en `03-Aplicaciones/` (template `Aplicacion`, estado `interesado`, con `match_score`) + nota en `04-Empresas/` (template `Empresa`) por cada una sobre el umbral.
39
42
  5. Actualizar el `00-Dashboard.md` con las activas por prioridad, marcando el **top 10 para hoy**.
40
43
 
41
- ### `score <job-url>` — Scorear una oferta puntual
44
+ ### `score <job-url>` - Scorear una oferta puntual
42
45
  1. WebFetch del JD.
43
- 2. Aplicar la rúbrica contra el perfil → score 0–100 + breakdown + recomendación (aplicar / borderline / skip).
46
+ 2. Aplicar la rúbrica contra el perfil → score 0-100 + breakdown + recomendación (aplicar / borderline / skip).
44
47
  3. Si pasa, crear las notas como arriba.
45
48
 
46
- ### `plan` — Plan de búsqueda de 7 días
49
+ ### `plan` - Plan de búsqueda de 7 días
47
50
  Generar un plan accionable con objetivos diarios. Ejemplo de esqueleto (ajustar al estado del usuario):
48
51
 
49
- - **Día 1 — Base.** Perfil maestro + perfiles por rol al día; LinkedIn optimizado (`/linkedin-optimize`).
50
- - **Día 2 — Sourcing.** Buscar y scorear 15–20 ofertas; quedarte con las ≥ umbral.
51
- - **Día 3 — Research.** Empresas + contactos objetivo de las top.
52
- - **Día 4 — Aplicaciones tanda 1.** CV custom + cover letter para las 5 de mayor prioridad (`/cv-tailor`, `/cv-ats-export`, `/cover-letter`).
53
- - **Día 5 — Outreach.** Mensajes a recruiters/hiring managers de las top (`/cold-outreach`).
54
- - **Día 6 — Aplicaciones tanda 2** + follow-ups de la tanda 1.
55
- - **Día 7 — Review.** Métricas (aplicadas / respuestas / entrevistas), ajustar perfil/CV según señales, planear próxima semana.
52
+ - **Día 1 - Base.** Perfil maestro + perfiles por rol al día; LinkedIn optimizado (`/linkedin-optimize`).
53
+ - **Día 2 - Sourcing.** Buscar y scorear 15-20 ofertas; quedarte con las ≥ umbral.
54
+ - **Día 3 - Research.** Empresas + contactos objetivo de las top.
55
+ - **Día 4 - Aplicaciones tanda 1.** CV custom + cover letter para las 5 de mayor prioridad (`/cv-tailor`, `/cv-ats-export`, `/cover-letter`).
56
+ - **Día 5 - Outreach.** Mensajes a recruiters/hiring managers de las top (`/cold-outreach`).
57
+ - **Día 6 - Aplicaciones tanda 2** + follow-ups de la tanda 1.
58
+ - **Día 7 - Review.** Métricas (aplicadas / respuestas / entrevistas), ajustar perfil/CV según señales, planear próxima semana.
56
59
 
57
60
  ## Rúbrica de scoring
58
61
 
59
- Ver `references/scoring-rubric.md`. En resumen, score ponderado 0–100 sobre:
62
+ Ver `references/scoring-rubric.md`. En resumen, score ponderado 0-100 sobre:
60
63
  - Match de stack/skills (peso alto)
61
64
  - Seniority correcto (penalizar over/under-qualified)
62
65
  - Modalidad y mercado/elegibilidad (remoto, zona horaria, visa)
@@ -68,14 +71,14 @@ Umbral default ≥75 (configurable en el frontmatter del perfil, campo `match_sc
68
71
 
69
72
  ## Tracking
70
73
 
71
- - Estados de aplicación: `interesado` → `aplicado` → `en proceso` → `entrevista` → `oferta` / `rechazado` / `descartado`.
74
+ - Estados de aplicación: `interesado` → `aplicado` → `entrevista` → `oferta`, y cierres: `contratado` / `rechazado` / `sin_respuesta` / `declinada` / `descartado` (los registra `/job-outcome`).
72
75
  - Mantener el `00-Dashboard.md` como vista única: activas por prioridad, próximas entrevistas, qué cierra pronto, a seguir esta semana.
73
76
 
74
77
  ## Reglas
75
78
 
76
79
  - No aplicar a todo: el umbral existe para concentrar esfuerzo donde hay match real.
77
80
  - Marcar gaps con honestidad al scorear (no inflar el match).
78
- - Respetar elegibilidad (visa/zona horaria) — un score alto con bloqueo de elegibilidad no sirve.
81
+ - Respetar elegibilidad (visa/zona horaria) - un score alto con bloqueo de elegibilidad no sirve.
79
82
  - No guardar datos sensibles de recruiters en remotos sin confirmación.
80
83
 
81
84
  ## Handoff
@@ -1,6 +1,6 @@
1
1
  # Job Scoring Rubric
2
2
 
3
- Score ponderado 0–100 para decidir dónde invertir esfuerzo. El objetivo no es aplicar a todo: es concentrarte en ofertas con match real. Umbral default **≥75** (configurable por perfil en `match_score_minimo`).
3
+ Score ponderado 0-100 para decidir dónde invertir esfuerzo. El objetivo no es aplicar a todo: es concentrarte en ofertas con match real. Umbral default **≥75** (configurable por perfil en `match_score_minimo`).
4
4
 
5
5
  ## Dimensiones y pesos (default)
6
6
 
@@ -13,12 +13,12 @@ Score ponderado 0–100 para decidir dónde invertir esfuerzo. El objetivo no es
13
13
  | **Empresa/cultura/etapa** | 10 | Encaje con preferencia (startup vs enterprise, producto vs consultora, valores). |
14
14
  | **Calidad de la oferta** | 10 | Claridad del JD, recencia (fresca > vieja), pocos aplicantes, proceso definido. |
15
15
 
16
- Sumá las dimensiones → score 0–100.
16
+ Sumá las dimensiones → score 0-100.
17
17
 
18
18
  ## Ajustes / banderas
19
19
 
20
- - **Frase gatillo positiva** (ej. "code-forward", "high autonomy", stack que amás): +2–5 si refuerza tu fit.
21
- - **Red flag en el JD** (ego, "rockstar", scope imposible, "trabajamos como familia"): −5–10 y nota de cautela.
20
+ - **Frase gatillo positiva** (ej. "code-forward", "high autonomy", stack que amás): +2-5 si refuerza tu fit.
21
+ - **Red flag en el JD** (ego, "rockstar", scope imposible, "trabajamos como familia"): −5-10 y nota de cautela.
22
22
  - **Cierre inminente:** no cambia el score, pero sube la **prioridad** de acción.
23
23
  - **Elegibilidad bloqueada** (visa que no tenés, zona horaria incompatible obligatoria): cap el score a <50 aunque el resto sea perfecto.
24
24
 
@@ -27,17 +27,17 @@ Sumá las dimensiones → score 0–100.
27
27
  | Score | Acción |
28
28
  |---|---|
29
29
  | **≥85** | Prioridad alta. Aplicar con CV custom + cover letter + outreach. |
30
- | **75–84** | Aplicar; CV custom sí, outreach si hay contacto fácil. |
31
- | **60–74** | Borderline. Aplicar solo si hay tiempo o un ángulo fuerte (referido). |
30
+ | **75-84** | Aplicar; CV custom sí, outreach si hay contacto fácil. |
31
+ | **60-74** | Borderline. Aplicar solo si hay tiempo o un ángulo fuerte (referido). |
32
32
  | **<60** | Skip salvo razón estratégica. |
33
33
 
34
34
  ## Prioridad (distinta del score)
35
35
 
36
36
  `prioridad` en el frontmatter combina score + urgencia + elegibilidad:
37
37
  - **alta:** score ≥85, o ≥75 con cierre inminente / referido caliente.
38
- - **media:** 75–84 sin urgencia.
38
+ - **media:** 75-84 sin urgencia.
39
39
  - **baja:** borderline o a la espera de validar requisitos.
40
40
 
41
41
  ## Registro
42
42
 
43
- Guardá el `score` en el frontmatter de la nota de `03-Aplicaciones/` y reflejá las activas en `00-Dashboard.md` agrupadas por prioridad. Anotá el breakdown si el score fue borderline — ayuda a decidir y a re-evaluar después.
43
+ Guardá el `score` en el frontmatter de la nota de `03-Aplicaciones/` y reflejá las activas en `00-Dashboard.md` agrupadas por prioridad. Anotá el breakdown si el score fue borderline - ayuda a decidir y a re-evaluar después.
@@ -0,0 +1,58 @@
1
+ ---
2
+ name: job-upskill
3
+ description: "Aggregate skill gaps across all scored job applications, weigh them by lost match points, and build a prioritized study plan with real resources. Triggers: analizar mis gaps, plan de estudio, que me falta aprender, upskill, en que skills invertir, gaps de las postulaciones, heatmap de skills."
4
+ argument-hint: "[url | nota de aplicación]"
5
+ category: "career"
6
+ user-invocable: true
7
+ allowed-tools: Read, Glob, Grep, Write, Bash, WebFetch, WebSearch
8
+ ---
9
+
10
+ # /job-upskill - Análisis de gaps y plan de estudio
11
+
12
+ Detecta qué skills te están costando puntos de match en las ofertas reales y arma un plan de estudio priorizado. Adaptado del /upskill de MadsLorentzen/ai-job-search: en vez de un CSV, la evidencia sale del frontmatter y las secciones de las notas de `03-Aplicaciones/` del career workspace (rutas relativas al workspace: `--workspace`, env `CAREER_WORKSPACE`, o `./career-workspace/`).
13
+
14
+ Argumento: `$ARGUMENTS` (vacío = modo agregado; URL o nota = modo targeted)
15
+
16
+ ## Modo agregado (sin argumento)
17
+
18
+ 1. **Recolectar evidencia.** Grep de `match_score:` en `03-Aplicaciones/*.md` (excluir `_index`; aceptar también el campo legacy `score:` de notas viejas). De cada nota leer: `match_score`, `estado`, `perfil`, y los gaps mencionados en las secciones "Match con mi perfil" / "Gaps reales" y los gaps reconocidos en `## Notas`.
19
+
20
+ 2. **Canonicalizar.** Unificar sinónimos antes de agregar ("AWS" = "Amazon Web Services"; "K8s" = "Kubernetes"; "GCP" = "Google Cloud"). Un término canónico por gap.
21
+
22
+ 3. **Ponderar.** `peso(gap) = Σ (100 - match_score)/100` sobre las notas que lo mencionan (la frecuencia queda implícita en la suma). Boost x1.5 por cada nota con `estado: rechazado` que lo mencione (señal real de mercado). Ofertas con score bajo exponen más gaps y pesan más.
23
+
24
+ 4. **Clasificar** cada gap: `[tooling]` (frameworks, infra, CI/CD), `[domain]` (industria, arquitectura), `[soft]` (comunicación, liderazgo, idiomas), `[credential]` (títulos, certificaciones).
25
+
26
+ 5. **Heatmap.** Ordenar por peso y cortar en Critical / High / Medium / Low por cuartiles (documentar los umbrales usados en el reporte). Mostrar el heatmap en el chat antes de seguir.
27
+
28
+ 6. **Plan de estudio** solo para Critical y High (sumar Medium si hay menos de 5 gaps):
29
+ - WebSearch por gap **incluyendo el año actual** en la query (ej. "best NestJS course 2026").
30
+ - 2-3 recursos reales por gap, priorizando gratuitos/oficiales/hands-on. **Nunca inventar recursos: solo citar lo encontrado en resultados reales.**
31
+ - Horas estimadas a proficiencia útil (rangos realistas, ej. "~20h") considerando la base existente del perfil (no arrancar de cero si ya hay fundamentos).
32
+ - Secuenciar por ROI (peso/horas) y dependencias (prerequisitos primero, quick wins temprano).
33
+
34
+ 7. **Reporte** en `07-Recursos/Upskill - YYYY-MM-DD.md`, previo approval del usuario sobre el heatmap:
35
+ - Frontmatter: `tipo: upskill`, `fecha`, `aplicaciones_analizadas` (número), `perfiles` (lista).
36
+ - Heatmap con peso y clasificación por gap.
37
+ - Evidencia: por cada gap Critical/High, wikilinks a las notas de aplicación que lo mencionan.
38
+ - Plan de estudio secuenciado con recursos, horas y dependencias.
39
+ - Si existe un reporte anterior en `07-Recursos/`, sección "Desde el último reporte": gaps cerrados vs nuevos.
40
+
41
+ 8. **No edita** notas de aplicaciones, perfiles ni dashboard. Solo crea el reporte.
42
+
43
+ ## Modo targeted (`/job-upskill <url|nota>`)
44
+
45
+ 1. Obtener el JD: nota existente → leer su "Match con mi perfil"; URL → `detail` del script que corresponda (`.claude/skills/job-scrape/scripts/getonbrd_search.py` o `linkedin_search.py`; si job-scrape no está instalado, WebFetch).
46
+ 2. Comparar contra el perfil de `01-Perfiles/` que corresponda: gaps solo de esa oferta, clasificados.
47
+ 3. Mini-plan: 1-2 recursos por gap (WebSearch, año actual), horas estimadas.
48
+ 4. **Responder en el chat.** Guardar como `07-Recursos/Upskill - YYYY-MM-DD - <Empresa>.md` solo si el usuario lo pide (no llenar Recursos de reportes de una sola oferta).
49
+
50
+ ## Reglas
51
+
52
+ - Nunca fabricar recursos ni estadísticas; todo sale de WebSearch real o de las notas.
53
+ - Reporte en el idioma de las notas del workspace, sin guiones largos, con wikilinks a las notas fuente.
54
+ - Los gaps `[credential]` (ej. título universitario) se reportan pero no entran al plan de estudio salvo pedido explícito (son decisiones de otra escala, no items de estudio semanal).
55
+
56
+ ## Handoff
57
+
58
+ Reporte COMPLETE en `07-Recursos/`. Retomar el pipeline con `/job-aplicar` en las ofertas abiertas, o `/job-scrape` para una corrida nueva apuntando a los gaps que ya se están cerrando.
@@ -1,13 +1,13 @@
1
1
  ---
2
2
  name: linkedin-optimize
3
- description: "Optimize a LinkedIn profile to attract recruiters and rank in search — headline, About, experience with KPIs, keywords, value proposition and a 30-second pitch. No LinkedIn Premium needed. Triggers: optimizar LinkedIn, mejorar perfil LinkedIn, headline LinkedIn, about LinkedIn, LinkedIn para reclutadores, LinkedIn SEO, pitch profesional."
3
+ description: "Optimize a LinkedIn profile to attract recruiters and rank in search - headline, About, experience with KPIs, keywords, value proposition and a 30-second pitch. No LinkedIn Premium needed. Triggers: optimizar LinkedIn, mejorar perfil LinkedIn, headline LinkedIn, about LinkedIn, LinkedIn para reclutadores, LinkedIn SEO, pitch profesional."
4
4
  argument-hint: "[section | full] (paste your current profile)"
5
5
  category: "career"
6
6
  user-invocable: true
7
7
  allowed-tools: Read, Glob, Grep, Write, Edit
8
8
  ---
9
9
 
10
- # LinkedIn Optimize — Perfil que atrae oportunidades
10
+ # LinkedIn Optimize - Perfil que atrae oportunidades
11
11
 
12
12
  Optimizás un perfil de LinkedIn para que (1) ranquee en las búsquedas de recruiters y (2) convenza al humano que lo abre. Trabajás contra el **perfil maestro** (`01-Perfiles/`) y lo que el usuario pega de su perfil actual.
13
13
 
@@ -31,8 +31,8 @@ Cubrí estas siete (el usuario puede pedir una con `[section]` o todas con `full
31
31
  - Las keywords del headline pesan fuerte en el ranking de búsqueda.
32
32
 
33
33
  ### 2. About (sección "Acerca de")
34
- - Primera 2–3 líneas son las que se ven sin "ver más": que enganchen.
35
- - Estructura: gancho → qué hacés y para quién → 2–3 logros con número → stack/keywords → CTA suave.
34
+ - Primera 2-3 líneas son las que se ven sin "ver más": que enganchen.
35
+ - Estructura: gancho → qué hacés y para quién → 2-3 logros con número → stack/keywords → CTA suave.
36
36
  - Primera persona, tono humano, escaneable (párrafos cortos o bullets).
37
37
 
38
38
  ### 3. Experiencia con KPIs
@@ -70,7 +70,7 @@ LinkedIn en **inglés** si apuntás a roles internacionales (mayor alcance), esp
70
70
 
71
71
  ## Reglas
72
72
 
73
- - No inventar logros ni métricas — todo debe ser defendible.
73
+ - No inventar logros ni métricas - todo debe ser defendible.
74
74
  - Keywords naturales, no stuffing.
75
75
  - Recordar que no se puede acceder/editar LinkedIn: el output es para copiar/pegar.
76
76