job-forge 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 (79) hide show
  1. package/.codex/config.toml +8 -0
  2. package/.cursor/mcp.json +21 -0
  3. package/.cursor/rules/main.mdc +519 -0
  4. package/.mcp.json +21 -0
  5. package/.opencode/agents/general-free.md +85 -0
  6. package/.opencode/agents/general-paid.md +39 -0
  7. package/.opencode/agents/glm-minimal.md +50 -0
  8. package/.opencode/skills/job-forge.md +185 -0
  9. package/AGENTS.md +514 -0
  10. package/CLAUDE.md +514 -0
  11. package/LICENSE +21 -0
  12. package/README.md +195 -0
  13. package/batch/README.md +60 -0
  14. package/batch/batch-prompt.md +399 -0
  15. package/batch/batch-runner.sh +673 -0
  16. package/bin/create-job-forge.mjs +375 -0
  17. package/bin/job-forge.mjs +120 -0
  18. package/bin/sync.mjs +141 -0
  19. package/config/profile.example.yml +67 -0
  20. package/cv-sync-check.mjs +128 -0
  21. package/dedup-tracker.mjs +201 -0
  22. package/docs/ARCHITECTURE.md +220 -0
  23. package/docs/CUSTOMIZATION.md +101 -0
  24. package/docs/MODEL-ROUTING.md +195 -0
  25. package/docs/README.md +54 -0
  26. package/docs/SETUP.md +186 -0
  27. package/docs/demo.gif +0 -0
  28. package/fonts/dm-sans-latin-ext.woff2 +0 -0
  29. package/fonts/dm-sans-latin.woff2 +0 -0
  30. package/fonts/space-grotesk-latin-ext.woff2 +0 -0
  31. package/fonts/space-grotesk-latin.woff2 +0 -0
  32. package/generate-pdf.mjs +168 -0
  33. package/iso/agents/general-free.md +90 -0
  34. package/iso/agents/general-paid.md +44 -0
  35. package/iso/agents/glm-minimal.md +55 -0
  36. package/iso/commands/job-forge.md +188 -0
  37. package/iso/config.json +7 -0
  38. package/iso/instructions.md +514 -0
  39. package/iso/mcp.json +15 -0
  40. package/merge-tracker.mjs +377 -0
  41. package/modes/README.md +30 -0
  42. package/modes/_shared-calibration.md +26 -0
  43. package/modes/_shared.md +272 -0
  44. package/modes/apply.md +257 -0
  45. package/modes/auto-pipeline.md +70 -0
  46. package/modes/batch.md +110 -0
  47. package/modes/compare.md +23 -0
  48. package/modes/contact.md +82 -0
  49. package/modes/deep.md +99 -0
  50. package/modes/followup.md +68 -0
  51. package/modes/negotiation.md +146 -0
  52. package/modes/offer.md +199 -0
  53. package/modes/pdf.md +121 -0
  54. package/modes/pipeline.md +83 -0
  55. package/modes/project.md +30 -0
  56. package/modes/rejection.md +92 -0
  57. package/modes/scan.md +185 -0
  58. package/modes/tracker.md +31 -0
  59. package/modes/training.md +27 -0
  60. package/normalize-statuses.mjs +152 -0
  61. package/opencode.json +28 -0
  62. package/package.json +78 -0
  63. package/scripts/add-tags.mjs +894 -0
  64. package/scripts/cursor-agent-loop.sh +211 -0
  65. package/scripts/cursor-agent-stream-format.py +134 -0
  66. package/scripts/next-num.mjs +33 -0
  67. package/scripts/release/check-source.mjs +37 -0
  68. package/scripts/render-report-header.mjs +78 -0
  69. package/scripts/session-report.mjs +129 -0
  70. package/scripts/slugify.mjs +27 -0
  71. package/scripts/today.mjs +20 -0
  72. package/scripts/token-usage-report.mjs +315 -0
  73. package/scripts/tracker-line.mjs +67 -0
  74. package/scripts/verify-greenhouse-urls.mjs +195 -0
  75. package/templates/cv-template.html +395 -0
  76. package/templates/portals.example.yml +3140 -0
  77. package/templates/states.yml +62 -0
  78. package/tracker-lib.mjs +257 -0
  79. package/verify-pipeline.mjs +267 -0
