Runtime Data Flow for Job Evaluation in Career-Ops: The Complete Oferta Pipeline

Career-Ops evaluates job postings through a nine-stage "oferta" pipeline that orchestrates Playwright-based JD capture, canonical data aggregation from cv.md and config/profile.yml, LLM prompt construction via modes/oferta.md, atomic tracker updates via set-status.mjs, and optional PDF generation.

Career-Ops is a CLI-agnostic job-search automation framework built on Node.js and Playwright. Understanding the runtime data flow for job evaluation in career-ops reveals how the system transforms a raw URL or report number into a structured evaluation report while maintaining data consistency through atomic file operations and state validation.

Step-by-Step Runtime Data Flow

Trigger and Input Normalization

The evaluation pipeline initiates when a user invokes the oferta mode via the CLI. The entry point modes/oferta.mjs accepts a single argument that can be either a live URL or an existing report number (e.g., 042).


# Evaluate a live job posting

node modes/oferta.mjs https://company.com/careers/12345

# Re-evaluate from an archived report

node modes/oferta.mjs 042

The orchestrator immediately delegates to jd-capture.mjs to normalize the input into a canonical job-description structure.

Job Description Resolution

The resolveJD function in jd-capture.mjs handles two distinct input paths. For URLs, it launches a headless Playwright browser to fetch the page, extract the title, company, and description using CSS selectors, and return a normalized object. For report numbers, it locates the file in reports/<num>-*.md, parses the archived JD block, and returns the historical data.

// From jd-capture.mjs
export async function resolveJD(arg) {
  if (arg.startsWith("http")) {
    const page = await fetchPage(arg);
    const title = await page.$eval("h1", (el) => el.textContent.trim());
    const company = await page.$eval(".company", (el) => el.textContent.trim());
    const description = await page.$eval(".description", (el) => el.innerHTML);
    return { title, company, description, source: "url", url: arg };
  }
  
  // Report number path
  const reportPath = `reports/${arg.padStart(3, "0")}-*.md`;
  const files = await glob(reportPath);
  const content = await readFile(files[0], "utf8");
  const jdBlock = parseReport(content).jd;
  return { ...jdBlock, source: "report", reportId: arg };
}

Data Aggregation from Canonical Sources

Once the JD is resolved, modes/oferta.mjs aggregates the user's contextual data from immutable sources of truth. This includes the canonical CV (cv.md), user-specific configuration (config/profile.yml), archetype narrative (modes/_profile.md), and any accumulated proof points from interview-prep/story-bank.md.

// From modes/oferta.mjs
const jd = await resolveJD(arg);
const cv = await loadCV();
const profile = await loadProfile();
const storyBank = await loadStoryBank();
const template = await readFile("./modes/oferta.md", "utf8");

Prompt Construction and LLM Interaction

The system builds the evaluation prompt by injecting the aggregated data into the markdown template modes/oferta.md. The buildPrompt function replaces placeholders like {{CV}}, {{JD}}, and {{Profile}} with the actual content, creating a structured query that asks the LLM to score the match (0-5), identify strengths and gaps, suggest interview angles, and assess posting legitimacy.

const prompt = buildPrompt(template, { cv, jd, profile, storyBank });
const llmResponse = await callLLM(prompt);

The prompt is sent to the configured model (Claude, Gemini, etc.) via llm-client.mjs, which returns a structured markdown response.

Response Parsing and Structured Extraction

The parseResponse function in response-parser.mjs extracts machine-readable fields from the LLM's text output. It captures the numeric score, lists of strengths and gaps, negotiation ROI indicators, and the legitimacy flag that distinguishes active postings from closed ones.

const parsed = parseResponse(llmResponse);
// parsed contains: Score, Strengths, Gaps, Negotiation ROI, Legitimacy

Report Generation and Artifact Creation

With the parsed data, report-writer.mjs creates a new markdown file under reports/ using a sequential naming convention (e.g., 042-company-role-2024-08-22.md). The report embeds the JD, CV excerpt, and LLM analysis, creating a persistent artifact that can be referenced later or converted to PDF.

const reportPath = await writeReport(parsed, jd);
console.log(`Report written to ${reportPath}`);

Atomic Tracker Updates

If the JD corresponds to an existing tracker entry, the system invokes set-status.mjs to atomically update data/applications.md. This script uses file-locking (flock) to prevent race conditions and validates the new state against templates/states.yml before writing.

await setStatus(jd.reportId, "Evaluated", { note: `Oferta report ${reportPath}` });

The setStatus function in set-status.mjs splits the tracker file, locates the target row by ID or company name, updates the status column (index 5) and notes column (index 8), and writes the entire file within a locked transaction.

Optional Post-Processing

After the tracker update, the pipeline executes optional finalizers. generate-pdf.mjs uses Playwright to convert the markdown report to A4 PDF format. followup-seed.mjs schedules reminder tasks, and verify-pipeline.mjs runs consistency checks across the data files to ensure integrity.

// From generate-pdf.mjs
export async function generatePdf(htmlPath, pdfPath) {
  const html = await readFile(htmlPath, "utf8");
  const browser = await chromium.launch();
  const page = await browser.newPage();
  await page.setContent(html, { waitUntil: "networkidle" });
  await page.pdf({ path: pdfPath, format: "A4" });
  await browser.close();
}

Summary

  • Input Flexibility: The pipeline accepts both live URLs and archived report numbers, normalizing them through jd-capture.mjs into canonical JD structures.
  • Data Immutability: All evaluation context comes from version-controlled sources (cv.md, config/profile.yml, story-bank.md), ensuring reproducible assessments.
  • Atomic State Management: Tracker updates in data/applications.md are guarded by file locks and state validation against templates/states.yml, preventing data corruption during concurrent operations.
  • Extensible Architecture: New evaluation modes require only a markdown template in modes/ and a thin wrapper script that follows the established orchestration pattern.
  • Playwright Integration: The system uses headless browser automation for both data capture (live JD verification) and artifact generation (PDF rendering).

Frequently Asked Questions

What triggers the job evaluation pipeline in Career-Ops?

The pipeline triggers when a user executes node modes/oferta.mjs with either a job posting URL or an existing report number. This invocation runs the orchestrator script that coordinates the nine-stage flow from JD capture to tracker update.

How does Career-Ops prevent race conditions when updating the tracker?

The set-status.mjs script implements atomic writes using file-locking mechanisms. It acquires a lock on data/applications.md before reading, validates the new state against templates/states.yml, modifies the target row's status and notes columns, and releases the lock only after writing the complete file back to disk.

Can the pipeline process both live job postings and archived descriptions?

Yes. The resolveJD function in jd-capture.mjs branches based on input type. URLs trigger Playwright-based live scraping with CSS selector extraction, while numeric report IDs load historical data from reports/<num>-*.md files, enabling re-evaluation of previously captured postings.

What data sources inform the LLM's job match evaluation?

The LLM receives a composite prompt built from five canonical sources: the job description (from jd-capture.mjs), the user's CV (cv.md), profile configuration (config/profile.yml), archetype narrative (modes/_profile.md), and behavioral proof points (interview-prep/story-bank.md). This multi-source context enables nuanced match scoring and gap analysis.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →