Ctrl K
ComunidadGuías…

negotiation-roi.mjs

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env node
/**
 * negotiation-roi.mjs — ROI-based salary-negotiation talking-point generator
 *
 * `salary-gap.mjs` tells a candidate where an offer sits relative to market
 * (a comparison). This script gives them something to *say* instead: a draft
 * talking point anchored in a quantified, VERIFIED achievement from
 * `interview-prep/story-bank.md`, translated into an estimated dollar value.
 *
 * v1 safety gate (the whole point of this file — read before touching the
 * verification logic):
 *
 *   A story-bank.md numeric claim is used ONLY if the same number, with a
 *   matching unit, ALSO appears verbatim in cv.md — for a before/after claim,
 *   both numbers in the SAME cv.md sentence. If it doesn't, the claim is
 *   EXCLUDED from output entirely — not flagged, not included with a
 *   caveat. Pulling an unverified figure into words a candidate will say out
 *   loud in a negotiation is a higher-stakes place for a fabrication to land
 *   than an interview answer: if challenged and it doesn't hold up, the
 *   damage is to credibility and the offer, not just to one interview
 *   answer. This is deliberately the narrowest possible check — direct
 *   substring match, no fuzzy scoring, no new infrastructure.
 *
 *   A story whose `**Provenance:**` marker reads `user-cannot-confirm` is
 *   excluded before that check runs, whatever cv.md says: the user has said
 *   they don't know whether its figures hold, and AGENTS.md's confirmation
 *   invariant forbids turning that answer into a quantified claim. The marker
 *   is read through story-provenance-check.mjs's parseStoryBlocks(), so a
 *   marker the checker honours (including one below a table or heading
 *   inside the story) is honoured here too. That checker's four-bucket
 *   classification is not used as the gate itself: its `existing` bucket
 *   accepts a context match this gate deliberately does not.
 *
 * Second invariant: wage basis and task frequency are NEVER guessed. Both
 * must come from either (a) explicit text in the story-bank entry itself
 * (e.g. "$45/hr", "this happens weekly"), or (b) an explicit CLI flag
 * (`--wage`, `--frequency`/`--occurrences`). A claim missing either input is
 * reported as uncalculable with a clear reason — never silently assigned a
 * default.
 *
 * Numeric-claim extraction scope (deliberately not exhaustive — see
 * TIME_REDUCTION_RE / PERCENT_REDUCTION_RE below):
 *   - "N hours ... to M hours" (arrow, "down to", or bare "to"; the second
 *     unit may be omitted and inherits the first, e.g. "8 hours to 2")
 *   - "cut/reduced/decreased/dropped/shaved ... by X%"
 *   Only the hour-family unit (hour/hours/hr/hrs/h) is recognized; other
 *   units (days, minutes, FTEs) are out of scope for v1. Percent-reduction
 *   claims are extracted and verified but never calculable in v1 — turning a
 *   percentage into a dollar figure needs a baseline duration this script
 *   does not infer.
 *
 * Read-only / draft-only: never writes to any file, never sends or submits
 * anything. Prints a draft for the user to review themselves (AGENTS.md
 * "Ethical Use" — human always reviews before anything goes out).
 *
 * Run: node negotiation-roi.mjs                                  (JSON)
 *      node negotiation-roi.mjs --summary                        (human-readable)
 *      node negotiation-roi.mjs --wage 45 --frequency weekly     (supply missing inputs)
 *      node negotiation-roi.mjs --wage 45 --occurrences 52       (direct annual count)
 *      node negotiation-roi.mjs --self-test
 *
 * Issue #2949 — github.com/career-ops-hq/career-ops
 */

import { readFileSync, existsSync } from 'fs';
import { join } from 'path';
import { fileURLToPath } from 'url';
import { spawnSync } from 'child_process';
import { parseStories } from './lib/story-bank.mjs';
import { parseStoryBlocks } from './story-provenance-check.mjs';
import { flagValue, hasFlag } from './lib/cli-flags.mjs';
import { isMainModule } from './lib/is-main-module.mjs';
import { getCareerOpsRoot } from './path-resolver.mjs';

const DATA_ROOT = getCareerOpsRoot();
// BOTH from the data root, because the v1 safety gate compares them: a claim
// survives only if its number also appears verbatim in cv.md. Resolving the
// story bank from the CODE root and cv.md from the DATA root pointed the two
// halves of one comparison at two different installs — so for any configured
// data root the gate read a story bank that is not there, found no claims, and
// produced an empty draft that looks like "you have no quantified stories".
const STORY_BANK_PATH = join(DATA_ROOT, 'interview-prep', 'story-bank.md');
const CV_PATH = join(DATA_ROOT, 'cv.md');

