Role Reaper Systems spec · how it works

Personalized, but never dishonest

Role Reaper is built for one job hunt: mine. The personalization is the whole point. But personal doesn't mean loose. Every stage that could quietly overstate a fit or reword a fact is boxed in by code. Here's the five-stage pipeline, start to finish.

Systems
Stage 0 · stage0-terms.jsThree résumés, three searches

I don't apply as one person. I have three résumé variants, AI-automation, marketing/growth-ops, and analytics, because the same background reads differently to different employers. So the pipeline starts by reading all three and deriving a distinct set of search phrases for each. The rest of the run pulls jobs against every variant's terms, so an analytics role and an AI role both surface, each found on its own terms.

async function deriveTerms(variantName, resumeText) {
  // one call per variant — the model turns a résumé into search phrases
  const res = await callClaude(buildTermsPrompt(variantName, resumeText));
  return JSON.parse(res);   // e.g. 5 phrases tuned to THAT variant
}
// output: search-terms.json — { "Analytics": [...], "AI-Automation": [...], ... }
Stage 2 · extractThe model reads. It does not judge.

Each posting goes to the model with one job: turn description text into structured facts. The prompt forbids inference, scoring, and the word "likely." Skills are matched against a fixed list by index, so there's no paraphrasing. Temperature is 0.

// the model's only output — facts, no opinions
{
  "matchedSkillIndexes": [0, 1, 8],
  "workMode": "remote",
  "statedSalaryMax": 135000,
  "yearsRequired": 3,
  "requiresClearance": false,
  "isJunk": false
}
Stage 2 · scoreCode applies the rules.

The facts hit a scoring function with fixed rules. Hard blockers return 0 (clearance, 8+ years required). Everything else adds or subtracts from a base of 50. The model never sees a number, so the same posting always scores the same.

function scoreFromFacts(facts) {
  if (facts.requiresClearance) return { score: 0 };
  if (facts.yearsRequired >= 8)  return { score: 0 };

  let score = 50;                       // base
  score += skillCount * 7;               // per matched skill
  score += { remote: 15, hybrid: 5 }[mode]; // location
  if (salaryMax >= 120000) score += 10;
  if (yearsRequired >= 5) score -= 40;   // senior penalty
  return { score: clamp(score, 0, 100) };
}
Stage 3 · tailorReorder, never rewrite.

For high-fit roles, the résumé bullets get reordered by relevance. The bullets themselves are verbatim and never touched. Each is tagged with the skills it proves, then ranked by rarity-weighted overlap with the role: a rare, defining skill (AI-assisted development) outranks a common one (SQL). An AI role leads with AI bullets; an analytics role leads with pipeline bullets.

// rarer skill => higher weight => more defining of fit
const weightOf = (skill) => 1 / (counts[skill] || 1);

const ranked = bullets
  .map((b) => {
    const matched = b.skills.filter(s => roleSkills.has(s));
    const score = matched.reduce((sum, s) => sum + weightOf(s), 0);
    return { text: b.text, score };   // text is verbatim
  })
  .sort((a, b) => b.score - a.score);

The model only writes the cover letter, and even there it's boxed in: use these ranked bullets as the only evidence, don't stretch toward the posting's vocabulary, no invented skills, everything defensible in an interview.

Memory · job-state.jsIt remembers what I've already done

A job tool that shows you the same postings every run is useless. So there's a persistent memory layer keyed by each posting's stable Adzuna ID. Every job carries a status through a lifecycle, and the ones I've acted on are suppressed from future pulls, so I never re-see something I already applied to or rejected.

// status lifecycle, keyed by stable Adzuna ID
new → queued → applied → interview → offer → closed
                → rejected

// applied / rejected are SUPPRESSED — never pulled again
function isSuppressed(store, job) {
  return SUPPRESSED_STATUSES.has(getStatus(store, job));
}

One design choice I like: the store only records jobs I've acted on. A job with no record is implicitly "new." So the memory stays small and meaningful, it's a record of decisions, not a log of everything the pipeline ever saw. Each entry also keeps its full status-transition history, so the tool can show how a given application progressed over time.

The pattern: the model touches text, deriving search terms, extracting facts, phrasing a cover letter. Code owns every decision that has to be reproducible or remembered: the score, the bullet ranking, the hard blockers, and what I've already done. It's built for me, tuned to me, and it still can't lie on my behalf. That's the point.