@@ -0,0 +1,377 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * merge-tracker.mjs — Merge batch tracker additions into the application tracker
4
+ *
5
+ * Supports both layouts:
6
+ * - Day-based: data/applications/YYYY-MM-DD.md (preferred)
7
+ * - Single-file: data/applications.md or applications.md (legacy)
8
+ *
9
+ * Handles multiple TSV formats:
10
+ * - 9-col: num\tdate\tcompany\trole\tstatus\tscore\tpdf\treport\tnotes
11
+ * - 8-col: num\tdate\tcompany\trole\tstatus\tscore\tpdf\treport (no notes)
12
+ * - Pipe-delimited (markdown table row): | col | col | ... |
13
+ *
14
+ * Dedup: company normalized + role fuzzy match + report number match
15
+ * If duplicate with higher score → update in-place, update report link
16
+ * Validates status against templates/states.yml when present (else built-in labels)
17
+ *
18
+ * Run: node merge-tracker.mjs [--dry-run] [--verify] (from repo root)
19
+ */
20
+
21
+ import { readFileSync, writeFileSync, readdirSync, mkdirSync, renameSync, existsSync } from 'fs';
22
+ import { join, relative, dirname } from 'path';
23
+ import { fileURLToPath } from 'url';
24
+ import {
25
+ PROJECT_DIR, DATA_APPS_DIR, DATA_APPS_FILE, ROOT_APPS_FILE,
26
+ usesDayFiles, ensureDayDir, getHeader, formatAppLine, parseAppLine,
27
+ readAllEntries, writeToDayFiles, listDayFiles, dayFilePath,
28
+ } from './tracker-lib.mjs';
29
+
30
+ const ADDITIONS_DIR = join(PROJECT_DIR, 'batch/tracker-additions');
31
+ const MERGED_DIR = join(ADDITIONS_DIR, 'merged');
32
+ const DRY_RUN = process.argv.includes('--dry-run');
33
+ const VERIFY = process.argv.includes('--verify');
34
+
35
+ if (process.argv.includes('--help') || process.argv.includes('-h')) {
36
+ console.log(`merge-tracker.mjs — merge batch/tracker-additions/*.tsv into the tracker
37
+
38
+ Supports day-based (data/applications/YYYY-MM-DD.md) and single-file layouts.
39
+ Moves processed files to batch/tracker-additions/merged/.
40
+
41
+ Usage:
42
+ node merge-tracker.mjs [--dry-run] [--verify]
43
+ npm run merge [-- --dry-run] [--verify]
44
+
45
+ Options:
46
+ --dry-run Show actions without writing the tracker or moving TSVs
47
+ --verify After merge, run verify-pipeline.mjs (ignored with --dry-run)
48
+
49
+ If the tracker file is missing but TSVs exist, creates the tracker
50
+ with an empty table header. If batch/tracker-additions/ is missing or has
51
+ no .tsv files, exits successfully with nothing to do.
52
+
53
+ Run from the repository root.`);
54
+ process.exit(0);
55
+ }
56
+
57
+ const STATES_FILE = existsSync(join(PROJECT_DIR, 'templates/states.yml'))
58
+ ? join(PROJECT_DIR, 'templates/states.yml')
59
+ : join(PROJECT_DIR, 'states.yml');
60
+
61
+ function loadCanonicalLabelsFromStatesYaml(filePath) {
62
+ if (!existsSync(filePath)) return null;
63
+ const text = readFileSync(filePath, 'utf-8');
64
+ const labels = [];
65
+ for (const line of text.split('\n')) {
66
+ const m = line.match(/^\s+label:\s*(.+)$/);
67
+ if (!m) continue;
68
+ let v = m[1].trim().replace(/^['"]|['"]$/g, '');
69
+ if (v) labels.push(v);
70
+ }
71
+ return labels.length > 0 ? labels : null;
72
+ }
73
+
74
+ const CANONICAL_STATES = loadCanonicalLabelsFromStatesYaml(STATES_FILE) || [
75
+ 'Evaluated', 'Applied', 'Contacted', 'Responded', 'Interview', 'Offer', 'Rejected', 'Discarded', 'SKIP',
76
+ ];
77
+
78
+ function validateStatus(status) {
79
+ const clean = status.replace(/\*\*/g, '').replace(/\s+\d{4}-\d{2}-\d{2}.*$/, '').trim();
80
+ const lower = clean.toLowerCase();
81
+
82
+ for (const valid of CANONICAL_STATES) {
83
+ if (valid.toLowerCase() === lower) return valid;
84
+ }
85
+
86
+ const aliases = {
87
+ 'hold': 'Evaluated',
88
+ 'applied': 'Applied', 'sent': 'Applied',
89
+ 'skip': 'SKIP',
90
+ };
91
+
92
+ if (aliases[lower]) return aliases[lower];
93
+
94
+ if (/^(dup(licate)?|repost)/i.test(lower)) return 'Discarded';
95
+
96
+ console.warn(`⚠️ Non-canonical status "${status}" → defaulting to "Evaluated"`);
97
+ return 'Evaluated';
98
+ }
99
+
100
+ function normalizeCompany(name) {
101
+ return name.toLowerCase().replace(/[^a-z0-9]/g, '');
102
+ }
103
+
104
+ function roleFuzzyMatch(a, b) {
105
+ const wordsA = a.toLowerCase().split(/\s+/).filter(w => w.length > 3);
106
+ const wordsB = b.toLowerCase().split(/\s+/).filter(w => w.length > 3);
107
+ const overlap = wordsA.filter(w => wordsB.some(wb => wb.includes(w) || w.includes(wb)));
108
+ return overlap.length >= 2;
109
+ }
110
+
111
+ function extractReportNum(reportStr) {
112
+ const m = reportStr.match(/\[(\d+)\]/);
113
+ return m ? parseInt(m[1]) : null;
114
+ }
115
+
116
+ function parseScore(s) {
117
+ const m = s.replace(/\*\*/g, '').match(/([\d.]+)/);
118
+ return m ? parseFloat(m[1]) : 0;
119
+ }
120
+
121
+ /**
122
+ * Parse a TSV file content into a structured addition object.
123
+ */
124
+ function parseTsvContent(content, filename) {
125
+ content = content.trim();
126
+ if (!content) return null;
127
+
128
+ let parts;
129
+ let addition;
130
+
131
+ if (content.startsWith('|')) {
132
+ parts = content.split('|').map(s => s.trim()).filter(Boolean);
133
+ if (parts.length < 8) {
134
+ console.warn(`⚠️ Skipping malformed pipe-delimited ${filename}: ${parts.length} fields`);
135
+ return null;
136
+ }
137
+ addition = {
138
+ num: parseInt(parts[0]),
139
+ date: parts[1],
140
+ company: parts[2],
141
+ role: parts[3],
142
+ score: parts[4],
143
+ status: validateStatus(parts[5]),
144
+ pdf: parts[6],
145
+ report: parts[7],
146
+ notes: parts[8] || '',
147
+ };
148
+ } else {
149
+ parts = content.split('\t');
150
+ if (parts.length < 8) {
151
+ console.warn(`⚠️ Skipping malformed TSV ${filename}: ${parts.length} fields`);
152
+ return null;
153
+ }
154
+
155
+ const col4 = parts[4].trim();
156
+ const col5 = parts[5].trim();
157
+ const col4LooksLikeScore = /^\d+\.?\d*\/5$/.test(col4) || col4 === 'N/A' || col4 === 'DUP';
158
+ const col5LooksLikeScore = /^\d+\.?\d*\/5$/.test(col5) || col5 === 'N/A' || col5 === 'DUP';
159
+ const col4LooksLikeStatus = /^(evaluated|applied|contacted|responded|interview|offer|rejected|discarded|skip|duplicate|repost|hold)/i.test(col4);
160
+ const col5LooksLikeStatus = /^(evaluated|applied|contacted|responded|interview|offer|rejected|discarded|skip|duplicate|repost|hold)/i.test(col5);
161
+
162
+ let statusCol, scoreCol;
163
+ if (col4LooksLikeStatus && !col4LooksLikeScore) {
164
+ statusCol = col4; scoreCol = col5;
165
+ } else if (col4LooksLikeScore && col5LooksLikeStatus) {
166
+ statusCol = col5; scoreCol = col4;
167
+ } else if (col5LooksLikeScore && !col4LooksLikeScore) {
168
+ statusCol = col4; scoreCol = col5;
169
+ } else {
170
+ statusCol = col4; scoreCol = col5;
171
+ }
172
+
173
+ addition = {
174
+ num: parseInt(parts[0]),
175
+ date: parts[1],
176
+ company: parts[2],
177
+ role: parts[3],
178
+ status: validateStatus(statusCol),
179
+ score: scoreCol,
180
+ pdf: parts[6],
181
+ report: parts[7],
182
+ notes: parts[8] || '',
183
+ };
184
+ }
185
+
186
+ if (isNaN(addition.num) || addition.num === 0) {
187
+ console.warn(`⚠️ Skipping ${filename}: invalid entry number`);
188
+ return null;
189
+ }
190
+
191
+ return addition;
192
+ }
193
+
194
+ // ---- Main ----
195
+
196
+ if (!existsSync(ADDITIONS_DIR)) {
197
+ console.log('✅ No pending additions to merge.');
198
+ process.exit(0);
199
+ }
200
+
201
+ const tsvFiles = readdirSync(ADDITIONS_DIR).filter(f => f.endsWith('.tsv'));
202
+ if (tsvFiles.length === 0) {
203
+ console.log('✅ No pending additions to merge.');
204
+ process.exit(0);
205
+ }
206
+
207
+ // Initialize tracker
208
+ const layout = usesDayFiles() ? 'day' : 'single';
209
+ let appLines;
210
+ let existingApps;
211
+ let maxNum;
212
+
213
+ if (layout === 'day') {
214
+ ensureDayDir();
215
+ ({ entries: existingApps, maxNum } = readAllEntries());
216
+ } else {
217
+ // Single-file mode
218
+ const APPS_FILE = existsSync(DATA_APPS_FILE) ? DATA_APPS_FILE : ROOT_APPS_FILE;
219
+ const appsDisplay = relative(PROJECT_DIR, APPS_FILE).replace(/\\/g, '/');
220
+
221
+ if (!existsSync(APPS_FILE)) {
222
+ if (DRY_RUN) {
223
+ console.log('(dry-run) Would create data/applications.md with empty tracker header.');
224
+ } else {
225
+ console.log('No tracker file yet; creating data/applications.md with empty header.');
226
+ mkdirSync(join(PROJECT_DIR, 'data'), { recursive: true });
227
+ writeFileSync(DATA_APPS_FILE, getHeader() + '\n', 'utf-8');
228
+ }
229
+ }
230
+
231
+ const filePath = existsSync(DATA_APPS_FILE) ? DATA_APPS_FILE : ROOT_APPS_FILE;
232
+ appLines = readFileSync(filePath, 'utf-8').split('\n');
233
+ existingApps = [];
234
+ maxNum = 0;
235
+ for (const line of appLines) {
236
+ if (line.startsWith('|') && !line.includes('---') && !line.includes('Company')) {
237
+ const app = parseAppLine(line);
238
+ if (app) {
239
+ existingApps.push(app);
240
+ if (app.num > maxNum) maxNum = app.num;
241
+ }
242
+ }
243
+ }
244
+ }
245
+
246
+ const appsDisplay = layout === 'day' ? relative(PROJECT_DIR, DATA_APPS_DIR) : relative(PROJECT_DIR, existsSync(DATA_APPS_FILE) ? DATA_APPS_FILE : ROOT_APPS_FILE);
247
+ console.log(`📊 ${appsDisplay}: ${existingApps.length} existing entries, max #${maxNum}`);
248
+
249
+ tsvFiles.sort((a, b) => {
250
+ const numA = parseInt(a.replace(/\D/g, '')) || 0;
251
+ const numB = parseInt(b.replace(/\D/g, '')) || 0;
252
+ return numA - numB;
253
+ });
254
+
255
+ console.log(`📥 Found ${tsvFiles.length} pending additions`);
256
+
257
+ let added = 0;
258
+ let updated = 0;
259
+ let skipped = 0;
260
+ const newEntries = [];
261
+
262
+ for (const file of tsvFiles) {
263
+ const content = readFileSync(join(ADDITIONS_DIR, file), 'utf-8').trim();
264
+ const addition = parseTsvContent(content, file);
265
+ if (!addition) { skipped++; continue; }
266
+
267
+ const reportNum = extractReportNum(addition.report);
268
+ let duplicate = null;
269
+
270
+ if (reportNum) {
271
+ duplicate = existingApps.find(app => {
272
+ const existingReportNum = extractReportNum(app.report);
273
+ return existingReportNum === reportNum;
274
+ });
275
+ }
276
+
277
+ if (!duplicate) {
278
+ duplicate = existingApps.find(app => app.num === addition.num);
279
+ }
280
+
281
+ if (!duplicate) {
282
+ const normCompany = normalizeCompany(addition.company);
283
+ duplicate = existingApps.find(app => {
284
+ if (normalizeCompany(app.company) !== normCompany) return false;
285
+ return roleFuzzyMatch(addition.role, app.role);
286
+ });
287
+ }
288
+
289
+ if (duplicate) {
290
+ const newScore = parseScore(addition.score);
291
+ const oldScore = parseScore(duplicate.score);
292
+
293
+ if (newScore > oldScore) {
294
+ console.log(`🔄 Update: #${duplicate.num} ${addition.company} — ${addition.role} (${oldScore}→${newScore})`);
295
+
296
+ if (layout === 'day') {
297
+ // Update in existing entries list for later write
298
+ duplicate.date = addition.date;
299
+ duplicate.company = addition.company;
300
+ duplicate.role = addition.role;
301
+ duplicate.score = addition.score;
302
+ duplicate.report = addition.report;
303
+ duplicate.notes = `Re-eval ${addition.date} (${oldScore}→${newScore}). ${addition.notes}`;
304
+ } else {
305
+ const lineIdx = appLines.indexOf(duplicate.raw);
306
+ if (lineIdx >= 0) {
307
+ appLines[lineIdx] = `| ${duplicate.num} | ${addition.date} | ${addition.company} | ${addition.role} | ${addition.score} | ${duplicate.status} | ${duplicate.pdf} | ${addition.report} | Re-eval ${addition.date} (${oldScore}→${newScore}). ${addition.notes} |`;
308
+ }
309
+ }
310
+ updated++;
311
+ } else {
312
+ console.log(`⏭️ Skip: ${addition.company} — ${addition.role} (existing #${duplicate.num} ${oldScore} >= new ${newScore})`);
313
+ skipped++;
314
+ }
315
+ } else {
316
+ const entryNum = addition.num > maxNum ? addition.num : ++maxNum;
317
+ if (addition.num > maxNum) maxNum = addition.num;
318
+
319
+ newEntries.push({
320
+ ...addition,
321
+ num: entryNum,
322
+ });
323
+ added++;
324
+ console.log(`➕ Add #${entryNum}: ${addition.company} — ${addition.role} (${addition.score})`);
325
+ }
326
+ }
327
+
328
+ // Write new entries
329
+ if (!DRY_RUN) {
330
+ if (layout === 'day') {
331
+ // Merge new entries into existing, then write day files
332
+ existingApps.push(...newEntries);
333
+ writeToDayFiles(existingApps);
334
+ } else {
335
+ // Single-file: insert new lines after header
336
+ if (newEntries.length > 0) {
337
+ let insertIdx = -1;
338
+ for (let i = 0; i < appLines.length; i++) {
339
+ if (appLines[i].includes('---') && appLines[i].startsWith('|')) {
340
+ insertIdx = i + 1;
341
+ break;
342
+ }
343
+ }
344
+ if (insertIdx >= 0) {
345
+ appLines.splice(insertIdx, 0, ...newEntries.map(formatAppLine));
346
+ }
347
+ }
348
+
349
+ const APPS_FILE = existsSync(DATA_APPS_FILE) ? DATA_APPS_FILE : ROOT_APPS_FILE;
350
+ writeFileSync(APPS_FILE, appLines.join('\n'));
351
+ }
352
+
353
+ // Move processed files to merged/
354
+ if (!existsSync(MERGED_DIR)) mkdirSync(MERGED_DIR, { recursive: true });
355
+ for (const file of tsvFiles) {
356
+ renameSync(join(ADDITIONS_DIR, file), join(MERGED_DIR, file));
357
+ }
358
+ console.log(`\n✅ Moved ${tsvFiles.length} TSVs to merged/`);
359
+ }
360
+
361
+ console.log(`\n📊 Summary: +${added} added, 🔄${updated} updated, ⏭️${skipped} skipped`);
362
+ if (DRY_RUN) console.log('(dry-run — no changes written)');
363
+
364
+ // Optional verify — resolve verify-pipeline.mjs relative to this file (works whether
365
+ // installed as a package in node_modules or run from the repo root).
366
+ if (VERIFY && !DRY_RUN) {
367
+ console.log('\n--- Running verification ---');
368
+ const { execSync } = await import('child_process');
369
+ const { fileURLToPath } = await import('url');
370
+ const { dirname } = await import('path');
371
+ const here = dirname(fileURLToPath(import.meta.url));
372
+ try {
373
+ execSync(`node ${join(here, 'verify-pipeline.mjs')}`, { stdio: 'inherit' });
374
+ } catch (e) {
375
+ process.exit(1);
376
+ }
377
+ }
@@ -0,0 +1,30 @@
1
+ # Modes
2
+
3
+ Markdown prompts used with opencode together with the root [`OPENCODE.md`](../OPENCODE.md). Each file aligns with a `/job-forge …` entry point or shared behavior described there.
4
+
5
+ - **`_shared.md`** — Archetypes, scoring dimensions, negotiation scaffolding. Edit this first when you change how offers are classified or weighted.
6
+ - **Per-command files** — Each `*.md` here pairs with a `/job-forge …` entry in [`OPENCODE.md`](../OPENCODE.md). How modes connect to batch, tracker, and scripts is spelled out in [**Architecture — Modes**](../docs/ARCHITECTURE.md#modes-modes).
7
+
8
+ | File | Role |
9
+ |------|------|
10
+ | [`_shared.md`](_shared.md) | Shared archetypes, scoring, negotiation scaffolding |
11
+ | [`auto-pipeline.md`](auto-pipeline.md) | Default path when the user pastes a JD or URL — full evaluate → report → PDF → tracker flow |
12
+ | [`offer.md`](offer.md) | Explicit full evaluation (blocks A–F) for a single offer |
13
+ | [`compare.md`](compare.md) | Side-by-side comparison of multiple offers |
14
+ | [`contact.md`](contact.md) | LinkedIn or email outreach drafts |
15
+ | [`deep.md`](deep.md) | Deeper company / role research |
16
+ | [`pdf.md`](pdf.md) | Tailored CV and PDF generation |
17
+ | [`training.md`](training.md) | Evaluate a course, cert, or learning path |
18
+ | [`project.md`](project.md) | Evaluate a portfolio project for job fit |
19
+ | [`tracker.md`](tracker.md) | Application tracker hygiene and status questions |
20
+ | [`apply.md`](apply.md) | Application forms and long-form answers |
21
+ | [`scan.md`](scan.md) | Portal and job-board scanning |
22
+ | [`pipeline.md`](pipeline.md) | Work through pending URLs in `data/pipeline.md` |
23
+ | [`batch.md`](batch.md) | Batch evaluation workflow and TSV-oriented runs |
24
+ | [`followup.md`](followup.md) | What to follow up on next |
25
+ | [`rejection.md`](rejection.md) | Log or process a rejection |
26
+ | [`negotiation.md`](negotiation.md) | Offer received — negotiation framing |
27
+
28
+ To tailor profile-driven settings, portals, and templates, see [`docs/CUSTOMIZATION.md`](../docs/CUSTOMIZATION.md).
29
+
30
+ Contributors: see [`CONTRIBUTING.md`](../CONTRIBUTING.md) for branch workflow and the `npm run verify` gate; prefer one cohesive change per PR (for example a single mode or updates under `_shared.md` only).
@@ -0,0 +1,26 @@
1
+ # Scoring Calibration Anchors
2
+
3
+ **This file is Read on-demand, NOT loaded into the global `instructions` prefix.** It lives separately from `_shared.md` because its contents churn as the candidate accumulates reports — keeping it out of the cached prefix means updates here don't bust the prompt cache for every unrelated session.
4
+
5
+ **When to Read this file:** right before assigning a final score during evaluation. Once per evaluation, not per dimension.
6
+
7
+ ---
8
+
9
+ **Use these reference profiles to anchor scores and prevent drift over time.** When evaluating an offer, mentally compare it to these anchors before assigning a final score. Scores MUST be relative to the candidate's actual profile, not absolute.
10
+
11
+ <!-- [CUSTOMIZE] Replace these with real offers you've evaluated, or archetypes
12
+ that represent clear score levels for YOUR situation. The examples below
13
+ are generic starting points — after 10-20 evaluations, replace them with
14
+ actual reports from your pipeline (e.g., "Report #045 — Anthropic — 4.7/5"). -->
15
+
16
+ | Score | What it looks like | Example anchor |
17
+ |-------|--------------------|----------------|
18
+ | **5.0** | Dream role. Exact archetype, 90%+ CV match, top-quartile comp, remote, strong brand, fast process. You'd accept immediately. | _Replace with your highest-scored report once you have one_ |
19
+ | **4.0** | Strong match. Right archetype, 75%+ match, fair comp, minor gaps that are easy to frame. Worth a tailored application. | _Replace with a real ~4.0 report_ |
20
+ | **3.0** | Moderate match. Adjacent archetype, 50-60% match, 2-4 hard gaps, comp unknown or median. Worth evaluating but not a priority. | _Replace with a real ~3.0 report_ |
21
+ | **2.0** | Weak match. Wrong seniority or archetype, major gaps, below-market comp signals. Discourage unless specific reason. | _Replace with a real ~2.0 report_ |
22
+ | **1.0** | No fit. Unrelated domain, entry-level, relocation-only, or red flags. Skip. | _Replace with a real ~1.0 report_ |
23
+
24
+ **Recalibration trigger:** After every 50 evaluations (or when you notice scores clustering — e.g., everything is 3.5-4.2), review the anchors table. Replace generic descriptions with actual reports. If your best offer so far is a 4.3, that's your effective ceiling — adjust the 5.0 anchor to reflect what a true dream role would actually look like for you.
25
+
26
+ **How to use during evaluation:** After computing the weighted score, sanity-check it against the anchors. "Is this really a 4.5? Is it as strong as [anchor report]?" If not, adjust. The anchors prevent both inflation (everything is 4+) and deflation (nothing breaks 3.5).