// ── Frequency vocabulary ─────────────────────────────────────────────
// Occurrences per year, business-cadence convention (matches how a candidate
// would talk about a recurring work task, not calendar days).
const FREQUENCY_MAP = {
  daily: 260, weekly: 52, biweekly: 26, 'bi-weekly': 26,
  monthly: 12, quarterly: 4, annually: 1, yearly: 1,
};

// Ordered so the first match wins; longer/more-specific phrases first so
// "every other week" isn't swallowed by a looser "week" pattern.
const FREQUENCY_TEXT_PATTERNS = [
  { re: /\bbi-?weekly\b|\bevery\s+(?:other|two)\s+weeks?\b/i, key: 'biweekly' },
  { re: /\bdaily\b|\bevery\s+day\b|\beach\s+day\b/i, key: 'daily' },
  { re: /\bweekly\b|\bevery\s+week\b|\beach\s+week\b|\bonce\s+a\s+week\b/i, key: 'weekly' },
  { re: /\bmonthly\b|\bevery\s+month\b|\beach\s+month\b|\bonce\s+a\s+month\b/i, key: 'monthly' },
  { re: /\bquarterly\b|\bevery\s+quarter\b/i, key: 'quarterly' },
  { re: /\bannually\b|\byearly\b|\bevery\s+year\b|\bonce\s+a\s+year\b/i, key: 'annually' },
];

const WAGE_TEXT_RE = /\$\s?(\d+(?:\.\d+)?)\s*(?:\/|per\s+)\s*(?:hr|hour)\b/i;

// ── Numeric-claim extraction ─────────────────────────────────────────

// Second unit is OPTIONAL and inherits the first ("8 hours to 2" — the
// example this issue was filed with). `[^.\n\d]{0,40}?` keeps the connector
// short so unrelated numbers elsewhere in the sentence aren't bridged.
const TIME_REDUCTION_RE = /(\d+(?:\.\d+)?)\s*(hours?|hrs?|h)\b[^.\n\d]{0,40}?(?:down to|to|→|->|➞)\s*(\d+(?:\.\d+)?)\s*(hours?|hrs?|h)?\b/gi;

const PERCENT_REDUCTION_RE = /(?:cut|reduced?|decreased?|dropped|shaved)[^.\n]{0,60}?by\s+(\d+(?:\.\d+)?)\s*%/gi;

function sentenceAround(text, start, end) {
  const beforeDot = text.lastIndexOf('. ', start);
  const sStart = beforeDot === -1 ? 0 : beforeDot + 2;
  let sEnd = text.indexOf('. ', end);
  sEnd = sEnd === -1 ? text.length : sEnd + 1;
  return text.slice(sStart, sEnd).trim();
}

/**
 * Extract quantified numeric claims from one story's combined text.
 * @param {string} storyText - situation+task+action+result+reflection, joined.
 * @param {string} storyTitle
 * @returns {Array<object>} claim objects (not yet verified against cv.md)
 */
export function extractQuantifiedClaims(storyText, storyTitle) {
  const claims = [];
  const text = String(storyText || '');

  TIME_REDUCTION_RE.lastIndex = 0;
  let m;
  while ((m = TIME_REDUCTION_RE.exec(text)) !== null) {
    const beforeVal = parseFloat(m[1]);
    const beforeUnit = m[2];
    const afterVal = parseFloat(m[3]);
    const afterUnit = m[4] || beforeUnit; // inherited when omitted
    claims.push({
      story: storyTitle,
      type: 'time-reduction',
      raw: m[0].trim(),
      sentence: sentenceAround(text, m.index, m.index + m[0].length),
      before: { value: beforeVal, unit: beforeUnit },
      after: { value: afterVal, unit: afterUnit },
    });
  }

  PERCENT_REDUCTION_RE.lastIndex = 0;
  while ((m = PERCENT_REDUCTION_RE.exec(text)) !== null) {
    claims.push({
      story: storyTitle,
      type: 'percent-reduction',
      raw: m[0].trim(),
      sentence: sentenceAround(text, m.index, m.index + m[0].length),
      percent: parseFloat(m[1]),
    });
  }

  return claims;
}

// ── Verification gate (the v1 safety boundary) ───────────────────────

const HOUR_UNIT_FAMILY = ['hour', 'hours', 'hr', 'hrs', 'h'];

function numberVariants(value, unitFamily) {
  const num = String(value);
  const variants = [];
  for (const u of unitFamily) {
    variants.push(`${num} ${u}`);
    variants.push(`${num}${u}`);
  }
  return variants;
}

