#!/usr/bin/env node /** * match-star.mjs — Zero-LLM, zero-browser ATS behavioural question matcher. * * Parses interview-prep/story-bank.md, scores each STAR story against the * question text (and optionally a JD file), and returns the top match(es) * formatted to ATS paste length (250–500 words). * * The answer is meant to be pasted as-is, so its figures are checked here * rather than left to whoever reads it (AGENTS.md, Source-of-Truth Boundary): * a story marked `**Provenance:** user-cannot-confirm` has every number * replaced by a placeholder, and every other figure that does not trace to * cv.md gets a ⚠️ line, using story-provenance-check.mjs's classifier. * * Usage: * node match-star.mjs "Tell me about a time you led a project under pressure" * node match-star.mjs "Describe a conflict you resolved" --jd jds/acme.md * node match-star.mjs "Give an example of handling ambiguity" --top 2 * node match-star.mjs --list # list all stories with their tags */ import { readFileSync, existsSync } from 'fs'; import { isMainModule } from './lib/is-main-module.mjs'; import { join } from 'path'; import { getCareerOpsRoot } from './path-resolver.mjs'; import { parseStories, splitStoryBlocks, isValidStory } from './lib/story-bank.mjs'; import { parseStoryBlocks, classifyEntries } from './story-provenance-check.mjs'; import { flagValue, safeIntFlag, validateFlags } from './lib/cli-flags.mjs'; // ── Config ────────────────────────────────────────────────────────── // From the data root, not the cwd. interview-prep/ is USER_PATHS in // update-system.mjs, and a bare relative path resolves against wherever the // process was started — so this read the checkout's story bank (usually absent) // for anyone whose data lives elsewhere. const STORY_BANK_PATH = join(getCareerOpsRoot(), 'interview-prep', 'story-bank.md'); const CV_PATH = join(getCareerOpsRoot(), 'cv.md'); const UNCONFIRMED = '[unconfirmed figure]'; const USAGE = `Usage: node match-star.mjs "" [--jd ] [--top ] node match-star.mjs --list --jd Also score the stories against this JD --top Print the n best matches (default 1) --list List the stories in the bank with their tags --help, -h Show this help`; const KNOWN_FLAGS = ['--jd', '--top', '--list', '--help', '-h']; const VALUE_FLAGS = ['--jd', '--top']; // flagValue() reads `--jd=jds/acme.md` as well as `--jd jds/acme.md`. The // indexOf() lookup this replaces saw only the second, so the first ran without // the JD, and `--top=2` printed one match. const args = process.argv.slice(2); const LIST_MODE = args.includes('--list'); const jdPath = flagValue(args, '--jd') ?? null; const TOP_N = safeIntFlag(flagValue(args, '--top'), 1) || 1; // Exclude flag operands by index position, not by value, to preserve repeated text in the question const excludeIdx = new Set(args.flatMap((a, i) => (VALUE_FLAGS.includes(a) ? [i + 1] : []))); const question = args .filter((a, i) => !a.startsWith('--') && !excludeIdx.has(i)) .join(' ').trim(); // ── Parser ─────────────────────────────────────────────────────────── // parseStories lives in lib/story-bank.mjs so every story-bank reader shares // one definition of a story (#4514). Re-exported below for existing callers. // ── Scoring ────────────────────────────────────────────────────────── /** * Tokenize text into lowercase words, stripping punctuation. * @param {string} text * @returns {string[]} */ function tokenize(text) { // Letters, marks and digits of ANY script. The previous `[^a-z0-9\s]` class // deleted every non-Latin character, so a story bank written in Russian, // Hindi, Greek or Ukrainian tokenized to [] and scored 0 against a question // in the same language — the matcher was inert, not degraded, for anyone whose // `language.output` is not English (#2847). // // \p{M} is included deliberately: without it Devanagari matras become spaces // and shatter a word into fragments that match nothing (the mistake caught in // #2781's review of the sibling role tokenizer). return text.toLowerCase().replace(/[^\p{L}\p{M}\p{N}\s]/gu, ' ').split(/\s+/).filter(Boolean); } const STOPWORDS = new Set([ 'a','an','the','and','or','but','in','on','at','to','for','of','with', 'you','me','my','your','i','we','they','it','is','was','were','are', 'be','been','have','had','has','do','did','does','tell','about','time', 'when','how','give','example','describe','situation','where','what', ]); /** * Score a story against a question + optional JD text. * Higher = better match. * @param {object} story * @param {string[]} queryTokens * @param {string[]} jdTokens * @returns {number} */ function score(story, queryTokens, jdTokens) { const signal = queryTokens.filter(t => !STOPWORDS.has(t)); let s = 0; // Tag match: highest weight (tags are explicit "best for" labels). // Tokenized exact membership (mirrors the JD-boost path below) so short query // tokens (ai, ml, go, qa…) can't spuriously collide inside longer tag words. const tagTokens = new Set(story.tags.flatMap(tokenize)); for (const token of signal) { if (tagTokens.has(token)) s += 3; } // Title/theme match const titleTokens = tokenize(story.title + ' ' + story.theme); for (const token of signal) { if (titleTokens.includes(token)) s += 2; } // Action + result match (the most substantive parts) const bodyTokens = tokenize(story.action + ' ' + story.result); for (const token of signal) { if (bodyTokens.includes(token)) s += 1; } // JD boost: stopword-filtered JD tokens matched against tokenized tags (exact token overlap) if (jdTokens.length > 0) { const jdSignal = new Set(jdTokens.filter(t => !STOPWORDS.has(t))); for (const tag of story.tags) { const tagTokens = tokenize(tag); if (tagTokens.some(t => jdSignal.has(t))) s += 2; } } return s; } // ── Formatter ──────────────────────────────────────────────────────── // A number with what makes it a figure: a currency sign before it, and a // magnitude, `%`, `+`, `x`, `h` or `/unit` rate after it ("$60/hr", "$40K", // "3.5x", "1,200+", "8h"). Not inside a word, so "Q3" and "B2B" stay. const FIGURE_RE = /(? ` ⚠️ "${c.claim}" is not in cv.md (derived-unverified): confirm it before pasting.`), ...buckets.supportedByResume.map((c) => ` ⚠️ "${c.claim}": cv.md supports the fact but not this figure (supportedByResume): confirm it before pasting.`), ]; } /** * Format a STAR story as ATS-ready prose (250–500 words). * @param {object} story - a parseStories() story, plus `provenance` (its * Provenance marker, lowercased, or null) * @param {string} question * @param {{cvText?: string}} [opts] - cv.md, to check the figures against * @returns {string} */ function formatAts(story, question, { cvText = '' } = {}) { const parts = []; if (story.situation) parts.push(story.situation); if (story.task) parts.push(story.task); if (story.action) parts.push(story.action); if (story.result) parts.push(story.result); if (story.reflection) parts.push(story.reflection); // Every field this prints, not only the prose: a figure in a title or a tag // is still a figure. Source is left as written: it says where the story // came from ("Report #042"), which is what the user needs to check it. const cannotConfirm = story.provenance === 'user-cannot-confirm'; const safe = (text) => (cannotConfirm ? maskFigures(text) : text); const joined = safe(parts.filter(Boolean).join(' ')); const wordArr = joined.split(/\s+/).filter(Boolean); // Enforce 500-word ceiling; prose below 250 words gets a warning const prose = wordArr.slice(0, 500).join(' '); const words = Math.min(wordArr.length, 500); const notice = wordArr.length < 250 ? '\n ⚠️ Under 250 words — consider expanding this story in story-bank.md.' : ''; return [ `— ${safe(story.title)}${story.theme ? ` [${safe(story.theme)}]` : ''}`, story.source ? ` Source: ${story.source}` : '', story.tags.length ? ` Tags: ${story.tags.map(safe).join(', ')}` : '', ...provenanceWarnings(story, prose, cvText), '', prose, '', ` (~${words} words)${notice}`, ].filter(s => s !== null).join('\n'); } // ── Exports (for test-all.mjs and other consumers) ─────────────────── export { parseStories, tokenize, score, formatAts, STOPWORDS }; // ── Main ───────────────────────────────────────────────────────────── if (isMainModule(import.meta.url)) { // Before the story bank is read: a mistyped flag was dropped and its value // joined the question, and --help without a bank printed the missing-bank error. validateFlags(args, KNOWN_FLAGS, USAGE, { valueFlags: VALUE_FLAGS, requireOperand: true }); if (!existsSync(STORY_BANK_PATH)) { console.error(`Error: ${STORY_BANK_PATH} not found.`); console.error('Run /career-ops interview-prep on a role first to populate your story bank.'); process.exit(1); } const content = readFileSync(STORY_BANK_PATH, 'utf-8'); // The checker's own reading of each story's marker, matched by heading line, // so a marker it honours is honoured here. const provenanceAt = new Map(parseStoryBlocks(content) .filter(e => e.kind === 'story').map(e => [e.line, e.provenance])); const stories = parseStories(content).map(s => ({ ...s, provenance: provenanceAt.get(s.line) ?? null })); const cvText = existsSync(CV_PATH) ? readFileSync(CV_PATH, 'utf-8') : ''; // Say what is in the file but cannot be read, so an empty or short bank is // never mistaken for the whole story (#4514). Never rewrites the file: it is // user data, and converting it is the agent's job, with the user's say-so. const { blocks, tableRows } = splitStoryBlocks(content); const invalid = blocks.filter(b => !isValidStory(b)); if (tableRows.length || invalid.length) { console.error('⚠️ story-bank.md has entries this matcher cannot read:'); if (tableRows.length) console.error(` - ${tableRows.length} table row(s) (lines ${tableRows.map(r => r.line).join(', ')})`); for (const b of invalid) console.error(` - "${b.title || '(untitled)'}" (line ${b.line}): no **A (Action):** line`); console.error(' Ask your agent to convert them to the format in templates/story-bank.template.md.\n'); } if (stories.length === 0) { console.error('No stories found in story-bank.md yet.'); console.error('Run /career-ops interview-prep on a role to add your first stories.'); process.exit(1); } if (LIST_MODE) { console.log(`\nStory Bank — ${stories.length} stories\n${'─'.repeat(40)}`); stories.forEach((s, i) => { console.log(`${i + 1}. ${s.title}${s.theme ? ` [${s.theme}]` : ''}`); if (s.tags.length) console.log(` Tags: ${s.tags.join(', ')}`); if (s.source) console.log(` ${s.source}`); console.log(''); }); process.exit(0); } if (!question) { console.error(USAGE); process.exit(1); } const queryTokens = tokenize(question); let jdTokens = []; if (jdPath) { if (!existsSync(jdPath)) { console.error(`Error: JD file not found at ${jdPath}`); process.exit(1); } jdTokens = tokenize(readFileSync(jdPath, 'utf-8')); } // Score and rank const ranked = stories .map(s => ({ story: s, score: score(s, queryTokens, jdTokens) })) .sort((a, b) => b.score - a.score) .slice(0, TOP_N); console.log(`\nATS Behavioural Question Matcher`); console.log('─'.repeat(40)); console.log(`Question: "${question}"`); if (jdPath) console.log(`JD: ${jdPath}`); console.log(`Stories: ${stories.length} in bank\n`); for (let i = 0; i < ranked.length; i++) { const { story, score: s } = ranked[i]; console.log(`${'─'.repeat(40)}`); console.log(`Match ${i + 1} of ${ranked.length} (score: ${s})\n`); console.log(formatAts(story, question, { cvText })); console.log(''); } if (ranked.length > 0 && ranked[0].score === 0) { console.log('⚠️ No strong match found. Consider adding a story to story-bank.md that covers this competency.'); } } // end CLI guard