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

> Explore the runtime data flow for job evaluation in Career-Ops. Understand the nine-stage oferta pipeline, Playwright JD capture, LLM integration, and atomic tracker updates for efficient career operations.

- Repository: [Santiago Fernández de Valderrama/career-ops](https://github.com/santifer/career-ops)
- Tags: architecture
- Published: 2026-08-22

---

**Career-Ops evaluates job postings through a nine-stage "oferta" pipeline that orchestrates Playwright-based JD capture, canonical data aggregation from [`cv.md`](https://github.com/santifer/career-ops/blob/main/cv.md) and [`config/profile.yml`](https://github.com/santifer/career-ops/blob/main/config/profile.yml), LLM prompt construction via [`modes/oferta.md`](https://github.com/santifer/career-ops/blob/main/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`).

```bash

# 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.

```javascript
// 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`](https://github.com/santifer/career-ops/blob/main/cv.md)), user-specific configuration ([`config/profile.yml`](https://github.com/santifer/career-ops/blob/main/config/profile.yml)), archetype narrative ([`modes/_profile.md`](https://github.com/santifer/career-ops/blob/main/modes/_profile.md)), and any accumulated proof points from [`interview-prep/story-bank.md`](https://github.com/santifer/career-ops/blob/main/interview-prep/story-bank.md).

```javascript
// 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`](https://github.com/santifer/career-ops/blob/main/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.

```javascript
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.

```javascript
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`](https://github.com/santifer/career-ops/blob/main/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.

```javascript
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`](https://github.com/santifer/career-ops/blob/main/data/applications.md). This script uses file-locking (`flock`) to prevent race conditions and validates the new state against [`templates/states.yml`](https://github.com/santifer/career-ops/blob/main/templates/states.yml) before writing.

```javascript
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.

```javascript
// 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`](https://github.com/santifer/career-ops/blob/main/cv.md), [`config/profile.yml`](https://github.com/santifer/career-ops/blob/main/config/profile.yml), [`story-bank.md`](https://github.com/santifer/career-ops/blob/main/story-bank.md)), ensuring reproducible assessments.
- **Atomic State Management**: Tracker updates in [`data/applications.md`](https://github.com/santifer/career-ops/blob/main/data/applications.md) are guarded by file locks and state validation against [`templates/states.yml`](https://github.com/santifer/career-ops/blob/main/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`](https://github.com/santifer/career-ops/blob/main/data/applications.md) before reading, validates the new state against [`templates/states.yml`](https://github.com/santifer/career-ops/blob/main/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`](https://github.com/santifer/career-ops/blob/main/cv.md)), profile configuration ([`config/profile.yml`](https://github.com/santifer/career-ops/blob/main/config/profile.yml)), archetype narrative ([`modes/_profile.md`](https://github.com/santifer/career-ops/blob/main/modes/_profile.md)), and behavioral proof points ([`interview-prep/story-bank.md`](https://github.com/santifer/career-ops/blob/main/interview-prep/story-bank.md)). This multi-source context enables nuanced match scoring and gap analysis.