function escapeRegExp(s) {
  return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}

// Boundary-aware match: a numeric variant like "8 hours" must NOT match
// inside "18 hours", and "60%" must NOT match inside "160%". Plain substring
// matching (`includes`) is unbounded and lets a candidate's story-bank claim
// "verify" against an unrelated, larger number in cv.md — the exact
// fabrication-into-negotiation gap this gate exists to close. A negative
// lookbehind/lookahead for an adjacent digit (or decimal point) rules that
// out while still matching the variant anywhere else in the text.
function anyVariantInText(variants, text) {
  const hay = text.toLowerCase();
  return variants.some((v) => {
    const escaped = escapeRegExp(v.toLowerCase());
    const re = new RegExp(`(?<![\\d.])${escaped}(?![\\d.])`, 'i');
    return re.test(hay);
  });
}

// A sentence ends at a line break or at `.`/`!`/`?` followed by whitespace,
// so "1.5 hours" stays in one piece.
function cvSentences(text) {
  return text.split(/\n+|(?<=[.!?])\s+/).filter((s) => s.trim());
}

/**
 * Verify a claim's number(s) appear verbatim (with a matching unit) in cv.md.
 * A time reduction needs both numbers in ONE cv.md sentence: "8 hours of
 * on-call coverage" in one bullet and "a 2 hours workshop" in another are two
 * unrelated facts, not a reduction from 8 hours to 2.
 * Returns { verified: boolean, checkedVariants: string[] } — never mutates
 * the claim, never partially includes an unverified figure.
 * @param {object} claim
 * @param {string} cvText
 */
export function verifyClaim(claim, cvText) {
  const text = String(cvText || '');
  if (claim.type === 'time-reduction') {
    const beforeVariants = numberVariants(claim.before.value, HOUR_UNIT_FAMILY);
    const afterVariants = numberVariants(claim.after.value, HOUR_UNIT_FAMILY);
    const verified = cvSentences(text).some((sentence) =>
      anyVariantInText(beforeVariants, sentence) && anyVariantInText(afterVariants, sentence));
    return { verified, checkedVariants: [...beforeVariants, ...afterVariants] };
  }
  if (claim.type === 'percent-reduction') {
    const variants = [`${claim.percent}%`, `${claim.percent} %`];
    return { verified: anyVariantInText(variants, text), checkedVariants: variants };
  }
  return { verified: false, checkedVariants: [] };
}

// ── ROI calculation ───────────────────────────────────────────────────

/**
 * Resolve occurrences/year for one claim: story text > CLI flag. Never guesses.
 * @returns {{ occurrencesPerYear: number, source: string } | null}
 */
export function resolveFrequency(storyText, opts) {
  for (const { re, key } of FREQUENCY_TEXT_PATTERNS) {
    if (re.test(storyText)) {
      return { occurrencesPerYear: FREQUENCY_MAP[key], source: `story text ("${key}")` };
    }
  }
  if (opts.occurrencesPerYear != null) {
    return { occurrencesPerYear: opts.occurrencesPerYear, source: 'CLI --occurrences' };
  }
  if (opts.frequency && FREQUENCY_MAP[opts.frequency] != null) {
    return { occurrencesPerYear: FREQUENCY_MAP[opts.frequency], source: `CLI --frequency ${opts.frequency}` };
  }
  return null;
}

/**
 * Resolve an hourly wage basis for one claim: story text > CLI flag. Never guesses.
 * @returns {{ hourlyWage: number, source: string } | null}
 */
export function resolveWage(storyText, opts) {
  const m = storyText.match(WAGE_TEXT_RE);
  if (m) return { hourlyWage: parseFloat(m[1]), source: `story text ("$${m[1]}/hr")` };
  if (opts.wage != null) return { hourlyWage: opts.wage, source: 'CLI --wage' };
  return null;
}

/**
 * Compute the full, visible calculation for one verified time-reduction claim.
 * Returns null (never a guessed number) when any required input is missing.
 * @param {object} claim
 * @param {string} storyText
 * @param {object} opts - { wage, frequency, occurrencesPerYear }
 */
