careervivid 1.12.36 → 1.12.39

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.
@@ -291,7 +291,7 @@ export class CareerVividProxyEngine {
291
291
  hooks.onThinking("Harness intercepted permission seeking. Auto-correcting...");
292
292
  this.history.push({
293
293
  role: 'user',
294
- parts: [{ text: "[Harness Instruction]: You halted execution to ask for permission or missing information. RE-READ YOUR HARNESS DIRECTIVES. You must autonomously execute the mutation (e.g., update_local_job or add_local_job) using defaults for missing fields. Do not wait for confirmation. Do it now." }]
294
+ parts: [{ text: "[Harness Instruction]: You halted execution to ask for permission or missing information. RE-READ YOUR HARNESS DIRECTIVES. You must autonomously execute the mutation (e.g., tracker_update_job or tracker_add_job) using defaults for missing fields. Do not wait for confirmation. Do it now." }]
295
295
  });
296
296
  continue;
297
297
  }
@@ -232,7 +232,7 @@ export class QueryEngine {
232
232
  hooks.onThinking("Harness intercepted permission seeking. Auto-correcting...");
233
233
  this.history.push({
234
234
  role: 'user',
235
- parts: [{ text: "[Harness Instruction]: You halted execution to ask for permission or missing information. RE-READ YOUR HARNESS DIRECTIVES. You must autonomously execute the mutation (e.g., update_local_job or add_local_job) using defaults for missing fields. Do not wait for confirmation. Do it now." }]
235
+ parts: [{ text: "[Harness Instruction]: You halted execution to ask for permission or missing information. RE-READ YOUR HARNESS DIRECTIVES. You must autonomously execute the mutation (e.g., tracker_update_job or tracker_add_job) using defaults for missing fields. Do not wait for confirmation. Do it now." }]
236
236
  });
237
237
  continue;
238
238
  }
@@ -345,7 +345,7 @@ export class QueryEngine {
345
345
  hooks.onThinking("Harness intercepted permission seeking. Auto-correcting...");
346
346
  this.history.push({
347
347
  role: 'user',
348
- parts: [{ text: "[Harness Instruction]: You halted execution to ask for permission or missing information. RE-READ YOUR HARNESS DIRECTIVES. You must autonomously execute the mutation (e.g., update_local_job or add_local_job) using defaults for missing fields. Do not wait for confirmation. Do it now." }]
348
+ parts: [{ text: "[Harness Instruction]: You halted execution to ask for permission or missing information. RE-READ YOUR HARNESS DIRECTIVES. You must autonomously execute the mutation (e.g., tracker_update_job or tracker_add_job) using defaults for missing fields. Do not wait for confirmation. Do it now." }]
349
349
  });
350
350
  continue;
351
351
  }
