Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ batch/tracker-additions/**/*.tsv
!batch/tracker-additions/.gitkeep
jds/*
!jds/.gitkeep
scratch/
resume-ops/
resume-ops.log

# Writing samples (user's personal files — never committed)
writing-samples/*
Expand Down
53 changes: 53 additions & 0 deletions modes/resume-ops.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# Mode: resume-ops — High-Quality Tailoring via External API

Use this mode when the user wants a better-tailored resume. This mode leverages the specialized `resume-ops` service.

**The AI agent is responsible for the entire technical lifecycle**: starting the service, ensuring data compatibility, and executing the tailoring. The user should only need to provide the JD.

## Pipeline

1. **Prepare Job Description (JD)**:
- Identify the JD text or URL.
- If it's a URL, use `scan.mjs` or relevant tools to extract the text.

2. **Technical Setup (Invisible to User)**:
- **Cloning & Service**: Run `node start-resume-ops.mjs`. This script will:
- Automatically clone `resume-ops` from GitLab if missing.
- Automatically configure the `.env` for local execution.
- Start the service using `uv` on port 8000.
- **Docker/Podman**: This integration runs `resume-ops` directly via Python/uv for simplicity. **Docker or Podman are NOT required** for this setup, making it easy for most users.
- **JSON Resume**: Check for `resume-ops/.local/master-resume.json`. If missing, generate it immediately from `cv.md` and `config/profile.yml`.

3. **Execute Tailoring**:
- Determine the output PDF path: `output/cv-{candidate}-{company}-{YYYY-MM-DD}.pdf`.
- Use `config/profile.yml` to get `{candidate}` (normalized to kebab-case) and `{company}`.
- Run the helper script:
```bash
node resume-ops.mjs --resume resume-ops/.local/master-resume.json --jd "{JD_TEXT}" --output output/cv-{candidate}-{company}-{YYYY-MM-DD}.pdf
```
Comment on lines +25 to +27
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

Add blank lines around the fenced code block.

The code block should be surrounded by blank lines for proper Markdown rendering and consistency with linting rules.

📝 Proposed fix
 3. **Execute Tailoring**:
    - Determine the output PDF path: `output/cv-{candidate}-{company}-{YYYY-MM-DD}.pdf`.
    - Use `config/profile.yml` to get `{candidate}` (normalized to kebab-case) and `{company}`.
    - Run the helper script:
+
      ```bash
      node resume-ops.mjs --resume resume-ops/.local/master-resume.json --jd "{JD_TEXT}" --output output/cv-{candidate}-{company}-{YYYY-MM-DD}.pdf
      ```
+
    - If `config/profile.yml` has a `cv.theme` value, pass it via `--theme`.
🧰 Tools
🪛 markdownlint-cli2 (0.22.1)

[warning] 25-25: Fenced code blocks should be surrounded by blank lines

(MD031, blanks-around-fences)


[warning] 27-27: Fenced code blocks should be surrounded by blank lines

(MD031, blanks-around-fences)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@modes/resume-ops.md` around lines 25 - 27, Add a blank line immediately
before the opening fenced code block (```bash) and another blank line
immediately after the closing ``` so the snippet containing "node resume-ops.mjs
--resume resume-ops/.local/master-resume.json --jd \"{JD_TEXT}\" --output
output/cv-{candidate}-{company}-{YYYY-MM-DD}.pdf" is visually separated from
surrounding text and conforms to Markdown linting; update the surrounding lines
accordingly and ensure any subsequent note about passing --theme (if present)
remains on its own paragraph after the blank line.

- If `config/profile.yml` has a `cv.theme` value, pass it via `--theme`.

4. **Update Tracker**:
- If this is a new application, create a TSV entry in `batch/tracker-additions/` and run `node merge-tracker.mjs`.
- If it's an existing application in `data/applications.md`, update the `PDF` column from ❌ to ✅.

## JSON Resume Mapping Guide (if generating)

| JSON Field | Source in Career-Ops |
| ----------- | --------------------------------------------------------------- |
| `basics` | `config/profile.yml` (name, email, phone, website, location) |
| `work` | `cv.md` -> Experience section. Map each role to a `work` entry. |
| `education` | `cv.md` -> Education section. |
| `skills` | `cv.md` -> Technical Skills section. |
| `projects` | `cv.md` -> Projects section. |

## Why use this mode?

- **Better Quality**: Uses a specialized multi-step LLM pipeline for tailoring.
- **ATS-Optimized**: Generates clean PDFs via the `resumed` engine.
- **Standardized**: Produces a `resume.json` that can be used with other JSON Resume tools.

## Safety & Ethics

- NEVER invent experience or skills.
- Only reformulate existing content using JD keywords.
7 changes: 6 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
"dedup": "node dedup-tracker.mjs",
"merge": "node merge-tracker.mjs",
"pdf": "node generate-pdf.mjs",
"resume-ops": "node resume-ops.mjs",
"resume-ops:start": "node start-resume-ops.mjs",
"sync-check": "node cv-sync-check.mjs",
"update:check": "node update-system.mjs check",
"update": "node update-system.mjs apply",
Expand All @@ -35,6 +37,9 @@
"@google/generative-ai": "^0.24.1",
"dotenv": "^16.4.5",
"js-yaml": "^4.1.1",
"playwright": "^1.58.1"
"jsonresume-theme-stackoverflow": "^3.3.0",
"playwright": "^1.58.1",
"puppeteer": "^24.43.1",
"resumed": "^6.1.0"
}
}
91 changes: 91 additions & 0 deletions resume-ops.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import fs from 'fs';
import path from 'path';

async function pollTask(taskId) {
const url = `http://127.0.0.1:8000/api/v1/tasks/${taskId}`;
while (true) {
const response = await fetch(url);
if (!response.ok) throw new Error(`Polling failed: ${response.status}`);
const data = await response.json();
if (data.status === 'completed') return data;
if (data.status === 'failed') throw new Error(`Task failed: ${JSON.stringify(data.error)}`);
process.stdout.write('.');
await new Promise(r => setTimeout(r, 2000));
}
}
Comment on lines +4 to +15
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Validate taskId format before URL construction.

Line 5 interpolates taskId directly into the URL without validation. If the Resume Ops API returns a malicious taskId (e.g., containing ../ or special characters), it could lead to unintended endpoint access. While the service is local, validating the format adds defense in depth.

🛡️ Proposed fix to validate taskId
 async function pollTask(taskId) {
+  // Validate taskId format (alphanumeric, hyphens, underscores only)
+  if (!/^[a-zA-Z0-9_-]+$/.test(taskId)) {
+    throw new Error(`Invalid task ID format: ${taskId}`);
+  }
   const url = `http://127.0.0.1:8000/api/v1/tasks/${taskId}`;
   while (true) {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@resume-ops.mjs` around lines 4 - 15, The pollTask function interpolates
taskId into the URL without validation; validate and sanitize taskId before
constructing the URL by (1) enforcing an allowed pattern (e.g., UUID/hex or a
strict /^[A-Za-z0-9-]+$/ regex) and throwing an error for invalid values, and
(2) using encodeURIComponent on the validated taskId when building the URL;
update pollTask to run this check at the top (refer to function pollTask and the
taskId parameter) so malicious strings like "../" or special characters are
rejected or safely encoded before fetch is called.


async function main() {
const args = process.argv.slice(2);
const params = {};
for (let i = 0; i < args.length; i++) {
if (args[i].startsWith('--')) {
params[args[i].slice(2)] = args[i + 1];
i++;
}
}

if (!params.resume || !params.jd || !params.output) {
console.error('Usage: node resume-ops.mjs --resume <path> --jd <path|text> --output <path> [--theme <name>]');
process.exit(1);
}

let resume;
try {
resume = JSON.parse(fs.readFileSync(params.resume, 'utf8'));
} catch (e) {
console.error(`❌ Error reading resume: ${e.message}`);
process.exit(1);
}

let jd = params.jd;
if (fs.existsSync(jd)) {
jd = fs.readFileSync(jd, 'utf8');
}

console.log('📡 Sending tailoring request to Resume Ops API...');

try {
const response = await fetch('http://127.0.0.1:8000/api/v1/tailor', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
resume,
job_description: jd,
theme: params.theme || 'jsonresume-theme-stackoverflow'
})
});

if (!response.ok) {
const err = await response.text();
throw new Error(`API Error: ${response.status} ${err}`);
}

let data = await response.json();

if (data.task_id && !data.pdf_base64) {
console.log(`⏳ Task queued (ID: ${data.task_id}). Polling for completion...`);
data = await pollTask(data.task_id);
console.log('\n✅ Task completed.');
}

if (data.pdf_base64) {
fs.mkdirSync(path.dirname(params.output), { recursive: true });
fs.writeFileSync(params.output, Buffer.from(data.pdf_base64, 'base64'));
console.log(`📄 PDF successfully saved to ${params.output}`);

// Also save the tailored JSON if available
if (data.resume) {
const jsonPath = params.output.replace('.pdf', '.json');
fs.writeFileSync(jsonPath, JSON.stringify(data.resume, null, 2));
console.log(`📝 Tailored JSON saved to ${jsonPath}`);
}
Comment on lines +77 to +81
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Use case-insensitive path handling for JSON output.

Line 78 uses .replace('.pdf', '.json') which is case-sensitive. If --output specifies a path with uppercase .PDF, the JSON file will be named incorrectly (e.g., resume.PDF.json instead of resume.json).

🔧 Proposed fix using proper path manipulation
       // Also save the tailored JSON if available
       if (data.resume) {
-        const jsonPath = params.output.replace('.pdf', '.json');
+        const parsed = path.parse(params.output);
+        const jsonPath = path.join(parsed.dir, parsed.name + '.json');
         fs.writeFileSync(jsonPath, JSON.stringify(data.resume, null, 2));
         console.log(`📝 Tailored JSON saved to ${jsonPath}`);
       }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@resume-ops.mjs` around lines 77 - 81, The code uses a case-sensitive string
replace on params.output to produce the JSON path; change this to use Node's
path utilities so extensions are handled case-insensitively and robustly:
import/require 'path' and compute jsonPath from path.parse(params.output) (e.g.,
const p = path.parse(params.output); const jsonPath = path.join(p.dir,
`${p.name}.json`)); then continue to call fs.writeFileSync(jsonPath,
JSON.stringify(data.resume, null, 2)) and log the jsonPath; reference symbols:
params.output, data.resume, fs.writeFileSync.

} else {
throw new Error('No PDF content received from API');
}
} catch (err) {
console.error(`❌ Error: ${err.message}`);
process.exit(1);
}
Comment on lines +45 to +88
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

Consider adding request timeout for external API calls.

The fetch call to the Resume Ops API (line 48) and polling loop (lines 6-14) have no timeout configuration. If the service hangs or becomes unresponsive, the script will wait indefinitely, leading to poor user experience.

⏱️ Proposed fix to add timeout handling
   console.log('📡 Sending tailoring request to Resume Ops API...');
   
   try {
-    const response = await fetch('http://127.0.0.1:8000/api/v1/tailor', {
+    const controller = new AbortController();
+    const timeoutId = setTimeout(() => controller.abort(), 60000); // 60s timeout
+    const response = await fetch('http://127.0.0.1:8000/api/v1/tailor', {
       method: 'POST',
       headers: { 'Content-Type': 'application/json' },
       body: JSON.stringify({
         resume,
         job_description: jd,
         theme: params.theme || 'jsonresume-theme-stackoverflow'
-      })
+      }),
+      signal: controller.signal
     });
+    clearTimeout(timeoutId);

     if (!response.ok) {

Also add a maximum iteration limit to pollTask:

 async function pollTask(taskId) {
   if (!/^[a-zA-Z0-9_-]+$/.test(taskId)) {
     throw new Error(`Invalid task ID format: ${taskId}`);
   }
   const url = `http://127.0.0.1:8000/api/v1/tasks/${taskId}`;
+  let attempts = 0;
+  const MAX_ATTEMPTS = 60; // 2 minutes with 2s intervals
   while (true) {
+    if (attempts++ >= MAX_ATTEMPTS) {
+      throw new Error('Polling timeout: task did not complete in time');
+    }
     const response = await fetch(url);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@resume-ops.mjs` around lines 45 - 88, Add request timeouts and a max-poll
limit: wrap the Resume Ops POST fetch in an AbortController with a configurable
timeout (e.g., 10–30s) and abort the request if exceeded, ensuring the error
bubbles up to the existing catch; likewise update the pollTask function to
accept a maxAttempts and/or totalTimeout and stop polling after that limit (or
when the total timeout elapses), returning a clear error if the task never
completes. Reference the fetch call in the main block (the POST to
'/api/v1/tailor') and the pollTask(task_id) function; ensure both paths clean up
the AbortController timers and throw a descriptive Error so the existing catch
logs and exits.

}

main();
3 changes: 3 additions & 0 deletions scratch/test-jd.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
We are looking for a Senior Product Manager with experience in AI and automation.
The ideal candidate has experience with Python, LLMs, and high-quality resume generation.
Key skills: Product Management, AI, Python, REST APIs.
125 changes: 125 additions & 0 deletions start-resume-ops.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { spawn, spawnSync } from 'child_process';
import net from 'net';
import path from 'path';
import fs from 'fs';
import { fileURLToPath } from 'url';

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const PORT = 8000;
const RESUME_OPS_DIR = path.resolve(__dirname, 'resume-ops');
const RESUME_OPS_REPO = 'https://gitlab.com/CovaiLabs/resume-ops.git';

function checkPort(port) {
return new Promise((resolve) => {
const socket = new net.Socket();
socket.setTimeout(1000);
socket.on('connect', () => {
socket.destroy();
resolve(true);
});
socket.on('timeout', () => {
socket.destroy();
resolve(false);
});
socket.on('error', () => {
socket.destroy();
resolve(false);
});
socket.connect(port, '127.0.0.1');
});
}

async function main() {
const isRunning = await checkPort(PORT);
if (isRunning) {
console.log(`✅ Resume Ops is already running on port ${PORT}.`);
return;
}

// Check if resume-ops directory exists
if (!fs.existsSync(RESUME_OPS_DIR)) {
console.log(`📂 resume-ops directory not found. Cloning from ${RESUME_OPS_REPO}...`);
try {
spawnSync('git', ['clone', RESUME_OPS_REPO, RESUME_OPS_DIR], { stdio: 'inherit' });
console.log('✅ Clone complete.');

// Initialize .env if missing
const envPath = path.join(RESUME_OPS_DIR, '.env');
if (!fs.existsSync(envPath)) {
console.log('📝 Initializing .env from .env.example...');
const exampleEnv = fs.readFileSync(path.join(RESUME_OPS_DIR, '.env.example'), 'utf8');
let localEnv = exampleEnv.replace('DATA_DIR=/data', 'DATA_DIR=./data');

// Sync common AI keys from the current environment
const keysToSync = ['OPENAI_API_KEY', 'ANTHROPIC_API_KEY', 'GEMINI_API_KEY', 'OPENAI_BASE_URL'];
keysToSync.forEach(key => {
if (process.env[key]) {
console.log(`🔑 Syncing ${key} from environment...`);
// Replace the empty or placeholder value
localEnv = localEnv.replace(new RegExp(`${key}=.*`), `${key}=${process.env[key]}`);
}
});
Comment on lines +54 to +61
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Escape special characters when syncing API keys to .env.

Line 59 uses process.env[key] directly in a regex replacement string. If an API key contains characters with special meaning in regex replacements (e.g., $, \), they will be misinterpreted, potentially corrupting the .env file.

🔒 Proposed fix to escape replacement string
        keysToSync.forEach(key => {
          if (process.env[key]) {
            console.log(`🔑 Syncing ${key} from environment...`);
-           // Replace the empty or placeholder value
-           localEnv = localEnv.replace(new RegExp(`${key}=.*`), `${key}=${process.env[key]}`);
+           // Replace the empty or placeholder value (escape special regex chars in value)
+           const escapedValue = process.env[key].replace(/\$/g, '$$$$');
+           localEnv = localEnv.replace(new RegExp(`${key}=.*`), `${key}=${escapedValue}`);
          }
        });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const keysToSync = ['OPENAI_API_KEY', 'ANTHROPIC_API_KEY', 'GEMINI_API_KEY', 'OPENAI_BASE_URL'];
keysToSync.forEach(key => {
if (process.env[key]) {
console.log(`🔑 Syncing ${key} from environment...`);
// Replace the empty or placeholder value
localEnv = localEnv.replace(new RegExp(`${key}=.*`), `${key}=${process.env[key]}`);
}
});
const keysToSync = ['OPENAI_API_KEY', 'ANTHROPIC_API_KEY', 'GEMINI_API_KEY', 'OPENAI_BASE_URL'];
keysToSync.forEach(key => {
if (process.env[key]) {
console.log(`🔑 Syncing ${key} from environment...`);
// Replace the empty or placeholder value (escape special regex chars in value)
const escapedValue = process.env[key].replace(/\$/g, '$$$$');
localEnv = localEnv.replace(new RegExp(`${key}=.*`), `${key}=${escapedValue}`);
}
});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@start-resume-ops.mjs` around lines 54 - 61, The replacement uses
process.env[key] directly in localEnv.replace(new RegExp(`${key}=.*`),
`${key}=${process.env[key]}`) which can corrupt the .env when values contain
regex-replacement metacharacters; change the replacement to use a safe replacer
that injects the raw value (e.g., pass a function as the second argument or
escape the replacement string) so the matched line is replaced with `${key}=` +
the literal environment value; update the keysToSync loop to compute const value
= process.env[key] and perform localEnv = localEnv.replace(new
RegExp(`${key}=.*`), () => `${key}=${value}`) to ensure special characters in
process.env[key] are not interpreted.


fs.writeFileSync(envPath, localEnv);
}

// Create data directories proactively
const dataDir = path.join(RESUME_OPS_DIR, 'data');
const jobsDir = path.join(dataDir, 'jobs');
if (!fs.existsSync(jobsDir)) {
console.log('📂 Creating resume-ops data directories...');
fs.mkdirSync(jobsDir, { recursive: true });
}
} catch (err) {
console.error(`❌ Failed to clone resume-ops: ${err.message}`);
process.exit(1);
}
}

// Check if resumed is installed
const hasResumed = spawnSync('which', ['resumed']).status === 0 || fs.existsSync(path.resolve(__dirname, 'node_modules', '.bin', 'resumed'));
if (!hasResumed) {
console.log('📦 resumed (PDF engine) not found. Installing locally...');
try {
spawnSync('npm', ['install', 'resumed'], { cwd: __dirname, stdio: 'inherit' });
console.log('✅ resumed installed.');
} catch (err) {
console.error(`❌ Failed to install resumed: ${err.message}`);
// Don't exit, maybe it's already there but which failed
}
}

// Ensure node_modules/.bin is in PATH for the child process
const env = { ...process.env };
const binPath = path.resolve(__dirname, 'node_modules', '.bin');
env.PATH = `${binPath}:${env.PATH}`;

console.log(`🚀 Starting Resume Ops service in ${RESUME_OPS_DIR}...`);

const logFile = path.resolve(__dirname, 'resume-ops.log');
const out = fs.openSync(logFile, 'a');
const err = fs.openSync(logFile, 'a');

const child = spawn('uv', ['run', 'python', '-m', 'resume_ops_api'], {
cwd: RESUME_OPS_DIR,
stdio: ['ignore', out, err],
detached: true,
env,
});

child.unref();

console.log('⏳ Waiting for service to initialize...');
for (let i = 0; i < 10; i++) {
await new Promise(r => setTimeout(r, 1000));
if (await checkPort(PORT)) {
console.log(`✅ Resume Ops service is now ready on port ${PORT}.`);
return;
}
}

console.error('❌ Failed to start Resume Ops service. Please start it manually with `uv run python -m resume_ops_api` in the resume-ops directory.');
process.exit(1);
}

main();