export function computeCalculation(claim, storyText, opts) {
  if (claim.type !== 'time-reduction') return { calculation: null, reason: 'percent-only claim: no time baseline to convert into hours saved (out of scope for v1)' };

  const hoursSaved = claim.before.value - claim.after.value;
  if (!(hoursSaved > 0)) {
    return { calculation: null, reason: `not a time reduction (before ${claim.before.value}h is not greater than after ${claim.after.value}h)` };
  }

  const wageResolved = resolveWage(storyText, opts);
  if (!wageResolved) {
    return { calculation: null, reason: 'missing hourly-wage basis — supply --wage <number> or state it in the story ("$X/hr")' };
  }

  const freqResolved = resolveFrequency(storyText, opts);
  if (!freqResolved) {
    return { calculation: null, reason: 'missing task frequency — supply --frequency <daily|weekly|biweekly|monthly|quarterly|annually> / --occurrences <N>, or state it in the story ("this happens weekly")' };
  }

  const annualValue = hoursSaved * wageResolved.hourlyWage * freqResolved.occurrencesPerYear;
  return {
    calculation: {
      hoursSavedPerOccurrence: hoursSaved,
      hourlyWage: wageResolved.hourlyWage,
      wageSource: wageResolved.source,
      occurrencesPerYear: freqResolved.occurrencesPerYear,
      frequencySource: freqResolved.source,
      annualValue,
      formula: `${hoursSaved}h × $${wageResolved.hourlyWage}/hr × ${freqResolved.occurrencesPerYear}/year = $${annualValue.toLocaleString()}/year`,
    },
    reason: null,
  };
}

/**
 * Parse a CLI flag that must be a positive, finite number (e.g. --wage,
 * --occurrences). `Number()` rejects trailing garbage like "40usd" (returns
 * NaN for the whole string, unlike `parseFloat()` which would silently parse
 * a leading numeric prefix); combined with the finite/positive check this
 * also rejects negative values, zero, and Infinity/-Infinity.
 * @param {string|undefined} raw
 * @param {string} label - flag name for the error message, e.g. "--wage"
 * @returns {{ value: number|null, error: string|null }}
 */
export function parsePositiveNumberFlag(raw, label) {
  if (raw === undefined) return { value: null, error: null };
  const value = Number(raw);
  if (!Number.isFinite(value) || value <= 0) {
    return { value: null, error: `${label} must be a positive finite number, got "${raw}"` };
  }
  return { value, error: null };
}

/**
 * Draft talking-point paragraph. Achievement text is quoted from the source
 * (story-bank/cv.md), never invented. Always ends with the transferability
 * reminder — the script computes arithmetic only, it does not vouch for the
 * analogy holding in the target role.
 */
export function buildDraftParagraph(claim, calc) {
  const parts = [
    `Achievement: ${claim.sentence}`,
    `Estimated value: ${calc.formula}`,
    'Note: this is arithmetic only — it does not judge whether this achievement\'s context (company size, industry, scale) transfers to the target role. That judgment is yours to make before using this number in a real conversation.',
  ];
  return parts.join('\n');
}

// ── Orchestration (the testable core) ─────────────────────────────────

/**
 * Full analysis: extract -> verify -> calculate -> draft. Pure function over
 * its inputs so self-test can exercise it without touching disk.
 * @param {string} storyBankText
 * @param {string} cvText
 * @param {object} opts - { wage: number|null, frequency: string|null, occurrencesPerYear: number|null }
 */
export function analyze(storyBankText, cvText, opts = {}) {
  const stories = parseStories(storyBankText);
  const provenanceAt = new Map(parseStoryBlocks(storyBankText)
    .filter((e) => e.kind === 'story').map((e) => [e.line, e.provenance]));
  const warnings = [];

  let claimsFound = 0;
  let excludedUnverified = 0;
  let excludedCannotConfirm = 0;
  const calculable = [];
  const uncalculable = [];

  for (const story of stories) {
    const storyText = [story.situation, story.task, story.action, story.result, story.reflection]
      .filter(Boolean).join(' ');
    const storyLabel = story.theme ? `[${story.theme}] ${story.title}` : story.title;
    const claims = extractQuantifiedClaims(storyText, storyLabel);
    claimsFound += claims.length;

    // Before the cv.md gate, not after it: a figure the user said they can't
    // confirm stays out even when cv.md happens to carry the same numbers.
    if (provenanceAt.get(story.line) === 'user-cannot-confirm') {
      excludedCannotConfirm += claims.length;
      continue;
    }

    for (const claim of claims) {
      const { verified } = verifyClaim(claim, cvText);
      if (!verified) { excludedUnverified += 1; continue; } // v1 safety gate — silently excluded, not flagged

      const { calculation, reason } = computeCalculation(claim, storyText, opts);
      if (calculation) {
        calculable.push({
          story: claim.story,
          claimType: claim.type,
          achievement: claim.sentence,
          calculation,
          draftParagraph: buildDraftParagraph(claim, calculation),
        });
      } else {
        uncalculable.push({ story: claim.story, claimType: claim.type, achievement: claim.sentence, reason });
      }
    }
  }

  if (excludedUnverified > 0) {
    warnings.push(`${excludedUnverified} quantified claim${excludedUnverified === 1 ? '' : 's'} found in story-bank.md ${excludedUnverified === 1 ? 'was' : 'were'} excluded: no matching figure found verbatim in cv.md (v1 safety gate).`);
  }
  if (excludedCannotConfirm > 0) {
    warnings.push(`${excludedCannotConfirm} quantified claim${excludedCannotConfirm === 1 ? '' : 's'} ${excludedCannotConfirm === 1 ? 'was' : 'were'} excluded: the story is marked **Provenance:** user-cannot-confirm, so its figures are never used as a talking point.`);
  }
  if (stories.length === 0) {
    warnings.push('No stories found in story-bank.md.');
  }

  return {
    storiesScanned: stories.length,
    claimsFound,
    verified: calculable.length + uncalculable.length,
    excludedUnverified,
    excludedCannotConfirm,
    calculable,
    uncalculable,
    warnings,
  };
}