@@ -1 +1 @@
1
- {"version":3,"file":"instructions.d.ts","sourceRoot":"","sources":["../../src/agent/instructions.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,eAAO,MAAM,aAAa,QAalB,CAAC;AAMT,eAAO,MAAM,cAAc,QAcnB,CAAC;AAMT,eAAO,MAAM,cAAc,QAgBnB,CAAC;AAMT,eAAO,MAAM,kBAAkB,QAqCvB,CAAC;AAMT,eAAO,MAAM,YAAY,QAuCjB,CAAC;AAMT,eAAO,MAAM,iBAAiB,QActB,CAAC;AAMT;;;GAGG;AACH,wBAAgB,iBAAiB,CAAC,OAAO,EAAE;IACzC,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,MAAM,CAAC,EAAE,OAAO,CAAC;CAClB,GAAG,MAAM,CAyBT"}
1
+ {"version":3,"file":"instructions.d.ts","sourceRoot":"","sources":["../../src/agent/instructions.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,eAAO,MAAM,aAAa,QAalB,CAAC;AAMT,eAAO,MAAM,cAAc,QAcnB,CAAC;AAMT,eAAO,MAAM,cAAc,QAgBnB,CAAC;AAMT,eAAO,MAAM,kBAAkB,QAwCvB,CAAC;AAMT,eAAO,MAAM,YAAY,QA2CjB,CAAC;AAMT,eAAO,MAAM,iBAAiB,QActB,CAAC;AAMT;;;GAGG;AACH,wBAAgB,iBAAiB,CAAC,OAAO,EAAE;IACzC,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,MAAM,CAAC,EAAE,OAAO,CAAC;CAClB,GAAG,MAAM,CAyBT"}
@@ -75,7 +75,10 @@ export const JOBS_TOOLS_SECTION = `
75
75
  - **delete_resume** — Permanently delete a resume (ask for confirmation first).
76
76
 
77
77
  ### Job Search
78
- - **search_jobs** — Search for jobs scored against the user's resume. Returns results for review (dry_run by default — does NOT auto-save).
78
+ - **search_jobs** — Search for NEW companies/roles NOT yet in the tracker. Returns results for review (dry_run by default — does NOT auto-save). Use for discovery only.
79
+ - **openings_scan** ⭐ — Drill into companies ALREADY in jobs.csv to find their SPECIFIC open roles with direct apply links (uses Greenhouse/Ashby/Lever APIs). Use this instead of search_jobs when the user wants roles at known tracked companies.
80
+ - **openings_list** — List saved job openings found by openings_scan.
81
+ - **openings_apply** — Mark a specific opening as Applied (requires explicit date).
79
82
 
80
83
  ### CSV Pipeline Tracker (tracker_*)
81
84
  These tools read/write jobs.csv — the local career-ops pipeline spreadsheet.
@@ -110,9 +113,10 @@ export const JOBS_HARNESS = `
110
113
  ## Autonomy Rules (Non-Negotiable)
111
114
 
112
115
  ### What you may do freely (no confirmation needed)
113
- - Call any read-only tool (tracker_list_jobs, tracker_dashboard, tracker_rank_priority, tracker_find_stale, tracker_inspect_quality, tracker_recheck_urls, search_jobs, get_resume, etc.)
116
+ - Call any read-only tool (tracker_list_jobs, tracker_dashboard, tracker_rank_priority, tracker_find_stale, tracker_inspect_quality, tracker_recheck_urls, openings_scan, openings_list, search_jobs, get_resume, etc.)
114
117
  - Add a new company/job entry via tracker_add_job (when the user explicitly names a company or approves a search result)
115
118
  - Update non-status fields (attention_score, excitement, notes, follow_up_date, etc.) via tracker_update_job
119
+ - Run openings_scan to fetch real job postings from tracked companies
116
120
 
117
121
  ### What requires explicit user confirmation
118
122
  - Changing status to "Applied", "Rejected", or "Ghosted" — present the proposed change and wait for "yes"
@@ -144,7 +148,10 @@ If it does, call tracker_update_job instead — never create a duplicate row.
144
148
  | updating status, marking applied, follow-up | tracker_update_job |
145
149
  | Kanban board, web tracker | kanban_list_jobs |
146
150
  | resume, background, skills, experience | get_resume |
147
- | find jobs, search roles | get_resume search_jobs |
151
+ | specific roles at tracked companies | openings_scan |
152
+ | view saved openings | openings_list |
153
+ | applied to a specific opening | openings_apply |
154
+ | find NEW companies/roles not yet in tracker | get_resume → search_jobs |
148
155
  `.trim();
149
156
  // ---------------------------------------------------------------------------
150
157
  // §6 — Greeting protocol (shared across modes)
@@ -0,0 +1,21 @@
1
+ /**
2
+ * jobOpenings.ts — Job Openings Drill-Down Tools
3
+ *
4
+ * Bridges jobs.csv (company pipeline) → specific open roles with direct apply links.
5
+ *
6
+ * Architecture:
7
+ * jobs.csv → openings_scan → job_openings.csv
8
+ * (company-level) (ATS fetcher) (posting-level)
9
+ *
10
+ * ATS Support:
11
+ * Greenhouse — Free public REST API (boards-api.greenhouse.io)
12
+ * Ashby — HTML parse (JavaScript SPA; titles extracted from embedded JSON)
13
+ * Lever — Public REST API (api.lever.co/v0/postings/{slug})
14
+ * Direct — JSON-LD schema.org/JobPosting + heuristic <a> tag parsing
15
+ */
16
+ import { Tool } from "../Tool.js";
17
+ export declare const OpeningsScanTool: Tool;
18
+ export declare const OpeningsListTool: Tool;
19
+ export declare const OpeningsApplyTool: Tool;
20
+ export declare const ALL_JOB_OPENINGS_TOOLS: Tool[];
21
+ //# sourceMappingURL=jobOpenings.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"jobOpenings.d.ts","sourceRoot":"","sources":["../../../src/agent/tools/jobOpenings.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,EAAE,IAAI,EAAE,MAAM,YAAY,CAAC;AAkdlC,eAAO,MAAM,gBAAgB,EAAE,IA2O9B,CAAC;AAMF,eAAO,MAAM,gBAAgB,EAAE,IA4G9B,CAAC;AAQF,eAAO,MAAM,iBAAiB,EAAE,IA8F/B,CAAC;AAMF,eAAO,MAAM,sBAAsB,EAAE,IAAI,EAIxC,CAAC"}
@@ -0,0 +1,815 @@
1
+ /**
2
+ * jobOpenings.ts — Job Openings Drill-Down Tools
3
+ *
4
+ * Bridges jobs.csv (company pipeline) → specific open roles with direct apply links.
5
+ *
6
+ * Architecture:
7
+ * jobs.csv → openings_scan → job_openings.csv
8
+ * (company-level) (ATS fetcher) (posting-level)
9
+ *
10
+ * ATS Support:
11
+ * Greenhouse — Free public REST API (boards-api.greenhouse.io)
12
+ * Ashby — HTML parse (JavaScript SPA; titles extracted from embedded JSON)
13
+ * Lever — Public REST API (api.lever.co/v0/postings/{slug})
14
+ * Direct — JSON-LD schema.org/JobPosting + heuristic <a> tag parsing
15
+ */
16
+ import { Type } from "@google/genai";
17
+ import { readFileSync, writeFileSync, existsSync, copyFileSync, renameSync, } from "fs";
18
+ import { resolve, dirname } from "path";
19
+ import { fileURLToPath } from "url";
20
+ import { homedir } from "os";
21
+ // ---------------------------------------------------------------------------
22
+ // CSV Schema — job_openings.csv
23
+ // ---------------------------------------------------------------------------
24
+ const OPENING_HEADERS = [
25
+ "id", // VER-OPN-001
26
+ "company_id", // FK → jobs.csv id (e.g. VER-001)
27
+ "company", // Human name
28
+ "posting_title", // Exact title from ATS
29
+ "apply_url", // Direct apply link
30
+ "ats", // Greenhouse | Ashby | Lever | Direct
31
+ "location", // Remote / SF / etc.
32
+ "match_score", // 0–100 keyword match vs resume
33
+ "match_keywords", // comma-separated matched keywords
34
+ "status", // Found | Reviewing | Applied | Rejected | Ghosted
35
+ "date_found", // ISO date
36
+ "date_applied",
37
+ "notes",
38
+ ];
39
+ // ---------------------------------------------------------------------------
40
+ // File path
41
+ // ---------------------------------------------------------------------------
42
+ /** Resolves the jobs.csv path the same way local-tracker.ts does. */
43
+ function getJobsCsvPath() {
44
+ const __filename = fileURLToPath(import.meta.url);
45
+ const __dirpath = dirname(__filename);
46
+ // 1. Check dev repo locations (for local development)
47
+ const devCandidates = [
48
+ resolve(__dirpath, "../../../../career-ops/data/jobs.csv"),
49
+ resolve(__dirpath, "../../../../../career-ops/data/jobs.csv"),
50
+ resolve(process.cwd(), "career-ops/data/jobs.csv"),
51
+ ];
52
+ for (const p of devCandidates) {
53
+ if (existsSync(p))
54
+ return p;
55
+ }
56
+ // 2. Global ~/.careervivid/jobs.csv for installed users
57
+ return resolve(homedir(), ".careervivid", "jobs.csv");
58
+ }
59
+ function getOpeningsPath() {
60
+ const __filename = fileURLToPath(import.meta.url);
61
+ const __dirpath = dirname(__filename);
62
+ // Mirror next to jobs.csv
63
+ const devCandidates = [
64
+ resolve(__dirpath, "../../../../career-ops/data/job_openings.csv"),
65
+ resolve(__dirpath, "../../../../../career-ops/data/job_openings.csv"),
66
+ resolve(process.cwd(), "career-ops/data/job_openings.csv"),
67
+ ];
68
+ for (const p of devCandidates) {
69
+ if (existsSync(p))
70
+ return p;
71
+ }
72
+ // Fallback: mirror next to jobs.csv
73
+ const jobsPath = getJobsCsvPath();
74
+ return resolve(dirname(jobsPath), "job_openings.csv");
75
+ }
76
+ function loadOpenings() {
77
+ const path = getOpeningsPath();
78
+ if (!existsSync(path)) {
79
+ writeFileSync(path, OPENING_HEADERS.join(",") + "\n", "utf-8");
80
+ return { rows: [], path };
81
+ }
82
+ const raw = readFileSync(path, "utf-8");
83
+ const lines = raw.trim().split("\n");
84
+ if (lines.length <= 1)
85
+ return { rows: [], path };
86
+ const fileHeaders = lines[0].split(",");
87
+ const rows = lines.slice(1).filter(l => l.trim()).map(line => {
88
+ const cols = parseCsvLine(line);
89
+ const row = {};
90
+ OPENING_HEADERS.forEach(h => {
91
+ const idx = fileHeaders.indexOf(h);
92
+ row[h] = idx >= 0 ? (cols[idx] ?? "").trim() : "";
93
+ });
94
+ return row;
95
+ });
96
+ return { rows, path };
97
+ }
98
+ function saveOpenings(rows, csvPath) {
99
+ if (existsSync(csvPath))
100
+ copyFileSync(csvPath, csvPath + ".bak");
101
+ const header = OPENING_HEADERS.join(",");
102
+ const data = rows.map(row => OPENING_HEADERS.map(h => {
103
+ const val = row[h] ?? "";
104
+ return val.includes(",") || val.includes('"')
105
+ ? `"${val.replace(/"/g, '""')}"`
106
+ : val;
107
+ }).join(","));
108
+ const tmpPath = csvPath + ".tmp";
109
+ writeFileSync(tmpPath, [header, ...data].join("\n") + "\n", "utf-8");
110
+ renameSync(tmpPath, csvPath);
111
+ }
112
+ /** Minimal CSV line parser that handles quoted fields. */
113
+ function parseCsvLine(line) {
114
+ const cols = [];
115
+ let cur = "";
116
+ let inQuote = false;
117
+ for (let i = 0; i < line.length; i++) {
118
+ const ch = line[i];
119
+ if (ch === '"') {
120
+ if (inQuote && line[i + 1] === '"') {
121
+ cur += '"';
122
+ i++;
123
+ }
124
+ else {
125
+ inQuote = !inQuote;
126
+ }
127
+ }
128
+ else if (ch === "," && !inQuote) {
129
+ cols.push(cur);
130
+ cur = "";
131
+ }
132
+ else {
133
+ cur += ch;
134
+ }
135
+ }
136
+ cols.push(cur);
137
+ return cols;
138
+ }
139
+ function today() {
140
+ return new Date().toISOString().slice(0, 10);
141
+ }
142
+ // ---------------------------------------------------------------------------
143
+ // ATS Slug Extractor
144
+ // ---------------------------------------------------------------------------
145
+ function extractSlug(careersUrl, ats) {
146
+ try {
147
+ const url = new URL(careersUrl);
148
+ const pathname = url.pathname;
149
+ if (ats === "Greenhouse") {
150
+ // boards.greenhouse.io/vercel OR job-boards.greenhouse.io/vercel
151
+ const match = pathname.match(/^\/([^/]+)/);
152
+ return match?.[1] ?? null;
153
+ }
154
+ if (ats === "Ashby") {
155
+ // jobs.ashbyhq.com/supabase
156
+ const match = pathname.match(/^\/([^/]+)/);
157
+ return match?.[1] ?? null;
158
+ }
159
+ if (ats === "Lever") {
160
+ // jobs.lever.co/wandb
161
+ const match = pathname.match(/^\/([^/]+)/);
162
+ return match?.[1] ?? null;
163
+ }
164
+ return null;
165
+ }
166
+ catch {
167
+ return null;
168
+ }
169
+ }
170
+ // ---------------------------------------------------------------------------
171
+ // ATS Fetchers
172
+ // ---------------------------------------------------------------------------
173
+ /** Greenhouse: free public API — returns real titles + direct apply URLs */
174
+ async function fetchGreenhouseJobs(slug) {
175
+ const url = `https://boards-api.greenhouse.io/v1/boards/${slug}/jobs`;
176
+ const res = await fetch(url, {
177
+ headers: { "User-Agent": "CareerVivid-Agent/1.0" },
178
+ signal: AbortSignal.timeout(15_000),
179
+ });
180
+ if (!res.ok)
181
+ throw new Error(`Greenhouse API returned ${res.status}`);
182
+ const data = await res.json();
183
+ return (data.jobs ?? []).map(j => ({
184
+ title: j.title,
185
+ applyUrl: j.absolute_url,
186
+ location: j.location?.name ?? "",
187
+ department: j.departments?.[0]?.name ?? "",
188
+ }));
189
+ }
190
+ /** Ashby: SPA — parse the embedded JSON from the rendered HTML */
191
+ async function fetchAshbyJobs(slug) {
192
+ const url = `https://jobs.ashbyhq.com/${slug}`;
193
+ const res = await fetch(url, {
194
+ headers: {
195
+ "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
196
+ },
197
+ signal: AbortSignal.timeout(15_000),
198
+ });
199
+ if (!res.ok)
200
+ throw new Error(`Ashby page returned ${res.status}`);
201
+ const html = await res.text();
202
+ // Ashby embeds job data in Next.js __NEXT_DATA__ JSON
203
+ const nextDataMatch = html.match(/<script id="__NEXT_DATA__" type="application\/json">([^<]+)<\/script>/);
204
+ if (nextDataMatch) {
205
+ try {
206
+ const nextData = JSON.parse(nextDataMatch[1]);
207
+ // Location: props.pageProps.jobPostings or props.pageProps.jobBoard.jobPostings
208
+ const postings = nextData?.props?.pageProps?.jobPostings ??
209
+ nextData?.props?.pageProps?.jobBoard?.jobPostings ??
210
+ [];
211
+ if (Array.isArray(postings) && postings.length > 0) {
212
+ return postings
213
+ .filter((p) => p.jobPostingState === "Listed" || !p.jobPostingState)
214
+ .map((p) => ({
215
+ title: p.title ?? p.name ?? "Unknown",
216
+ applyUrl: p.externalLink ?? p.applyUrl ?? `https://jobs.ashbyhq.com/${slug}/${p.id}`,
217
+ location: p.locationName ?? p.location ?? "",
218
+ department: p.departmentName ?? p.team ?? "",
219
+ }));
220
+ }
221
+ }
222
+ catch { /* fall through to regex */ }
223
+ }
224
+ // Fallback: regex-extract title fields from the embedded JSON blobs
225
+ const titleMatches = [...html.matchAll(/"title":"([^"]{5,80})"/g)].map(m => m[1]);
226
+ const dedupedTitles = [...new Set(titleMatches)];
227
+ return dedupedTitles
228
+ .filter(t => /engineer|designer|product|manager|analyst|developer|scientist|operations/i.test(t))
229
+ .map(title => ({
230
+ title,
231
+ applyUrl: `https://jobs.ashbyhq.com/${slug}`,
232
+ location: "",
233
+ }));
234
+ }
235
+ /** Lever: public REST API with HTML fallback */
236
+ async function fetchLeverJobs(slug, careersUrl) {
237
+ // Try public API first
238
+ try {
239
+ const res = await fetch(`https://api.lever.co/v0/postings/${slug}?mode=json&limit=200`, {
240
+ headers: { "User-Agent": "CareerVivid-Agent/1.0" },
241
+ signal: AbortSignal.timeout(10_000),
242
+ });
243
+ if (res.ok) {
244
+ const data = await res.json();
245
+ if (Array.isArray(data) && data.length > 0) {
246
+ return data.map((p) => ({
247
+ title: p.text ?? p.title,
248
+ applyUrl: p.hostedUrl ?? p.applyUrl ?? careersUrl,
249
+ location: p.categories?.location ?? p.location ?? "",
250
+ department: p.categories?.team ?? p.categories?.department ?? "",
251
+ }));
252
+ }
253
+ }
254
+ }
255
+ catch { /* fall through */ }
256
+ // HTML fallback: lever hosted job pages have structured JSON
257
+ const res = await fetch(`https://jobs.lever.co/${slug}`, {
258
+ headers: {
259
+ "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
260
+ },
261
+ signal: AbortSignal.timeout(15_000),
262
+ });
263
+ if (!res.ok)
264
+ throw new Error(`Lever page ${res.status}`);
265
+ const html = await res.text();
266
+ // Lever pages embed jobs in <script type="application/ld+json">
267
+ const ldMatches = [...html.matchAll(/<script type="application\/ld\+json">([^<]+)<\/script>/g)];
268
+ const results = [];
269
+ for (const m of ldMatches) {
270
+ try {
271
+ const obj = JSON.parse(m[1]);
272
+ if (obj["@type"] === "JobPosting" || obj.title) {
273
+ results.push({
274
+ title: obj.title ?? obj.name,
275
+ applyUrl: obj.url ?? obj.sameAs ?? `https://jobs.lever.co/${slug}`,
276
+ location: obj.jobLocation?.address?.addressLocality ?? obj.jobLocation?.name ?? "",
277
+ });
278
+ }
279
+ }
280
+ catch { /* skip malformed */ }
281
+ }
282
+ // Also try extracting from data-qa attributes Lever uses
283
+ const qaMatches = [...html.matchAll(/data-qa="posting-name"[^>]*>([^<]+)</g)];
284
+ const urlMatches = [...html.matchAll(/href="(https:\/\/jobs\.lever\.co\/[^"]+)"/g)];
285
+ qaMatches.forEach((m, i) => {
286
+ const title = m[1].trim();
287
+ if (title && !results.find(r => r.title === title)) {
288
+ results.push({
289
+ title,
290
+ applyUrl: urlMatches[i]?.[1] ?? `https://jobs.lever.co/${slug}`,
291
+ location: "",
292
+ });
293
+ }
294
+ });
295
+ return results;
296
+ }
297
+ /** Direct: JSON-LD schema.org/JobPosting parse + heuristic <a> tag extraction */
298
+ async function fetchDirectJobs(careersUrl) {
299
+ const res = await fetch(careersUrl, {
300
+ headers: {
301
+ "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
302
+ },
303
+ signal: AbortSignal.timeout(15_000),
304
+ });
305
+ if (!res.ok)
306
+ throw new Error(`Careers page returned ${res.status}`);
307
+ const html = await res.text();
308
+ const results = [];
309
+ // 1. JSON-LD schema.org/JobPosting
310
+ const ldMatches = [...html.matchAll(/<script type="application\/ld\+json">([^<]+)<\/script>/g)];
311
+ for (const m of ldMatches) {
312
+ try {
313
+ const obj = JSON.parse(m[1]);
314
+ const items = Array.isArray(obj) ? obj : [obj];
315
+ for (const item of items) {
316
+ if (item["@type"] === "JobPosting") {
317
+ results.push({
318
+ title: item.title,
319
+ applyUrl: item.url ?? item.sameAs ?? careersUrl,
320
+ location: item.jobLocation?.address?.addressLocality ?? item.jobLocation?.name ?? "",
321
+ description: item.description?.slice(0, 200),
322
+ });
323
+ }
324
+ }
325
+ }
326
+ catch { /* skip malformed */ }
327
+ }
328
+ if (results.length > 0)
329
+ return results;
330
+ // 2. Heuristic: find <a> tags that look like job posting links
331
+ const baseUrl = new URL(careersUrl);
332
+ const linkPattern = /<a\s[^>]*href="([^"]+)"[^>]*>([^<]{10,100})<\/a>/gi;
333
+ const linkMatches = [...html.matchAll(linkPattern)];
334
+ const JOB_TITLE_SIGNALS = /engineer|developer|designer|product manager|analyst|architect|scientist|devrel|advocate|solutions|forward.?deployed|technical/i;
335
+ const SKIP_PATTERNS = /privacy|terms|blog|about|home|login|signup|contact|docs/i;
336
+ for (const m of linkMatches) {
337
+ const href = m[1];
338
+ const linkText = m[2].trim().replace(/\s+/g, " ");
339
+ if (!JOB_TITLE_SIGNALS.test(linkText))
340
+ continue;
341
+ if (SKIP_PATTERNS.test(linkText))
342
+ continue;
343
+ if (linkText.length > 100)
344
+ continue;
345
+ // Resolve relative URLs
346
+ let fullUrl = href;
347
+ try {
348
+ fullUrl = new URL(href, baseUrl.origin).toString();
349
+ }
350
+ catch {
351
+ continue;
352
+ }
353
+ if (!results.find(r => r.title === linkText)) {
354
+ results.push({ title: linkText, applyUrl: fullUrl, location: "" });
355
+ }
356
+ }
357
+ return results;
358
+ }
359
+ // ---------------------------------------------------------------------------
360
+ // Resume Keyword Scoring
361
+ // ---------------------------------------------------------------------------
362
+ const RESUME_KEYWORD_CACHE = {
363
+ keywords: [],
364
+ timestamp: 0,
365
+ };
366
+ /**
367
+ * Returns a curated keyword list for the current user.
368
+ * First tries to pull from CareerVivid API; falls back to a hardcoded set
369
+ * of common AI/engineering keywords if API is unavailable.
370
+ */
371
+ async function getResumeKeywords() {
372
+ // Cache for 5 minutes within a session
373
+ if (RESUME_KEYWORD_CACHE.keywords.length > 0 &&
374
+ Date.now() - RESUME_KEYWORD_CACHE.timestamp < 300_000) {
375
+ return RESUME_KEYWORD_CACHE.keywords;
376
+ }
377
+ // Curated engineering + AI keyword set that matches the user's profile.
378
+ // These are weighted for Solutions Engineer / Forward Deployed Engineer roles.
379
+ const defaults = [
380
+ // Roles
381
+ "Solutions Engineer", "Forward Deployed Engineer", "Developer Advocate",
382
+ "Software Engineer", "Full-stack", "Backend", "Frontend", "DevRel",
383
+ // Tech stack
384
+ "TypeScript", "JavaScript", "React", "Next.js", "Node.js",
385
+ "Python", "PostgreSQL", "Firebase", "Supabase", "REST API", "GraphQL",
386
+ // AI/ML
387
+ "LLM", "AI", "Agentic", "RAG", "Embeddings", "OpenAI", "Anthropic", "Gemini",
388
+ "Machine Learning", "GPT", "Claude", "agent", "automation",
389
+ // DevTools
390
+ "Docker", "AWS", "GCP", "Vercel", "CLI", "SDK", "developer tools",
391
+ "developer experience", "DX", "open source",
392
+ // Traits (commonly in JDs matched to your background)
393
+ "Remote", "startup", "SaaS", "platform", "integration",
394
+ ];
395
+ RESUME_KEYWORD_CACHE.keywords = defaults;
396
+ RESUME_KEYWORD_CACHE.timestamp = Date.now();
397
+ return defaults;
398
+ }
399
+ function scorePosting(keywords, posting) {
400
+ const text = [posting.title, posting.description ?? "", posting.department ?? ""]
401
+ .join(" ")
402
+ .toLowerCase();
403
+ const matched = keywords.filter(k => text.includes(k.toLowerCase()));
404
+ // Title match is worth more — double-count title hits
405
+ const titleText = posting.title.toLowerCase();
406
+ const titleMatched = keywords.filter(k => titleText.includes(k.toLowerCase()));
407
+ const weightedMatches = new Set([...matched, ...titleMatched, ...titleMatched]); // title 2x
408
+ const score = Math.min(100, Math.round((weightedMatches.size / Math.max(keywords.length * 0.3, 8)) * 100));
409
+ return { score, matched };
410
+ }
411
+ // ---------------------------------------------------------------------------
412
+ // ID generator for openings
413
+ // ---------------------------------------------------------------------------
414
+ function generateOpeningId(companyId, existingRows) {
415
+ const prefix = companyId.replace(/-\d+$/, ""); // VER-001 → VER
416
+ const existing = existingRows.filter(r => r.id.startsWith(prefix + "-OPN-"));
417
+ const maxN = existing.reduce((max, r) => {
418
+ const n = parseInt(r.id.split("-OPN-")[1] ?? "0", 10);
419
+ return Math.max(max, isNaN(n) ? 0 : n);
420
+ }, 0);
421
+ return `${prefix}-OPN-${String(maxN + 1).padStart(3, "0")}`;
422
+ }
423
+ // ---------------------------------------------------------------------------
424
+ // Tool: openings_scan
425
+ // ---------------------------------------------------------------------------
426
+ export const OpeningsScanTool = {
427
+ name: "openings_scan",
428
+ description: `Scans companies already in the user's job tracker (jobs.csv) to find their ACTUAL open roles with direct apply links.
429
+
430
+ This is the CORRECT way to find specific job postings — it uses the company's real ATS (Greenhouse, Ashby, Lever) to return verified, clickable apply URLs instead of hallucinating jobs.
431
+
432
+ Use this when the user asks:
433
+ - "What specific roles are open at my tracked companies?"
434
+ - "Find me specific jobs I can apply to"
435
+ - "Drill into openings at Vercel / LangChain / Supabase"
436
+ - "What's open at my Tier 1 companies?"
437
+ - "Show me real job postings with direct links"
438
+
439
+ The tool reads careers_url + ats from jobs.csv, fetches live postings,
440
+ scores them against the user's resume, and returns a ranked table.
441
+
442
+ DO NOT use search_jobs for this — search_jobs is for discovering new companies.
443
+ openings_scan is for drilling into companies the user has already vetted.`,
444
+ parameters: {
445
+ type: Type.OBJECT,
446
+ properties: {
447
+ companies: {
448
+ type: Type.ARRAY,
449
+ items: { type: Type.STRING },
450
+ description: "Optional. Company names to scan (e.g. ['Vercel', 'LangChain']). Default: all Tier 1 companies with status 'To Apply' or 'Applied'.",
451
+ },
452
+ tier: {
453
+ type: Type.NUMBER,
454
+ description: "Optional. Filter by tier (1, 2, or 3). Default: 1.",
455
+ },
456
+ role_filter: {
457
+ type: Type.STRING,
458
+ description: "Optional. Keyword to filter job titles (e.g. 'engineer', 'solutions'). Case-insensitive.",
459
+ },
460
+ min_score: {
461
+ type: Type.NUMBER,
462
+ description: "Optional. Minimum resume match score to include (0–100). Default: 40.",
463
+ },
464
+ save: {
465
+ type: Type.BOOLEAN,
466
+ description: "Optional. Save discovered openings to job_openings.csv. Default: true.",
467
+ },
468
+ max_companies: {
469
+ type: Type.NUMBER,
470
+ description: "Optional. Max number of companies to scan per call. Default: 10 (to avoid long waits).",
471
+ },
472
+ },
473
+ required: [],
474
+ },
475
+ execute: async (args) => {
476
+ try {
477
+ // Load companies from jobs.csv — use the same path-resolution as local-tracker.ts
478
+ const csvPath = getJobsCsvPath();
479
+ if (!existsSync(csvPath)) {
480
+ return "❌ No jobs.csv found. Add some companies to your tracker first with tracker_add_job.";
481
+ }
482
+ const raw = readFileSync(csvPath, "utf-8");
483
+ const lines = raw.trim().split("\n");
484
+ const headers = lines[0].split(",");
485
+ const allJobs = lines.slice(1).filter(l => l.trim()).map(line => {
486
+ const cols = parseCsvLine(line);
487
+ const row = {};
488
+ headers.forEach((h, i) => { row[h] = (cols[i] ?? "").trim(); });
489
+ return row;
490
+ });
491
+ // Filter companies to scan
492
+ let candidates = allJobs;
493
+ if (args.companies && args.companies.length > 0) {
494
+ const names = args.companies.map(c => c.toLowerCase());
495
+ candidates = allJobs.filter(j => names.some(n => j.company?.toLowerCase().includes(n)));
496
+ }
497
+ else {
498
+ // Default: Tier 1 (or specified tier) with non-terminal status
499
+ const targetTier = String(args.tier ?? 1);
500
+ const TERMINAL_STATUSES = ["Rejected", "Ghosted", "Withdrawn", "Closed"];
501
+ candidates = allJobs.filter(j => j.tier === targetTier &&
502
+ !TERMINAL_STATUSES.includes(j.status) &&
503
+ j.careers_url?.startsWith("http"));
504
+ }
505
+ const maxCompanies = Math.min(args.max_companies ?? 10, 20);
506
+ candidates = candidates.slice(0, maxCompanies);
507
+ if (candidates.length === 0) {
508
+ return "ℹ️ No matching companies found in your tracker. Add companies with tracker_add_job first, or broaden the filter.";
509
+ }
510
+ const minScore = args.min_score ?? 40;
511
+ const keywords = await getResumeKeywords();
512
+ const scanResults = [];
513
+ process.stderr.write(`[openings_scan] Scanning ${candidates.length} companies...\n`);
514
+ // Fetch from each ATS
515
+ for (const job of candidates) {
516
+ const { id, company, careers_url: careersUrl, ats } = job;
517
+ const slug = extractSlug(careersUrl, ats);
518
+ let postings = [];
519
+ let error;
520
+ try {
521
+ if (ats === "Greenhouse" && slug) {
522
+ postings = await fetchGreenhouseJobs(slug);
523
+ }
524
+ else if (ats === "Ashby" && slug) {
525
+ postings = await fetchAshbyJobs(slug);
526
+ }
527
+ else if (ats === "Lever" && slug) {
528
+ postings = await fetchLeverJobs(slug, careersUrl);
529
+ }
530
+ else {
531
+ // Direct or unknown ATS — try HTML parse
532
+ postings = await fetchDirectJobs(careersUrl);
533
+ }
534
+ }
535
+ catch (err) {
536
+ error = err.message?.slice(0, 100);
537
+ }
538
+ scanResults.push({ company, companyId: id, ats, postings, error });
539
+ process.stderr.write(` ${company}: ${postings.length} postings${error ? ` (⚠️ ${error})` : ""}\n`);
540
+ }
541
+ // Score + filter postings
542
+ const { rows: existingOpenings, path: openingsPath } = loadOpenings();
543
+ const newRows = [];
544
+ const reportSections = [];
545
+ for (const result of scanResults) {
546
+ const { company, companyId, ats, postings, error } = result;
547
+ if (error && postings.length === 0) {
548
+ reportSections.push(`\n⚠️ ${company} (${ats}): Could not fetch — ${error}`);
549
+ continue;
550
+ }
551
+ // Apply role filter
552
+ let filtered = postings;
553
+ if (args.role_filter) {
554
+ const kw = args.role_filter.toLowerCase();
555
+ filtered = postings.filter(p => p.title.toLowerCase().includes(kw));
556
+ }
557
+ // Score + sort
558
+ const scored = filtered
559
+ .map(p => ({ posting: p, ...scorePosting(keywords, p) }))
560
+ .filter(s => s.score >= minScore)
561
+ .sort((a, b) => b.score - a.score);
562
+ if (scored.length === 0) {
563
+ reportSections.push(`\n○ ${company} (${ats}) — ${postings.length} postings, none matched score ≥${minScore}%`);
564
+ continue;
565
+ }
566
+ const lines = [
567
+ `\n◆ ${company} (${ats}) — ${postings.length} open roles → ${scored.length} match above ${minScore}%`,
568
+ ];
569
+ for (const { posting, score, matched } of scored.slice(0, 8)) {
570
+ lines.push(` ★ [${score}%] ${posting.title}${posting.location ? ` [${posting.location}]` : ""}`, ` → ${posting.applyUrl}`);
571
+ if (matched.length > 0) {
572
+ lines.push(` ✓ Keywords: ${matched.slice(0, 5).join(", ")}`);
573
+ }
574
+ // Build new row for job_openings.csv
575
+ if (args.save !== false) {
576
+ const existing = existingOpenings.find(r => r.company_id === companyId && r.posting_title === posting.title);
577
+ if (!existing) {
578
+ const newId = generateOpeningId(companyId, [...existingOpenings, ...newRows]);
579
+ newRows.push({
580
+ id: newId,
581
+ company_id: companyId,
582
+ company,
583
+ posting_title: posting.title,
584
+ apply_url: posting.applyUrl,
585
+ ats,
586
+ location: posting.location ?? "",
587
+ match_score: String(score),
588
+ match_keywords: matched.slice(0, 10).join("; "),
589
+ status: "Found",
590
+ date_found: today(),
591
+ date_applied: "",
592
+ notes: "",
593
+ });
594
+ }
595
+ }
596
+ }
597
+ reportSections.push(lines.join("\n"));
598
+ }
599
+ // Persist new rows
600
+ let savedCount = 0;
601
+ if (args.save !== false && newRows.length > 0) {
602
+ const allRows = [...existingOpenings, ...newRows];
603
+ saveOpenings(allRows, openingsPath);
604
+ savedCount = newRows.length;
605
+ }
606
+ const totalFound = scanResults.reduce((s, r) => s + r.postings.length, 0);
607
+ const totalMatched = scanResults.reduce((s, r) => s + r.postings.filter(p => scorePosting(keywords, p).score >= minScore).length, 0);
608
+ return [
609
+ `🔍 Job Openings Scan — ${candidates.length} companies, ${totalFound} live postings, ${totalMatched} matches`,
610
+ "─".repeat(70),
611
+ ...reportSections,
612
+ "─".repeat(70),
613
+ savedCount > 0
614
+ ? `\n✅ Saved ${savedCount} new openings to job_openings.csv. Say "show my openings" to list them.`
615
+ : "\n(No new openings saved — all already tracked or save=false.)",
616
+ `\nTo apply: tell me which role interests you and I can help autofill the application.`,
617
+ ].join("\n");
618
+ }
619
+ catch (err) {
620
+ return `❌ openings_scan failed: ${err.message}`;
621
+ }
622
+ },
623
+ };
624
+ // ---------------------------------------------------------------------------
625
+ // Tool: openings_list
626
+ // ---------------------------------------------------------------------------
627
+ export const OpeningsListTool = {
628
+ name: "openings_list",
629
+ description: `List saved job openings from job_openings.csv.
630
+ Shows specific postings found by openings_scan, with match scores and direct apply URLs.
631
+ Use this to review what's been found and decide what to apply to.
632
+
633
+ Use when the user asks:
634
+ - "Show my job openings"
635
+ - "What openings have you found?"
636
+ - "List the jobs I should apply to"
637
+ - "What did the scan find?"`,
638
+ parameters: {
639
+ type: Type.OBJECT,
640
+ properties: {
641
+ status_filter: {
642
+ type: Type.STRING,
643
+ description: "Optional. Filter by status: Found | Reviewing | Applied | Rejected | Ghosted. Default: all.",
644
+ },
645
+ company_filter: {
646
+ type: Type.STRING,
647
+ description: "Optional. Filter by company name (partial match).",
648
+ },
649
+ min_score: {
650
+ type: Type.NUMBER,
651
+ description: "Optional. Minimum match score to show. Default: 0.",
652
+ },
653
+ sort_by: {
654
+ type: Type.STRING,
655
+ description: "Optional. Sort by: match_score | date_found | company. Default: match_score.",
656
+ },
657
+ },
658
+ required: [],
659
+ },
660
+ execute: async (args) => {
661
+ try {
662
+ const { rows } = loadOpenings();
663
+ if (rows.length === 0) {
664
+ return "ℹ️ No job openings saved yet. Run openings_scan to discover real job postings from your tracked companies.";
665
+ }
666
+ let filtered = rows;
667
+ if (args.status_filter) {
668
+ filtered = filtered.filter(r => r.status.toLowerCase() === args.status_filter.toLowerCase());
669
+ }
670
+ if (args.company_filter) {
671
+ const kw = args.company_filter.toLowerCase();
672
+ filtered = filtered.filter(r => r.company.toLowerCase().includes(kw));
673
+ }
674
+ if (args.min_score) {
675
+ filtered = filtered.filter(r => Number(r.match_score) >= args.min_score);
676
+ }
677
+ // Sort
678
+ const sortBy = args.sort_by ?? "match_score";
679
+ filtered.sort((a, b) => {
680
+ if (sortBy === "match_score")
681
+ return Number(b.match_score) - Number(a.match_score);
682
+ if (sortBy === "date_found")
683
+ return b.date_found.localeCompare(a.date_found);
684
+ if (sortBy === "company")
685
+ return a.company.localeCompare(b.company);
686
+ return 0;
687
+ });
688
+ const lines = [
689
+ `📋 Job Openings — ${filtered.length} of ${rows.length} total`,
690
+ "─".repeat(70),
691
+ ];
692
+ // Group by status
693
+ const byStatus = new Map();
694
+ for (const r of filtered) {
695
+ const s = r.status || "Found";
696
+ if (!byStatus.has(s))
697
+ byStatus.set(s, []);
698
+ byStatus.get(s).push(r);
699
+ }
700
+ const STATUS_ICONS = {
701
+ Found: "🔍", Reviewing: "📖", Applied: "✅", Rejected: "❌", Ghosted: "👻",
702
+ };
703
+ for (const [status, group] of byStatus) {
704
+ lines.push(`\n${STATUS_ICONS[status] ?? "○"} ${status} (${group.length})`);
705
+ for (const r of group) {
706
+ lines.push(` [${String(r.match_score).padStart(3)}%] ${r.company} — ${r.posting_title}`, ` 📍 ${r.location || "Location not specified"}`, ` 🔗 ${r.apply_url}`);
707
+ if (r.date_applied)
708
+ lines.push(` Applied: ${r.date_applied}`);
709
+ }
710
+ }
711
+ lines.push("─".repeat(70));
712
+ lines.push(`\nTo apply: say "mark [opening-id] as applied on [date]" or "help me apply to [company] [role]".`);
713
+ lines.push(`Opening IDs: ${filtered.slice(0, 5).map(r => r.id).join(", ")}${filtered.length > 5 ? ", ..." : ""}`);
714
+ return lines.join("\n");
715
+ }
716
+ catch (err) {
717
+ return `❌ openings_list failed: ${err.message}`;
718
+ }
719
+ },
720
+ };
721
+ // ---------------------------------------------------------------------------
722
+ // Tool: openings_apply
723
+ // ---------------------------------------------------------------------------
724
+ const OPENING_VALID_STATUSES = ["Found", "Reviewing", "Applied", "Rejected", "Ghosted"];
725
+ export const OpeningsApplyTool = {
726
+ name: "openings_apply",
727
+ description: `Mark a specific job opening in job_openings.csv as Applied (or update its status).
728
+ REQUIRES explicit date_applied when marking as Applied — never infer the date autonomously.
729
+
730
+ Use when the user says:
731
+ - "I applied to the Vercel Developer Success Engineer role today"
732
+ - "Mark VER-OPN-001 as applied on 2026-04-14"
733
+ - "Update the LangChain opening status to Reviewing"`,
734
+ parameters: {
735
+ type: Type.OBJECT,
736
+ properties: {
737
+ opening_id: {
738
+ type: Type.STRING,
739
+ description: "The opening ID from job_openings.csv (e.g. VER-OPN-001). Required.",
740
+ },
741
+ status: {
742
+ type: Type.STRING,
743
+ description: `New status: Found | Reviewing | Applied | Rejected | Ghosted.`,
744
+ },
745
+ date_applied: {
746
+ type: Type.STRING,
747
+ description: "ISO date of application (e.g. 2026-04-14). REQUIRED when status = Applied.",
748
+ },
749
+ notes: {
750
+ type: Type.STRING,
751
+ description: "Optional notes to append (not replace).",
752
+ },
753
+ },
754
+ required: ["opening_id"],
755
+ },
756
+ execute: async (args) => {
757
+ try {
758
+ const { rows, path } = loadOpenings();
759
+ const idx = rows.findIndex(r => r.id.toLowerCase() === args.opening_id.toLowerCase());
760
+ if (idx === -1) {
761
+ const ids = rows.slice(0, 10).map(r => r.id).join(", ");
762
+ return `❌ Opening ID "${args.opening_id}" not found.\nAvailable IDs: ${ids}`;
763
+ }
764
+ const row = { ...rows[idx] };
765
+ const changes = [];
766
+ if (args.status) {
767
+ if (!OPENING_VALID_STATUSES.includes(args.status)) {
768
+ return `❌ Invalid status "${args.status}". Valid: ${OPENING_VALID_STATUSES.join(", ")}`;
769
+ }
770
+ // Gate: Applied requires explicit date
771
+ if (args.status === "Applied" && !args.date_applied && row.status !== "Applied") {
772
+ return (`⚠️ CONFIRMATION REQUIRED\n` +
773
+ `To mark "${row.company} — ${row.posting_title}" as Applied, provide the date:\n` +
774
+ ` Example: "Yes, mark ${args.opening_id} as Applied on ${today()}"`);
775
+ }
776
+ changes.push(`status: ${row.status} → ${args.status}`);
777
+ row.status = args.status;
778
+ }
779
+ if (args.date_applied) {
780
+ row.date_applied = args.date_applied;
781
+ changes.push(`date_applied: ${args.date_applied}`);
782
+ }
783
+ else if (row.status === "Applied" && !row.date_applied) {
784
+ row.date_applied = today();
785
+ changes.push(`date_applied: ${today()} (auto)`);
786
+ }
787
+ if (args.notes) {
788
+ const existing = row.notes ? row.notes + "; " : "";
789
+ row.notes = (existing + args.notes).slice(0, 500);
790
+ changes.push(`notes: appended`);
791
+ }
792
+ rows[idx] = row;
793
+ saveOpenings(rows, path);
794
+ return [
795
+ `✅ Updated opening ${args.opening_id}:`,
796
+ ...changes.map(c => ` • ${c}`),
797
+ ``,
798
+ `${row.company} — ${row.posting_title}`,
799
+ `Status: ${row.status}${row.date_applied ? ` (applied ${row.date_applied})` : ""}`,
800
+ `🔗 ${row.apply_url}`,
801
+ ].join("\n");
802
+ }
803
+ catch (err) {
804
+ return `❌ openings_apply failed: ${err.message}`;
805
+ }
806
+ },
807
+ };
808
+ // ---------------------------------------------------------------------------
809
+ // Export
810
+ // ---------------------------------------------------------------------------
811
+ export const ALL_JOB_OPENINGS_TOOLS = [
812
+ OpeningsScanTool,
813
+ OpeningsListTool,
814
+ OpeningsApplyTool,
815
+ ];
@@ -1 +1 @@
1
- {"version":3,"file":"engineResolver.d.ts","sourceRoot":"","sources":["../../../src/commands/agent/engineResolver.ts"],"names":[],"mappings":"AACA,OAAO,EAAyC,KAAK,WAAW,EAAE,MAAM,iBAAiB,CAAC;AAC1F,OAAO,EAAE,WAAW,EAAE,MAAM,4BAA4B,CAAC;AACzD,OAAO,EAAE,sBAAsB,EAAE,MAAM,uCAAuC,CAAC;AAC/E,OAAO,EAAE,IAAI,EAAE,MAAM,qBAAqB,CAAC;AAI3C;;;;;;GAMG;AACH,wBAAgB,oBAAoB,CAAC,OAAO,EAAE;IAAE,IAAI,CAAC,EAAE,OAAO,CAAC;IAAC,MAAM,CAAC,EAAE,OAAO,CAAC;IAAC,MAAM,CAAC,EAAE,OAAO,CAAA;CAAE,GAAG,MAAM,CAE5G;AAED,wBAAgB,WAAW,CACzB,gBAAgB,EAAE,WAAW,EAC7B,aAAa,EAAE,MAAM,EACrB,iBAAiB,EAAE,MAAM,EACzB,KAAK,EAAE,IAAI,EAAE,EACb,cAAc,EAAE,MAAM,EACtB,eAAe,EAAE,OAAO,EACxB,QAAQ,EAAE,MAAM,GAAG,SAAS,EAC5B,YAAY,EAAE,MAAM,GAAG,SAAS,EAChC,OAAO,EAAE,MAAM,GAAG,SAAS,GAC1B,WAAW,GAAG,sBAAsB,GAAG,IAAI,CA2B7C;AAED,wBAAgB,WAAW,CACzB,OAAO,EAAE;IAAE,MAAM,CAAC,EAAE,OAAO,CAAC;IAAC,IAAI,CAAC,EAAE,OAAO,CAAC;IAAC,MAAM,CAAC,EAAE,OAAO,CAAC;IAAC,GAAG,CAAC,EAAE,OAAO,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAA;CAAE,EAC9F,gBAAgB,EAAE,MAAM,EACxB,aAAa,EAAE,MAAM,EACrB,cAAc,EAAE,MAAM,QAuBvB"}
1
+ {"version":3,"file":"engineResolver.d.ts","sourceRoot":"","sources":["../../../src/commands/agent/engineResolver.ts"],"names":[],"mappings":"AACA,OAAO,EAAyC,KAAK,WAAW,EAAE,MAAM,iBAAiB,CAAC;AAC1F,OAAO,EAAE,WAAW,EAAE,MAAM,4BAA4B,CAAC;AACzD,OAAO,EAAE,sBAAsB,EAAE,MAAM,uCAAuC,CAAC;AAC/E,OAAO,EAAE,IAAI,EAAE,MAAM,qBAAqB,CAAC;AAI3C;;;;;;GAMG;AACH,wBAAgB,oBAAoB,CAAC,OAAO,EAAE;IAAE,IAAI,CAAC,EAAE,OAAO,CAAC;IAAC,MAAM,CAAC,EAAE,OAAO,CAAC;IAAC,MAAM,CAAC,EAAE,OAAO,CAAA;CAAE,GAAG,MAAM,CAE5G;AAED,wBAAgB,WAAW,CACzB,gBAAgB,EAAE,WAAW,EAC7B,aAAa,EAAE,MAAM,EACrB,iBAAiB,EAAE,MAAM,EACzB,KAAK,EAAE,IAAI,EAAE,EACb,cAAc,EAAE,MAAM,EACtB,eAAe,EAAE,OAAO,EACxB,QAAQ,EAAE,MAAM,GAAG,SAAS,EAC5B,YAAY,EAAE,MAAM,GAAG,SAAS,EAChC,OAAO,EAAE,MAAM,GAAG,SAAS,GAC1B,WAAW,GAAG,sBAAsB,GAAG,IAAI,CA2B7C;AAED,wBAAgB,WAAW,CACzB,OAAO,EAAE;IAAE,MAAM,CAAC,EAAE,OAAO,CAAC;IAAC,IAAI,CAAC,EAAE,OAAO,CAAC;IAAC,MAAM,CAAC,EAAE,OAAO,CAAC;IAAC,GAAG,CAAC,EAAE,OAAO,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAA;CAAE,EAC9F,gBAAgB,EAAE,MAAM,EACxB,aAAa,EAAE,MAAM,EACrB,cAAc,EAAE,MAAM,QAwBvB"}
@@ -53,10 +53,11 @@ export function printBanner(options, selectedProvider, selectedModel, thinkingBu
53
53
  if (options.coding)
54
54
  console.log(chalk.green(" ✔ Coding mode: file I/O, shell, search tools active"));
55
55
  if (options.jobs) {
56
- console.log(chalk.cyan(" ✔ Job mode: search, save, list, status update, apply_to_job tools active"));
56
+ console.log(chalk.cyan(" ✔ Job mode: search, score, apply_to_job, openings_scan tools active"));
57
57
  console.log(chalk.magenta(" ✔ Browser mode: navigate, click, type, select, scroll, screenshot tools active"));
58
- console.log(chalk.yellow(" ✔ Local tracker: list_local_jobs · update_local_job · add_local_job"));
59
- console.log(chalk.yellow(" + score_pipeline · get_pipeline_metrics · flag_stale_jobs (jobs.csv v2)"));
58
+ console.log(chalk.yellow(" ✔ Local tracker: tracker_list_jobs · tracker_update_job · tracker_add_job"));
59
+ console.log(chalk.yellow(" + tracker_rank_priority · tracker_dashboard · tracker_find_stale"));
60
+ console.log(chalk.yellow(" + tracker_recheck_urls · openings_scan · openings_list (jobs.csv v2)"));
60
61
  }
61
62
  else if (options.resume)
62
63
  console.log(chalk.cyan(" ✔ Resume mode: get_resume tool active"));
@@ -1 +1 @@
1
- {"version":3,"file":"repl.d.ts","sourceRoot":"","sources":["../../../src/commands/agent/repl.ts"],"names":[],"mappings":"AAIA,OAAO,EAAE,sBAAsB,EAAE,MAAM,uCAAuC,CAAC;AAC/E,OAAO,EAAE,WAAW,EAAE,MAAM,4BAA4B,CAAC;AAEzD,OAAO,EAA4D,KAAK,WAAW,EAAE,MAAM,iBAAiB,CAAC;AAK7G,wBAAgB,iBAAiB,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,EAAE,KAAK,GAAE,MAAM,GAAG,IAAW,QAwBtF;AAED,wBAAsB,OAAO,CAC3B,MAAM,EAAE,WAAW,GAAG,sBAAsB,GAAG,IAAI,EACnD,OAAO,EAAE;IAAE,OAAO,CAAC,EAAE,OAAO,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,UAAU,CAAC,EAAE,MAAM,CAAC;IAAC,OAAO,CAAC,EAAE,MAAM,CAAC;IAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IAAC,IAAI,CAAC,EAAE,OAAO,CAAC;IAAC,MAAM,CAAC,EAAE,OAAO,CAAC;IAAC,MAAM,CAAC,EAAE,OAAO,CAAA;CAAE,EAC9K,gBAAgB,EAAE,WAAW,EAC7B,aAAa,EAAE,MAAM,EACrB,QAAQ,EAAE,MAAM,GAAG,SAAS,EAC5B,iBAAiB,EAAE,MAAM,EACzB,KAAK,EAAE,GAAG,EAAE,GACX,OAAO,CAAC,IAAI,CAAC,CAmkBf"}
1
+ {"version":3,"file":"repl.d.ts","sourceRoot":"","sources":["../../../src/commands/agent/repl.ts"],"names":[],"mappings":"AAIA,OAAO,EAAE,sBAAsB,EAAE,MAAM,uCAAuC,CAAC;AAC/E,OAAO,EAAE,WAAW,EAAE,MAAM,4BAA4B,CAAC;AAEzD,OAAO,EAA4D,KAAK,WAAW,EAAE,MAAM,iBAAiB,CAAC;AAK7G,wBAAgB,iBAAiB,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,EAAE,KAAK,GAAE,MAAM,GAAG,IAAW,QAwBtF;AAED,wBAAsB,OAAO,CAC3B,MAAM,EAAE,WAAW,GAAG,sBAAsB,GAAG,IAAI,EACnD,OAAO,EAAE;IAAE,OAAO,CAAC,EAAE,OAAO,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,UAAU,CAAC,EAAE,MAAM,CAAC;IAAC,OAAO,CAAC,EAAE,MAAM,CAAC;IAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IAAC,IAAI,CAAC,EAAE,OAAO,CAAC;IAAC,MAAM,CAAC,EAAE,OAAO,CAAC;IAAC,MAAM,CAAC,EAAE,OAAO,CAAA;CAAE,EAC9K,gBAAgB,EAAE,WAAW,EAC7B,aAAa,EAAE,MAAM,EACrB,QAAQ,EAAE,MAAM,GAAG,SAAS,EAC5B,iBAAiB,EAAE,MAAM,EACzB,KAAK,EAAE,GAAG,EAAE,GACX,OAAO,CAAC,IAAI,CAAC,CAskBf"}
@@ -34,7 +34,7 @@ export async function askLoop(engine, options, selectedProvider, selectedModel,
34
34
  const WRITE_TOOLS = new Set([
35
35
  "tracker_add_job", "tracker_update_job", "kanban_add_job", "kanban_update_status",
36
36
  "save_cover_letter", "delete_cover_letter", "write_file", "patch_file",
37
- "tracker_recheck_urls",
37
+ "tracker_recheck_urls", "openings_apply",
38
38
  ]);
39
39
  const SESSION_MAX_MUTATIONS = 25;
40
40
  const TURN_MAX_MUTATIONS = 10;
@@ -247,6 +247,9 @@ export async function askLoop(engine, options, selectedProvider, selectedModel,
247
247
  verify_url: "🔍 Verifying URL...",
248
248
  verify_job_urls: "🔍 Verifying job URLs...",
249
249
  search_jobs: "🔍 Searching jobs...",
250
+ openings_scan: "🎯 Scanning companies for open roles...",
251
+ openings_list: "📋 Loading saved openings...",
252
+ openings_apply: "✅ Marking opening as applied...",
250
253
  get_resume: "📄 Loading resume...",
251
254
  list_resumes: "📄 Loading resumes...",
252
255
  get_profile: "👤 Loading profile...",
@@ -1 +1 @@
1
- {"version":3,"file":"toolRegistry.d.ts","sourceRoot":"","sources":["../../../src/commands/agent/toolRegistry.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,IAAI,EAAE,MAAM,qBAAqB,CAAC;AAuO3C,wBAAgB,QAAQ,CAAC,OAAO,EAAE;IAAE,IAAI,CAAC,EAAE,OAAO,CAAC;IAAC,MAAM,CAAC,EAAE,OAAO,CAAC;IAAC,MAAM,CAAC,EAAE,OAAO,CAAA;CAAE,GAAG,IAAI,EAAE,CA2ChG"}
1
+ {"version":3,"file":"toolRegistry.d.ts","sourceRoot":"","sources":["../../../src/commands/agent/toolRegistry.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,IAAI,EAAE,MAAM,qBAAqB,CAAC;AAwO3C,wBAAgB,QAAQ,CAAC,OAAO,EAAE;IAAE,IAAI,CAAC,EAAE,OAAO,CAAC;IAAC,MAAM,CAAC,EAAE,OAAO,CAAC;IAAC,MAAM,CAAC,EAAE,OAAO,CAAA;CAAE,GAAG,IAAI,EAAE,CA8ChG"}
@@ -6,6 +6,7 @@ import { ALL_LOCAL_TRACKER_TOOLS } from "../../agent/tools/local-tracker.js";
6
6
  import { ALL_URL_VERIFIER_TOOLS } from "../../agent/tools/urlVerifier.js";
7
7
  import { ALL_PORTFOLIO_TOOLS } from "../../agent/tools/portfolio.js";
8
8
  import { ALL_COVERLETTER_TOOLS } from "../../agent/tools/coverLetter.js";
9
+ import { ALL_JOB_OPENINGS_TOOLS } from "../../agent/tools/jobOpenings.js";
9
10
  import { publishSingleFile } from "../publish.js";
10
11
  // ── Publish tools ─────────────────────────────────────────────────────────────
11
12
  const PublishArticleTool = {
@@ -237,6 +238,10 @@ export function getTools(options) {
237
238
  if (!tools.find((x) => x.name === t.name))
238
239
  tools.push(t);
239
240
  }
241
+ for (const t of ALL_JOB_OPENINGS_TOOLS) {
242
+ if (!tools.find((x) => x.name === t.name))
243
+ tools.push(t);
244
+ }
240
245
  return tools;
241
246
  }
242
247
  if (options.resume) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "careervivid",
3
- "version": "1.12.36",
3
+ "version": "1.12.39",
4
4
  "description": "Official CLI for CareerVivid — publish articles, diagrams, and portfolio updates from your terminal or AI agent",
5
5
  "type": "module",
6
6
  "bin": {