@timurtekb/tekjobs 0.33.0 → 0.35.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.
@@ -8,8 +8,8 @@
8
8
  <link rel="icon" href="/brand/tekjobs-avatar-192.png" type="image/png" sizes="192x192" />
9
9
  <link rel="apple-touch-icon" href="/brand/tekjobs-avatar-192.png" />
10
10
  <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:wght@500;600&family=Hanken+Grotesk:wght@400;500;600&family=Azeret+Mono:wght@400;500&display=swap" data-zengin="fonts" />
11
- <script type="module" crossorigin src="/assets/index-COWTxQAI.js"></script>
12
- <link rel="stylesheet" crossorigin href="/assets/index-CSrneQ3x.css">
11
+ <script type="module" crossorigin src="/assets/index-CgarU0W5.js"></script>
12
+ <link rel="stylesheet" crossorigin href="/assets/index-CR3ppGJT.css">
13
13
  </head>
14
14
  <body>
15
15
  <div id="root"></div>
@@ -69,6 +69,8 @@ const routes = [
69
69
  ['GET', /^\/api\/profile\/summary$/, () => store.profileSummary()],
70
70
  ['GET', /^\/api\/companies$/, () => store.companies()],
71
71
  ['GET', /^\/api\/feeds$/, () => store.feeds()],
72
+ ['GET', /^\/api\/companies\/find$/, (_, q) => store.findBoard({ slug: q.get('slug') || '', ats: q.get('ats') || '', name: q.get('name') || '' })],
73
+ ['POST', /^\/api\/companies\/move$/, async (_, __, req) => { const b = await readBody(req); return store.moveBoard({ name: b.name || '', slug: b.slug, from: b.from || '', to: b.to }); }],
72
74
  ['POST', /^\/api\/companies$/, async (_, __, req) => store.addCompany(await readBody(req))],
73
75
  ['POST', /^\/api\/jobs\/import$/, async (_, __, req) => { const b = await readBody(req); return store.importLinks(b.urls ?? b.url); }],
74
76
  ['POST', /^\/api\/jobs\/([^/]+)\/reveal$/, (m) => store.revealJob(decodeURIComponent(m[1]))],
@@ -43,14 +43,14 @@ export function prompt({ sinceDays = 21, companies = [] } = {}) {
43
43
  Run these searches, each with newer_than:${sinceDays}d appended, then stop:
44
44
  ${[...SEARCHES, companySearch(companies)].filter(Boolean).map((s, i) => `${i + 1}. ${s} newer_than:${sinceDays}d`).join('\n')}
45
45
 
46
- Search results already carry subject, sender, date and a snippet. Decide from those. Read a thread when the sender is a LinkedIn relay and the person's name is not in the search result, and otherwise only when the subject and snippet do not say whether the message is a confirmation of receipt, a rejection, or an advance (an interview, a screen, a scheduling request). Do not read threads that are plainly confirmations.
46
+ Search results already carry subject, sender, date and a snippet. Decide from those. Read a thread when the sender is a LinkedIn relay and the person's name is not in the search result, when a confirmation or rejection names no role or the person has several applications at that company (to find the posting link or the requisition id), and otherwise only when the subject and snippet do not say whether the message is a confirmation of receipt, a rejection, or an advance (an interview, a screen, a scheduling request). Do not read threads that are plainly confirmations.
47
47
 
48
48
  Report every message that is about one of the person's job applications: confirmations of receipt, rejections, invitations to interview or to schedule, requests to complete or add to an application. Also report a recruiter or hiring manager writing to the person about a specific role at a named company (kind "outreach"; company is the hiring company, not the agency; role is the role they name; fromName is the human's name even when the address is a LinkedIn relay). Ignore mass job alerts, newsletters, "I want to connect" notes that name no role, and anything not tied to an application or to a specific role.${companies.length ? `
49
49
 
50
50
  The person's own records show applications at these companies (others may exist): ${companies.join(', ')}.` : ''}
51
51
 
52
52
  Output ONLY a JSON array, no prose, no code fence, one object per message:
53
- {"company": "company name as the email gives it", "role": "role title if stated, else empty string", "kind": "confirmation|rejection|advance|scheduling|info-request|outreach|other", "date": "YYYY-MM-DD", "gist": "one plain sentence", "from": "the sender exactly as the header shows it, Display Name <address>", "fromName": "the human who wrote, from the sender display name or the signature; empty only when no human is named. For a LinkedIn relay address (inmail-hit-reply@linkedin.com, hit-reply@linkedin.com) the display name is the person, so read the message if the search result does not show it; required for outreach", "messageId": "gmail message id", "subject": "subject line"}
53
+ {"company": "company name as the email gives it", "role": "role title if stated, else empty string", "kind": "confirmation|rejection|advance|scheduling|info-request|outreach|other", "date": "YYYY-MM-DD", "gist": "one plain sentence", "from": "the sender exactly as the header shows it, Display Name <address>", "fromName": "the human who wrote, from the sender display name or the signature; empty only when no human is named. For a LinkedIn relay address (inmail-hit-reply@linkedin.com, hit-reply@linkedin.com) the display name is the person, so read the message if the search result does not show it; required for outreach", "postingUrl": "a link in the email to the job posting or to the application itself, if there is one, else empty string", "reqId": "a requisition or job id the email quotes (R-12345, JR361297, Job ID 10502343), else empty string", "messageId": "gmail message id", "subject": "subject line"}
54
54
 
55
55
  If nothing matches, output [].`;
56
56
  }
@@ -65,6 +65,7 @@ export function parseOutput(text) {
65
65
  return arr.filter((m) => m && typeof m === 'object' && m.company && m.messageId).map((m) => ({
66
66
  company: String(m.company).trim(), role: String(m.role || '').trim(), kind: KINDS.includes(m.kind) ? m.kind : 'other',
67
67
  date: /^\d{4}-\d{2}-\d{2}$/.test(String(m.date || '')) ? m.date : '', gist: String(m.gist || '').trim().slice(0, 300),
68
+ postingUrl: /^https?:\/\//i.test(String(m.postingUrl || '').trim()) ? String(m.postingUrl).trim().slice(0, 500) : '', reqId: String(m.reqId || '').trim().slice(0, 60),
68
69
  from: String(m.from || '').trim(), fromName: String(m.fromName || '').trim().slice(0, 80), messageId: String(m.messageId).trim(), subject: String(m.subject || '').trim().slice(0, 200),
69
70
  }));
70
71
  }
@@ -88,6 +89,21 @@ export function suggestionFor(kind, noteStatus, date) {
88
89
  * Match mail entries to notes: same company by name, the exact role when the title matches, otherwise the
89
90
  * note that is furthest along, with the other candidates listed so a person can pick. Pure.
90
91
  */
92
+ const cleanUrl = (u) => String(u || '').toLowerCase().replace(/[?#].*$/, '').replace(/\/+$/, '');
93
+ /** The posting's own id inside a URL: a long number (Greenhouse, Lever ids) or a UUID (Ashby). */
94
+ const postingIdOf = (u) => { const c = cleanUrl(u); const m = c.match(/\/(\d{6,})(?:\/|$)/) || c.match(/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/); return m ? m[1] : ''; };
95
+ /** The note an email points at by its posting link or requisition id, when it carries one. Beats any title guess. */
96
+ export function linkMatch(m, notes) {
97
+ const url = cleanUrl(m.postingUrl), pid = postingIdOf(m.postingUrl), req = String(m.reqId || '').trim().toLowerCase();
98
+ if (!url && !req) return null;
99
+ return notes.find((n) => {
100
+ const nu = cleanUrl(n.url), nj = String(n.jobId || '').toLowerCase();
101
+ if (url && nu && (nu === url || (pid && (nu.includes(pid) || nj.includes(pid))))) return true;
102
+ if (req && req.length >= 4 && (nj.endsWith(':' + req) || nu.includes(req) || String(n.title || '').toLowerCase().includes(req))) return true;
103
+ return false;
104
+ }) || null;
105
+ }
106
+
91
107
  export function reconcile(mails, notes) {
92
108
  const byCompany = new Map();
93
109
  for (const n of notes) { const k = norm(n.company); if (!k) continue; if (!byCompany.has(k)) byCompany.set(k, []); byCompany.get(k).push(n); }
@@ -96,16 +112,18 @@ export function reconcile(mails, notes) {
96
112
  const key = norm(m.company);
97
113
  let cands = byCompany.get(key) || [];
98
114
  if (!cands.length && key) cands = [...byCompany.entries()].filter(([k]) => k.startsWith(key + ' ') || key.startsWith(k + ' ')).flatMap(([, v]) => v);
99
- const exact = m.role ? cands.find((n) => norm(n.title) === norm(m.role)) : null;
115
+ const linked = linkMatch(m, notes);
116
+ const exact = linked || (m.role ? cands.find((n) => norm(n.title) === norm(m.role)) : null);
100
117
  const byRank = [...cands].sort((a, b) => rank(b) - rank(a) || (b.score || 0) - (a.score || 0));
101
118
  // An email that names a role the vault does not have is a different application, not the note that
102
119
  // happens to share the company: default to a new note and offer the company's notes as a pick. (Learned
103
120
  // on the first real run, when a Staff Product Designer confirmation landed on an Engineering Manager note.)
104
121
  const otherRole = !exact && m.role && cands.length > 0;
122
+ const via = linked ? 'link' : exact ? 'title' : '';
105
123
  const best = exact || (otherRole ? null : byRank[0] || null);
106
124
  const match = exact ? 'exact' : otherRole ? 'company-other-role' : best ? 'company' : 'none';
107
125
  return {
108
- id: m.messageId, ...m, match, link: gmailLink(m.messageId),
126
+ id: m.messageId, ...m, match, via, link: gmailLink(m.messageId),
109
127
  noteId: best ? best.id : '', noteTitle: best ? best.title : '', noteStatus: best ? best.status : '',
110
128
  candidates: byRank.slice(0, 6).map((n) => ({ id: n.id, title: n.title, status: n.status })),
111
129
  suggestion: suggestionFor(m.kind, best ? best.status : '', m.date),
@@ -187,7 +205,7 @@ function appliedOnEmpty(id) {
187
205
  * Do what the group's strongest email suggests, to the note the person chose (or the matched one), record
188
206
  * every email in the group on that note, and mark them all confirmed.
189
207
  */
190
- export function confirm(id, { noteId } = {}) {
208
+ export async function confirm(id, { noteId } = {}) {
191
209
  const d = load();
192
210
  const group = groupItems(d.items || []).find((g) => g.ids.includes(id));
193
211
  const item = (d.items || []).find((i) => i.id === (group ? group.id : id));
@@ -197,10 +215,14 @@ export function confirm(id, { noteId } = {}) {
197
215
  const appliedOn = members.filter((m) => m.kind === 'confirmation' && m.date).map((m) => m.date).sort()[0] || item.date;
198
216
  let target = noteId || item.noteId;
199
217
  const s = target ? suggestionFor(item.kind, store.getJob(target).status, appliedOn) : suggestionFor(item.kind, '', appliedOn);
218
+ if (!target && s.action === 'create' && item.postingUrl) {
219
+ // The email links the posting: the note comes from the posting, scored and described, not from the email.
220
+ try { const r = (await store.importLinks([item.postingUrl]))[0]; if (r?.ok && r.id) target = r.id; } catch { /* the stub below */ }
221
+ }
200
222
  if (!target) {
201
223
  if (s.action !== 'create') throw Object.assign(new Error('This entry matched no note; pick one or create one.'), { status: 400 });
202
224
  const criteria = loadCriteria();
203
- const job = { id: `mail:${item.messageId}`, source: 'mail', company: item.company, title: item.role || 'Role not stated in the email', url: gmailLink(item.messageId), location: '', remote: false, posted: item.date || null, salary: '', department: '', employmentType: '',
225
+ const job = { id: `mail:${item.messageId}`, source: 'mail', company: item.company, title: item.role || 'Role not stated in the email', url: item.postingUrl || gmailLink(item.messageId), location: '', remote: false, posted: item.date || null, salary: '', department: '', employmentType: '',
204
226
  descriptionHtml: `<p>Created from an application email (${item.kind}, ${item.date || 'undated'}): ${item.gist}</p><p>Subject: ${item.subject}</p><p>From: ${item.from}</p>`, foundVia: 'created from an application email', addedBy: 'mail' };
205
227
  job.descriptionText = htmlToText(job.descriptionHtml);
206
228
  const file = writeJobNote(job, scoreJob(job, criteria), criteria);
@@ -226,9 +248,9 @@ export function confirm(id, { noteId } = {}) {
226
248
  export function safeGroups() {
227
249
  return groupItems(load().items || []).filter((g) => g.kind === 'confirmation' && g.match === 'exact' && g.noteId);
228
250
  }
229
- export function confirmSafe() {
251
+ export async function confirmSafe() {
230
252
  const done = [];
231
- for (const g of safeGroups()) { try { confirm(g.id); done.push(g.id); } catch { /* the next group still gets its turn */ } }
253
+ for (const g of safeGroups()) { try { await confirm(g.id); done.push(g.id); } catch { /* the next group still gets its turn */ } }
232
254
  return { confirmed: done.length, ...items() };
233
255
  }
234
256
  export function dismiss(id) {
@@ -55,6 +55,7 @@ export const TOOLS = [
55
55
  { name: 'import_resume', description: 'Import a resume file from a local path (PDF, DOCX, Markdown, text): copies it into Profile/ and extracts its text for the interview.', inputSchema: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] } },
56
56
  { name: 'add_job', description: 'Add postings the person found themselves, from links (LinkedIn job pages, a company careers page, a Greenhouse/Lever/Ashby posting). Each link is read once, scored with the current criteria, and written as a job note unless the scan already has it or a note with the same company and title exists. A LinkedIn link is opened as a signed-out visitor; if it names the company\'s own apply page on a known board, that page is imported and the LinkedIn link is recorded as where it was seen. Returns one result per link. Up to 25 links.', inputSchema: { type: 'object', properties: { urls: { type: 'array', items: { type: 'string' } } }, required: ['urls'] } },
57
57
  { name: 'attach_posting', description: 'Attach the real posting to a note that was created without one (from an application email or a pasted link with thin data). Reads the posting through its board when the link is Greenhouse/Lever/Ashby (including company pages with ?gh_jid= or ?ashby_jid=), replaces the note\'s posting facts, score, reasons and description, and keeps the status, notes, packet and drafts. The note name does not change.', inputSchema: { type: 'object', properties: { id: { type: 'string' }, url: { type: 'string' } }, required: ['id', 'url'] } },
58
+ { name: 'find_board', description: 'A board on the watchlist answers 404: ask every platform the scan can read for the same slug and report which answer with jobs, best first. With apply=true, move the watchlist row to the best one (its status clears and the next scan reads it there). Companies usually keep their slug when they change tracker; when nothing answers, the slug changed too and the careers page says the new one.', inputSchema: { type: 'object', properties: { slug: { type: 'string' }, ats: { type: 'string', description: 'the platform that failed, left out of the probe' }, name: { type: 'string' }, apply: { type: 'boolean' } }, required: ['slug'] } },
58
59
  { name: 'add_company', description: 'Append a board to the watchlist. ats is the platform (greenhouse, lever, ashby, workday, rippling, smartrecruiters, workable, bamboohr, breezy, personio, teamtailor, eightfold, atlassian, github, spotify, amazon); slug is the board token.', inputSchema: { type: 'object', properties: { name: { type: 'string' }, ats: { type: 'string' }, slug: { type: 'string' }, tier: { type: 'string' }, notes: { type: 'string' } }, required: ['name', 'ats', 'slug'] } },
59
60
  ];
60
61
 
@@ -93,6 +94,7 @@ export async function call(name, a = {}) {
93
94
  case 'set_criteria': return store.setCriteria(a.raw);
94
95
  case 'list_companies': return store.companies();
95
96
  case 'list_feeds': return store.feeds();
97
+ case 'find_board': { const r = await store.findBoard(a); if (a.apply && r.found[0]) return { ...r, moved: await store.moveBoard({ name: a.name || '', slug: a.slug, from: a.ats || '', to: r.found[0].ats }) }; return r; }
96
98
  case 'add_company': return store.addCompany(a);
97
99
  case 'add_job': return store.importLinks(a.urls);
98
100
  case 'attach_posting': return store.attachPosting(a.id, a.url);
@@ -969,6 +969,18 @@ export async function importLinks(urls) {
969
969
  return out;
970
970
  }
971
971
 
972
+ /** Where a board went: every platform that answers for the slug, the failed one left out. */
973
+ export async function findBoard({ slug, ats = '', name = '' }) {
974
+ if (!slug) throw Object.assign(new Error('slug is required'), { status: 400 });
975
+ const { probeBoard } = await import('../../scraper/find-board.mjs');
976
+ return probeBoard(slug, { name: name || slug, except: ats });
977
+ }
978
+ /** Move a watchlist row to the platform that answers; the next scan reads it there. */
979
+ export async function moveBoard({ name = '', slug, from = '', to }) {
980
+ const { moveCompany } = await import('../../scraper/find-board.mjs');
981
+ const r = moveCompany({ name, slug, from, to });
982
+ return { ...r, companies: companies() };
983
+ }
972
984
  export function addCompany({ name, ats, slug, tier = 'B', notes = '' }) {
973
985
  if (!name || !ats || !slug) throw Object.assign(new Error('name, ats and slug are required'), { status: 400 });
974
986
  if (loadCompanies().some((c) => c.ats === ats.toLowerCase() && c.slug === slug)) throw Object.assign(new Error('that board is already in the table'), { status: 409 });
package/cli.mjs CHANGED
@@ -41,6 +41,7 @@ const HELP = `tekjobs — a local job-search machine
41
41
  tekjobs up [--dev] [--port N] start the API (and with --dev, Vite) detached: they stay up until \`down\`
42
42
  tekjobs down stop what \`up\` started
43
43
  tekjobs ps what \`up\` has running
44
+ tekjobs board <slug> [--from ats] [--move] where a failed board answers now; --move rewrites the watchlist row
44
45
  tekjobs serve the app + API on http://127.0.0.1:8787, in this terminal
45
46
  tekjobs mcp the MCP server on stdio (Claude Code, Codex, Cursor, Claude Desktop)
46
47
 
@@ -171,6 +172,15 @@ async function main() {
171
172
  if (!s.dry) console.log(` Index: ${s.indexPath}. Job notes now show who you know at each company.`);
172
173
  return;
173
174
  }
175
+ if (cmd === 'board') {
176
+ const slug = rest.find((x) => !x.startsWith('--') && x !== opt('--from'));
177
+ if (!slug) return console.error('Usage: tekjobs board <slug> [--from <ats that failed>] [--move]');
178
+ const { probeBoard, moveCompany } = await import('./scraper/find-board.mjs');
179
+ const r = await probeBoard(slug, { except: opt('--from') || '' });
180
+ console.log(r.note);
181
+ if (rest.includes('--move') && r.found[0]) { const m = moveCompany({ slug, from: opt('--from') || '', to: r.found[0].ats }); console.log(m.moved ? `Moved ${m.row.name} to ${m.row.ats}; the next scan reads it there.` : `${m.row.name} already reads from ${m.row.ats}.`); }
182
+ return;
183
+ }
174
184
  if (cmd === 'up') return up();
175
185
  if (cmd === 'down') return down();
176
186
  if (cmd === 'ps') return ps();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timurtekb/tekjobs",
3
- "version": "0.33.0",
3
+ "version": "0.35.0",
4
4
  "description": "A local-first job-search machine: watches hundreds of company job boards, scores every posting against your profile, and files matches into a folder of markdown notes you own. Bring your own LLM through MCP.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -0,0 +1,51 @@
1
+ // Where did a board go? A company that answers 404 on the platform the watchlist names has usually moved its
2
+ // careers page to another tracker, often keeping the same slug (Amplitude, Hightouch and Outschool all went
3
+ // from Greenhouse to Ashby). This asks every platform the scan can read, with the same slug, and reports the
4
+ // ones that answer with jobs; `moveCompany` rewrites the row when the person says so.
5
+ import fs from 'node:fs';
6
+ import { P, loadCompanies } from './config.mjs';
7
+ import { fetchCompany } from './sources.mjs';
8
+
9
+ /** Platforms a slug alone identifies. Workday needs host/tenant/site; the career APIs are not per-company boards. */
10
+ export const PROBE_ATS = ['greenhouse', 'ashby', 'lever', 'workable', 'smartrecruiters', 'rippling', 'bamboohr', 'breezy', 'personio', 'teamtailor', 'eightfold'];
11
+ const ANY_TITLE = { titleTerms: {}, titleExclude: [] };
12
+
13
+ /**
14
+ * Every platform that answers for this slug, best first (most jobs). `except` is the platform that already
15
+ * failed, left out. `fetch` is the board fetcher, injectable so a test needs no network.
16
+ */
17
+ export async function probeBoard(slug, { name = slug, except = '', fetch = fetchCompany, platforms = PROBE_ATS } = {}) {
18
+ const tried = platforms.filter((p) => p !== except);
19
+ const results = await Promise.all(tried.map(async (ats) => {
20
+ try {
21
+ const r = await fetch({ name, ats, slug }, ANY_TITLE);
22
+ return { ats, ok: !!r.ok, jobs: r.ok ? (r.jobs || []).length : 0, error: r.ok ? '' : String(r.error || '') };
23
+ } catch (e) { return { ats, ok: false, jobs: 0, error: e.message }; }
24
+ }));
25
+ const found = results.filter((r) => r.ok && r.jobs > 0).sort((a, b) => b.jobs - a.jobs);
26
+ return { slug, except, tried, found, note: found.length ? `Answers on ${found.map((f) => `${f.ats} (${f.jobs} job${f.jobs === 1 ? '' : 's'})`).join(', ')}.` : `No platform answers for "${slug}". The company may have renamed its board; look at its careers page for the new slug.` };
27
+ }
28
+
29
+ /** Rewrite one watchlist row's platform in place; the status cell is cleared for the scan, the move is noted. */
30
+ export function moveCompany({ name, slug, from, to }, { today = new Date().toISOString().slice(0, 10) } = {}) {
31
+ if (!slug || !to) throw Object.assign(new Error('slug and to are required'), { status: 400 });
32
+ const row = loadCompanies().find((c) => c.slug === slug && (!from || c.ats === from) && (!name || c.name === name));
33
+ if (!row) throw Object.assign(new Error(`No row for ${from ? from + ':' : ''}${slug} on the watchlist`), { status: 404 });
34
+ if (row.ats === to) return { moved: false, row };
35
+ const md = fs.readFileSync(P.companies, 'utf8');
36
+ let moved = false;
37
+ const out = md.split(/\r?\n/).map((line) => {
38
+ if (moved || !line.trim().startsWith('|')) return line;
39
+ const cells = line.split('|').slice(1, -1).map((c) => c.trim());
40
+ if (cells.length < 3 || cells[0] !== row.name || cells[1].toLowerCase() !== row.ats || cells[2] !== row.slug) return line;
41
+ while (cells.length < 6) cells.push('');
42
+ cells[1] = to;
43
+ cells[4] = '';
44
+ cells[5] = [cells[5], `moved from ${row.ats} ${today}`].filter(Boolean).join('; ');
45
+ moved = true;
46
+ return `| ${cells.join(' | ')} |`;
47
+ });
48
+ if (!moved) throw Object.assign(new Error(`Could not find the row for ${row.name} in ${P.companies}`), { status: 500 });
49
+ fs.writeFileSync(P.companies, out.join('\n'));
50
+ return { moved: true, row: { ...row, ats: to, status: '', notes: [row.notes, `moved from ${row.ats} ${today}`].filter(Boolean).join('; ') } };
51
+ }
@@ -105,7 +105,7 @@
105
105
  | DoorDash | greenhouse | doordashusa | C | | |
106
106
  | Lyft | greenhouse | lyft | C | | |
107
107
  | Roblox | greenhouse | roblox | C | | |
108
- | Amplitude | greenhouse | amplitude | C | | |
108
+ | Amplitude | ashby | amplitude | C | | moved to ashby 2026-10-07 |
109
109
  | Mixpanel | greenhouse | mixpanel | C | | |
110
110
  | Lattice | greenhouse | lattice | C | | |
111
111
  | Carta | greenhouse | carta | C | | |
@@ -197,7 +197,7 @@
197
197
  | GitLab | greenhouse | gitlab | A | | |
198
198
  | Headway | ashby | headway | B | | |
199
199
  | Hex | ashby | hex | A | | |
200
- | Hightouch | greenhouse | hightouch | B | | |
200
+ | Hightouch | ashby | hightouch | B | | moved to ashby 2026-10-07 |
201
201
  | Honeycomb | greenhouse | honeycomb | B | | |
202
202
  | Included Health | lever | includedhealth | B | | |
203
203
  | Inngest | ashby | inngest | B | | |
@@ -224,7 +224,7 @@
224
224
  | Orum | ashby | orum | B | | |
225
225
  | Oscar | greenhouse | oscar | B | | |
226
226
  | Otter | greenhouse | otter | B | | |
227
- | Outschool | greenhouse | outschool | B | | |
227
+ | Outschool | ashby | outschool | B | | moved to ashby 2026-10-07 |
228
228
  | Parloa | greenhouse | parloa | B | | |
229
229
  | Payoneer | greenhouse | payoneer | B | | |
230
230
  | Peloton | greenhouse | peloton | B | | |