// ── Self-test ───────────────────────────────────────────────────────

const STORY_BANK_FIXTURE = `
### [Process Improvement] Cut onboarding paperwork time

**Source:** Acme Corp, 2025

**S (Situation):** New-hire onboarding paperwork was a recurring bottleneck for the HR team.

**T (Task):** Reduce the manual processing time without adding headcount.

**A (Action):** I built a template automation that cut a recurring process from 8 hours to 2 hours per batch.

**R (Result):** The team reclaimed significant capacity.

**Reflection:** This freed up capacity for higher-value onboarding work.

**Best for questions about:** process improvement, automation

### [Cost Savings] Reduced vendor review cycle

**Source:** Acme Corp, 2025

**S (Situation):** Vendor contract review was slow and manual.

**T (Task):** Speed up the review cycle.

**A (Action):** I reduced review turnaround by 60% through a standardized checklist.

**R (Result):** Vendor onboarding sped up considerably.

**Reflection:** N/A

**Best for questions about:** efficiency

### [Unverifiable] Claim with no cv.md backing

**Source:** Acme Corp, 2025

**S (Situation):** A different recurring task existed.

**T (Task):** Improve it.

**A (Action):** I cut this task from 15 hours to 3 hours.

**R (Result):** Time saved.

**Reflection:** N/A

**Best for questions about:** efficiency

### [Missing Wage] Claim verified but no wage supplied

**Source:** Acme Corp, 2025

**S (Situation):** A recurring reporting task existed.

**T (Task):** Streamline it.

**A (Action):** I cut the reporting process from 10 hours to 4 hours, done daily.

**R (Result):** Reporting sped up.

**Reflection:** N/A

**Best for questions about:** efficiency

### [Missing Frequency] Claim verified but no frequency supplied or stated

**Source:** Acme Corp, 2025

**S (Situation):** A recurring audit task existed.

**T (Task):** Streamline it.

**A (Action):** I cut the audit process from 20 hours to 5 hours.

**R (Result):** Audits sped up.

**Reflection:** N/A

**Best for questions about:** efficiency
`;

// cv.md backs the FIRST and FOURTH and FIFTH claims' numbers exactly
// (8h/2h, 10h/4h, 20h/5h) but never mentions 15h or 3h — the "Unverifiable"
// story's numbers are deliberately absent so the safety-gate test has
// something real to exclude.
const CV_FIXTURE = `
# Yuting Sun

## Experience

- Cut onboarding paperwork processing from 8 hours to 2 hours per batch via a template automation.
- Reduced a manual reporting process from 10 hours to 4 hours through streamlining.
- Cut a recurring audit process from 20 hours to 5 hours.
`;

function selfTest() {
  const assert = (cond, msg) => {
    if (!cond) { console.error(`SELF-TEST FAIL: ${msg}`); process.exit(1); }
  };

  // extractQuantifiedClaims
  const claims = extractQuantifiedClaims('I cut a recurring process from 8 hours to 2. Then reduced errors by 60%.', 'Test Story');
  assert(claims.length === 2, `expected 2 claims, got ${claims.length}`);
  const trClaim = claims.find(c => c.type === 'time-reduction');
  assert(trClaim.before.value === 8 && trClaim.before.unit === 'hours', 'before = 8 hours');
  assert(trClaim.after.value === 2 && trClaim.after.unit === 'hours', 'omitted second unit inherits first (hours)');
  const pctClaim = claims.find(c => c.type === 'percent-reduction');
  assert(pctClaim.percent === 60, 'percent claim = 60');

  // verifyClaim
  const verifiedOk = verifyClaim({ type: 'time-reduction', before: { value: 8 }, after: { value: 2 } }, CV_FIXTURE);
  assert(verifiedOk.verified === true, '8h/2h verified against cv.md fixture');
  const verifiedFail = verifyClaim({ type: 'time-reduction', before: { value: 15 }, after: { value: 3 } }, CV_FIXTURE);
  assert(verifiedFail.verified === false, '15h/3h NOT in cv.md fixture -> excluded');

  // Boundary-aware matching (CodeRabbit finding #1 on PR #2950): a claim of
  // "8 hours" must NOT verify against cv.md text that only contains
  // "18 hours" (unbounded substring match would incorrectly say it does).
  // Same for "60%" not matching inside "160%".
  const boundaryHourFail = verifyClaim(
    { type: 'time-reduction', before: { value: 8 }, after: { value: 2 } },
    'The old process took 18 hours and now takes 12 hours.'
  );
  assert(boundaryHourFail.verified === false, '"8 hours" must not verify inside "18 hours" (substring collision)');
  const boundaryPercentFail = verifyClaim(
    { type: 'percent-reduction', percent: 60 },
    'Throughput improved by 160% last quarter.'
  );
  assert(boundaryPercentFail.verified === false, '"60%" must not verify inside "160%" (substring collision)');
  // Sanity: the same variants DO verify when genuinely present with a boundary.
  const boundaryHourOk = verifyClaim(
    { type: 'time-reduction', before: { value: 8 }, after: { value: 2 } },
    'Cut the process from 8 hours to 2 hours.'
  );
  assert(boundaryHourOk.verified === true, '"8 hours" still verifies against genuine "8 hours" text');

  // resolveFrequency / resolveWage — never guess
  assert(resolveFrequency('no frequency mentioned here', {}) === null, 'no frequency source -> null, not guessed');
  assert(resolveFrequency('this happens weekly', {}).occurrencesPerYear === 52, 'frequency read from story text');
  assert(resolveFrequency('nothing here', { frequency: 'monthly' }).occurrencesPerYear === 12, 'frequency read from CLI flag');
  assert(resolveWage('no wage mentioned', {}) === null, 'no wage source -> null, not guessed');
  assert(resolveWage('paid at $45/hr for this role', {}).hourlyWage === 45, 'wage read from story text');
  assert(resolveWage('nothing here', { wage: 50 }).hourlyWage === 50, 'wage read from CLI flag');

  // Full pipeline — golden test
  const result = analyze(STORY_BANK_FIXTURE, CV_FIXTURE, { wage: 40 }); // global wage fallback; frequency only from story text
  assert(result.storiesScanned === 5, `5 stories scanned, got ${result.storiesScanned}`);

  // Fixture 1: verified (8h/2h in cv.md) but this story states no frequency
  // and no --frequency/--occurrences flag was passed -> uncalculable.
  const onboarding = result.uncalculable.find(u => u.story.includes('Cut onboarding paperwork time'));
  assert(onboarding, 'onboarding claim verified but present in uncalculable (no frequency)');
  assert(/frequency/.test(onboarding.reason), `onboarding reason mentions frequency, got: ${onboarding.reason}`);

  // Fixture 2 (percent-reduction, 60%): verified? cv.md fixture never states
  // "60%" -> excluded by the safety gate, not present anywhere in output.
  assert(!result.calculable.some(c => c.story.includes('Reduced vendor review cycle')), 'unverified percent claim excluded from calculable');
  assert(!result.uncalculable.some(u => u.story.includes('Reduced vendor review cycle')), 'unverified percent claim excluded from uncalculable too — gate is silent exclusion');

  // Fixture 3: numbers (15h/3h) never appear in cv.md -> excluded entirely,
  // must not leak into calculable OR uncalculable. Core safety-gate test.
  assert(!result.calculable.some(c => c.story.includes('Unverifiable')), '15h/3h claim excluded from calculable');
  assert(!result.uncalculable.some(u => u.story.includes('Unverifiable')), '15h/3h claim excluded from uncalculable');
  assert(result.excludedUnverified >= 1, 'excludedUnverified count reflects the gated claim');

  // Fixture 4: verified (10h/4h in cv.md), story says "daily" -> frequency
  // resolved from story text, wage from CLI --wage 40 -> fully calculable.
  const reporting = result.calculable.find(c => c.story.includes('Missing Wage'));
  assert(reporting, 'reporting claim (10h/4h, daily, wage from CLI) is calculable');
  assert(reporting.calculation.hoursSavedPerOccurrence === 6, '10h - 4h = 6h saved');
  assert(reporting.calculation.occurrencesPerYear === 260, 'daily -> 260/year');
  assert(reporting.calculation.hourlyWage === 40, 'wage from CLI --wage fallback');
  assert(reporting.calculation.annualValue === 6 * 40 * 260, 'annual value = hours x wage x occurrences');
  assert(/does not judge whether this achievement/.test(reporting.draftParagraph), 'draft paragraph carries the transferability reminder');

  // Fixture 5: verified (20h/5h in cv.md) but no frequency stated in story
  // AND no CLI --frequency/--occurrences passed in this run -> uncalculable.
  const audit = result.uncalculable.find(u => u.story.includes('Missing Frequency'));
  assert(audit, 'audit claim verified but uncalculable (missing frequency)');
  assert(/frequency/.test(audit.reason), `audit reason mentions frequency, got: ${audit.reason}`);

  // Now supply --frequency explicitly and confirm the same claim becomes calculable.
  const result2 = analyze(STORY_BANK_FIXTURE, CV_FIXTURE, { wage: 40, frequency: 'quarterly' });
  const auditCalc = result2.calculable.find(c => c.story.includes('Missing Frequency'));
  assert(auditCalc, 'audit claim becomes calculable once --frequency is supplied');
  assert(auditCalc.calculation.occurrencesPerYear === 4, 'quarterly -> 4/year');
  assert(auditCalc.calculation.hoursSavedPerOccurrence === 15, '20h - 5h = 15h saved');

  // No wage at all -> every verified time-reduction claim is uncalculable for
  // the missing-wage reason, never a guessed dollar figure.
  const result3 = analyze(STORY_BANK_FIXTURE, CV_FIXTURE, {});
  assert(result3.calculable.length === 0, 'zero calculable claims with no wage basis anywhere');
  assert(result3.uncalculable.every(u => u.claimType !== 'time-reduction' || /wage/.test(u.reason)), 'every time-reduction claim blocked on missing wage, not silently defaulted');

  // parsePositiveNumberFlag (CodeRabbit finding #2 on PR #2950): malformed,
  // negative, zero, and non-finite CLI values must be rejected, not silently
  // truncated to a leading numeric prefix.
  assert(parsePositiveNumberFlag(undefined, '--wage').error === null, 'flag not passed -> no error, value null');
  assert(parsePositiveNumberFlag(undefined, '--wage').value === null, 'flag not passed -> value null');
  assert(parsePositiveNumberFlag('40', '--wage').value === 40, 'valid numeric string parses');
  assert(parsePositiveNumberFlag('40usd', '--wage').error !== null, '"40usd" rejected, not silently parsed as 40');
  assert(parsePositiveNumberFlag('-10', '--wage').error !== null, 'negative value rejected');
  assert(parsePositiveNumberFlag('Infinity', '--wage').error !== null, 'Infinity rejected');
  assert(parsePositiveNumberFlag('0', '--wage').error !== null, 'zero rejected');
  assert(parsePositiveNumberFlag('40usd', '--occurrences').error !== null, '"40usd" rejected for --occurrences too');
  assert(parsePositiveNumberFlag('-10', '--occurrences').error !== null, 'negative rejected for --occurrences too');
  assert(parsePositiveNumberFlag('Infinity', '--occurrences').error !== null, 'Infinity rejected for --occurrences too');

  // A standalone trailing --wage/--occurrences with no value must error, not
  // silently fall through to "flag absent" (CodeRabbit follow-up, PR #2950).
  // This lives in main()'s CLI parsing, which calls process.exit() on error,
  // so it has to be exercised as a real subprocess rather than in-process.
  const selfPath = fileURLToPath(import.meta.url);
  const trailingWage = spawnSync(process.execPath, [selfPath, '--wage'], { encoding: 'utf-8' });
  assert(trailingWage.status === 1, `trailing --wage with no value must exit 1, got ${trailingWage.status}`);
  assert(/--wage requires a value/.test(trailingWage.stderr || ''), 'trailing --wage error message names the flag');
  const trailingOcc = spawnSync(process.execPath, [selfPath, '--occurrences'], { encoding: 'utf-8' });
  assert(trailingOcc.status === 1, `trailing --occurrences with no value must exit 1, got ${trailingOcc.status}`);
  assert(/--occurrences requires a value/.test(trailingOcc.stderr || ''), 'trailing --occurrences error message names the flag');

  console.log('negotiation-roi self-test OK (extraction + verification gate + boundary matching + wage/frequency invariants + calculation + draft paragraph + CLI flag validation)');
}

// ── CLI ────────────────────────────────────────────────────────────────

function printSummary(result) {
  console.log('\nNEGOTIATION ROI — talking points anchored in verified achievements\n');
  console.log(`  Stories scanned: ${result.storiesScanned}`);
  console.log(`  Quantified claims found: ${result.claimsFound}`);
  console.log(`  Verified against cv.md: ${result.verified}`);
  console.log(`  Excluded (no verbatim match in cv.md): ${result.excludedUnverified}`);
  console.log(`  Excluded (story marked user-cannot-confirm): ${result.excludedCannotConfirm}`);

  if (result.calculable.length) {
    console.log('\n  Draft talking points:\n');
    for (const c of result.calculable) {
      console.log(`  ── ${c.story} ──`);
      console.log(`  ${c.draftParagraph.replace(/\n/g, '\n  ')}`);
      console.log('');
    }
  } else {
    console.log('\n  No calculable talking points yet.');
  }

  if (result.uncalculable.length) {
    console.log('  Verified but not calculable:');
    for (const u of result.uncalculable) {
      console.log(`    - ${u.story} (${u.claimType}): ${u.reason}`);
    }
    console.log('');
  }

  if (result.warnings.length) {
    console.log('  Warnings:');
    for (const w of result.warnings) console.log(`    ⚠ ${w}`);
    console.log('');
  }
}

function main() {
  const args = process.argv.slice(2);
  if (args.includes('--self-test')) { selfTest(); return; }

  // Argument validation runs before any filesystem/live-data requirement, so
  // a malformed flag fails fast with a clear error rather than being masked
  // by an unrelated "story-bank.md not found" message (also makes this path
  // testable without needing real user-layer fixtures on disk).
  //
  // A standalone trailing flag (e.g. `--wage` with nothing after it) makes
  // flagValue() return undefined — indistinguishable, on its own, from the
  // flag never being passed at all. hasFlag() sees the bare flag either way,
  // so hasFlag() true + flagValue() undefined means "present but missing its
  // value", which must error rather than silently fall through to "absent"
  // (CodeRabbit, PR #2950).
  const wageRaw = flagValue(args, '--wage');
  if (hasFlag(args, '--wage') && wageRaw === undefined) {
    console.error('Error: --wage requires a value.');
    process.exit(1);
  }
  const occRaw = flagValue(args, '--occurrences');
  if (hasFlag(args, '--occurrences') && occRaw === undefined) {
    console.error('Error: --occurrences requires a value.');
    process.exit(1);
  }

  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);
  }
  if (!existsSync(CV_PATH)) {
    console.error(`Error: ${CV_PATH} not found — this is a user-layer file, create it first.`);
    process.exit(1);
  }
  const wageParsed = parsePositiveNumberFlag(wageRaw, '--wage');
  if (wageParsed.error) {
    console.error(`Error: ${wageParsed.error}`);
    process.exit(1);
  }
  const wage = wageParsed.value;

  const frequency = flagValue(args, '--frequency') ?? null;
  if (frequency && !Object.hasOwn(FREQUENCY_MAP, frequency)) {
    console.error(`Error: --frequency must be one of ${Object.keys(FREQUENCY_MAP).join(', ')}, got "${frequency}"`);
    process.exit(1);
  }

  const occParsed = parsePositiveNumberFlag(occRaw, '--occurrences');
  if (occParsed.error) {
    console.error(`Error: ${occParsed.error}`);
    process.exit(1);
  }
  const occurrencesPerYear = occParsed.value;

  const storyBankText = readFileSync(STORY_BANK_PATH, 'utf-8');
  const cvText = readFileSync(CV_PATH, 'utf-8');
  const result = analyze(storyBankText, cvText, { wage, frequency, occurrencesPerYear });

  if (hasFlag(args, '--summary')) {
    printSummary(result);
  } else {
    console.log(JSON.stringify(result, null, 2));
  }
}

// Derived from the flags this file actually accepts, so `--help` cannot
// describe an option that does not exist.
const USAGE = `Usage:
  node negotiation-roi.mjs [--summary] [--wage <n>] [--frequency <unit>] [--occurrences <n>] [--self-test]

  --summary             human-readable output instead of JSON
  --wage <n>            wage used for the annualized estimate
  --frequency <unit>    daily|weekly|biweekly|monthly|quarterly|annually|yearly
  --occurrences <n>     occurrences per year, instead of --frequency
  --self-test           run the built-in checks
  --help, -h   print this and exit`;

if (isMainModule(import.meta.url)) {
  // BEFORE any work. Unhandled, `--help` fell through to the analysis: this
  // script printed a full report for it, which is not what the flag asks for
  // and hides that it was never recognised.
  if (process.argv.slice(2).some((a) => a === '--help' || a === '-h')) {
    console.log(USAGE);
    process.exit(0);
  }
  main();
}