Ctrl K
ComunidadGuías…

closing-soon.mjs

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

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env node
/**
 * closing-soon.mjs — surface tracker rows whose posting is about to close
 *
 * A posting's closing date has no structured home in the tracker, so people who
 * evaluate first and apply later type it into the free-text Notes cell
 * ("closes 2026-10-22", "deadline Nov 4"). This script reads those notes (and,
 * when a row's Notes carry no closing marker at all, the archived `jds/`
 * capture that `jd-capture.mjs` resolves by report number) and lists the rows
 * that close within N days, soonest first. Rows whose date has already passed
 * go in a separate `overdue` group; rows where a closing marker was found but
 * no date could be read go in `unparsed`, so nothing is silently dropped.
 *
 * Zero LLM. The report only reads. It writes nothing, and no tracker
 * column is added (issue #5412).
 *
 * Recognized dates, each introduced by `closes`, `closing date`, `closing`,
 * `deadline` or `apply by` (optionally followed by `:`, `on`, `by`, `is` and a
 * weekday name):
 *   - ISO              2026-10-22
 *   - Mon D, YYYY      Oct 22, 2026   (month names or 3-letter abbreviations)
 *   - Mon D            Oct 22         (this year, or next year when the date
 *                                      passed more than ROLLOVER_GRACE_DAYS ago)
 *   - MM/DD/YYYY       10/22/2026     (only when unambiguous: the second part
 *                                      is above 12, or both parts are equal)
 * Anything else after a marker (04/05/2026, 4 Nov 2026, "TBD") is reported as
 * `unparsed` rather than guessed.
 *
 * Run: node closing-soon.mjs                        (JSON to stdout)
 *      node closing-soon.mjs --summary              (human-readable table)
 *      node closing-soon.mjs --within 14            (window in days, default 7)
 *      node closing-soon.mjs --status Evaluated,Applied
 *      node closing-soon.mjs --today 2026-10-17     (deterministic runs/tests)
 *      node closing-soon.mjs --tracker path/to/applications.md --jds path/to/jds
 *      node closing-soon.mjs --clashes              (interview days with closings piling up)
 *      node closing-soon.mjs --clashes --clash-min 3 --interviews path/to/active-interviews.md
 *
 * --clashes is opt-in: it reads data/active-interviews.md (leading YYYY-MM-DD of
 * the Date/Time cell; rows in a terminal Status are skipped) and, for each
 * interview date inside the window, counts the closing-soon rows that close on
 * that day or the day before. At --clash-min (default 2) or more it adds a
 * `clashes` group. Without the flag the output is unchanged.
 */

import { readFileSync, existsSync } from 'fs';
import { join, dirname, resolve } from 'path';
import { fileURLToPath } from 'url';
import { loadYaml } from './lib/yaml-document.mjs';

import { resolveColumns, parseTrackerRow, linkedReportNumbers } from './tracker-parse.mjs';
import { findCaptureForReport, captureSlug } from './jd-capture.mjs';
import { flagValue, hasFlag, validateFlags } from './lib/cli-flags.mjs';
import { localToday } from './lib/local-today.mjs';
import { parseDate } from './lib/parse-date.mjs';
import { isMainModule } from './lib/is-main-module.mjs';
import { getCareerOpsRoot, resolveTrackerPath } from './path-resolver.mjs';
import { parseActiveInterviews, findColumn } from './process-quality.mjs';

const CODE_ROOT = dirname(fileURLToPath(import.meta.url));
const STATES_FILE = join(CODE_ROOT, 'templates/states.yml');

export const DEFAULT_WITHIN_DAYS = 7;
export const DEFAULT_STATUS = 'Evaluated';
export const DEFAULT_CLASH_MIN = 2;

/** Interview Status values that no longer hold a date (emoji prefixes tolerated). */
const TERMINAL_INTERVIEW_RE = /\b(?:rejected|declined|ghosted|done)\b/i;

/**
 * A `Mon D` date (no year) that already passed by more than this many days is
 * read as next year's occurrence ("closes Jan 5" typed in December). Inside the
 * grace it stays this year and shows up as overdue.
 */
export const ROLLOVER_GRACE_DAYS = 60;

/** Largest capture read into memory; a JD longer than this is not a note. */
const MAX_CAPTURE_BYTES = 256 * 1024;
const READABLE_CAPTURE_EXTS = new Set(['.md', '.txt', '.html', '.htm']);

const MS_PER_DAY = 86_400_000;

const MONTHS = {
  jan: 1, january: 1, feb: 2, february: 2, mar: 3, march: 3, apr: 4, april: 4,
  may: 5, jun: 6, june: 6, jul: 7, july: 7, aug: 8, august: 8,
  sep: 9, sept: 9, september: 9, oct: 10, october: 10,
  nov: 11, november: 11, dec: 12, december: 12,
};
const MONTH_RE = '(?:jan(?:uary)?|feb(?:ruary)?|mar(?:ch)?|apr(?:il)?|may|june?|july?|aug(?:ust)?|sept?(?:ember)?|oct(?:ober)?|nov(?:ember)?|dec(?:ember)?)';

// `closing date` is listed before `closing` so the longer form wins at the same
// position; `closed` is deliberately not a marker (it states a past fact, with
// no date a reader could act on).
const MARKER_RE = /\b(closes|closing\s+date|closing|deadline|apply\s+by)\b/gi;
const LEAD_IN_RE = new RegExp(
  '^\\s*[:=\\-\u2013\u2014]?\\s*(?:(?:on|by|is|at)\\s+)?'
  + '(?:(?:mon|tue|tues|wed|thu|thur|thurs|fri|sat|sun)[a-z]*\\.?,?\\s+)?',
  'i',
);
const ISO_RE = /^(\d{4})-(\d{2})-(\d{2})(?![\d-])/;
const MON_D_Y_RE = new RegExp(`^(${MONTH_RE})\\.?\\s+(\\d{1,2})(?:st|nd|rd|th)?\\s*,?\\s*(\\d{4})(?!\\d)`, 'i');
const MON_D_RE = new RegExp(`^(${MONTH_RE})\\.?\\s+(\\d{1,2})(?:st|nd|rd|th)?(?![\\d/])(?!\\s*,?\\s*\\d{3,})`, 'i');
const NUMERIC_RE = /^(\d{1,2})\/(\d{1,2})\/(\d{4})(?!\d)/;
const DATEISH_RE = new RegExp(`^(?:\\d|${MONTH_RE}\\b)`, 'i');

const pad = (n) => String(n).padStart(2, '0');
const isoDay = (d) => d.toISOString().slice(0, 10);
const daysBetween = (from, to) => Math.round((to.getTime() - from.getTime()) / MS_PER_DAY);

/** Calendar-valid UTC date for y/m/d, or null (2026-02-31, Feb 29 off-leap). */
function makeDate(y, m, d) {
  return parseDate(`${y}-${pad(m)}-${pad(d)}`);
}

/**
 * Read one date off the front of `tail` (the text just after a marker).
 *
 * @returns {{date: Date, form: string, length: number}|{unparsed: string}|null}
 *   null when the tail does not look like a date at all.
 */
function readDate(tail, today) {
  let m = ISO_RE.exec(tail);
  if (m) {
    const date = makeDate(m[1], m[2], m[3]);
    return date ? { date, form: 'iso' } : { unparsed: 'invalid calendar date' };
  }

  m = MON_D_Y_RE.exec(tail);
  if (m) {
    const date = makeDate(m[3], MONTHS[m[1].toLowerCase()], m[2]);
    return date ? { date, form: 'mon-d-yyyy' } : { unparsed: 'invalid calendar date' };
  }

  m = NUMERIC_RE.exec(tail);
  if (m) {
    const a = Number(m[1]);
    const b = Number(m[2]);
    // MM/DD/YYYY is only unambiguous when it cannot also be DD/MM/YYYY.
    if (a < 1 || a > 12 || b < 1 || b > 31) return { unparsed: 'not a valid MM/DD/YYYY date' };
    if (b <= 12 && a !== b) return { unparsed: 'ambiguous MM/DD vs DD/MM' };
    const date = makeDate(m[3], a, b);
    return date ? { date, form: 'mm-dd-yyyy' } : { unparsed: 'invalid calendar date' };
  }

  m = MON_D_RE.exec(tail);
  if (m) {
    const month = MONTHS[m[1].toLowerCase()];
    const day = Number(m[2]);
    const year = today.getUTCFullYear();
    let date = makeDate(year, month, day);
    if (!date) return { unparsed: 'invalid calendar date' };
    if (daysBetween(date, today) > ROLLOVER_GRACE_DAYS) {
      date = makeDate(year + 1, month, day);
      if (!date) return { unparsed: 'invalid calendar date' };
    }
    return { date, form: 'mon-d' };
  }

  return DATEISH_RE.test(tail) ? { unparsed: 'unrecognized date format' } : null;
}

/**
 * Find closing-date markers in free text and read the date after each.
 *
 * @param {string} text
 * @param {Date} today - UTC-midnight "today"; only used to place `Mon D`.
 * @returns {{dates: {date: string, form: string, marker: string}[],
 *            unparsed: {marker: string, text: string, reason: string}[]}}
 *   `dates` are in text order. A bare `closing` with no date-shaped text after
 *   it is not a marker ("closing the loop"); every other marker without a
 *   readable date is returned in `unparsed`.
 */
export function extractClosingDates(text, today, { strictMarkers = true } = {}) {
  const dates = [];
  const unparsed = [];
  const source = String(text ?? '').replace(/\s+/g, ' ');
  MARKER_RE.lastIndex = 0;
  let match;
  while ((match = MARKER_RE.exec(source)) !== null) {
    const marker = match[1].toLowerCase().replace(/\s+/g, ' ');
    const after = source.slice(match.index + match[0].length, match.index + match[0].length + 60);
    const tail = after.replace(LEAD_IN_RE, '');
    const read = readDate(tail, today);

    if (read && read.date) {
      dates.push({ date: isoDay(read.date), form: read.form, marker });
    } else if (read && read.unparsed) {
      unparsed.push({ marker, text: `${match[0]}${after}`.slice(0, 48).trim(), reason: read.unparsed });
    } else if (strictMarkers && marker !== 'closing') {
      unparsed.push({ marker, text: `${match[0]}${after}`.slice(0, 48).trim(), reason: 'no date after marker' });
    }
  }
  return { dates, unparsed };
}

/** Canonical states from templates/states.yml, as lookups to the label. */
export function loadStateLookup(statesFile = STATES_FILE) {
  const doc = loadYaml(readFileSync(statesFile, 'utf-8')) || {};
  const lookup = new Map();
  for (const s of doc.states || []) {
    const label = String(s.label);
    for (const key of [s.id, s.label, ...(s.aliases || [])]) {
      lookup.set(String(key).trim().toLowerCase(), label);
    }
  }
  return lookup;
}

/** `Evaluated, applied` -> Set{'Evaluated','Applied'}; throws on an unknown state. */
export function resolveStatusFilter(value, lookup = loadStateLookup()) {
  const labels = new Set();
  for (const part of String(value).split(',')) {
    const key = part.trim().toLowerCase();
    if (!key) continue;
    const label = lookup.get(key);
    if (!label) {
      throw new Error(`Unknown status "${part.trim()}". Canonical states: ${[...new Set(lookup.values())].join(', ')}.`);
    }
    labels.add(label);
  }
  if (labels.size === 0) throw new Error('--status needs at least one state.');
  return labels;
}

/** A tag longer than this is treated as plain text, which bounds the scan. */
const MAX_TAG_CHARS = 4096;

/**
 * Index just past the '>' that ends the tag opening at html[start], skipping
 * quoted attribute values, or -1 when it does not close within MAX_TAG_CHARS.
 */
function tagEnd(html, start) {
  const limit = Math.min(html.length, start + MAX_TAG_CHARS);
  let quote = '';
  for (let i = start + 1; i < limit; i++) {
    const c = html[i];
    if (quote) {
      if (c === quote) quote = '';
    } else if (c === '"' || c === "'") {
      quote = c;
    } else if (c === '>') {
      return i + 1;
    }
  }
  return -1;
}

/**
 * Flatten HTML to plain text for date matching only (never rendered): comments
 * and script/style blocks are dropped, tags (including quoted attribute values)
 * become spaces, and the few entities that matter are decoded. A single linear
 * pass, so hostile markup cannot trigger regex backtracking; best-effort, not
 * an HTML parser.
 */
export function htmlToText(html) {
  const lower = html.toLowerCase();
  const missing = new Set(); // closers already searched for and absent: never search again
  const indexOfOnce = (needle, from) => {
    if (missing.has(needle)) return -1;
    const at = lower.indexOf(needle, from);
    if (at === -1) missing.add(needle);
    return at;
  };
  // Closer of a script/style block: `</script` must end at a tag-name boundary,
  // so `</scripture>` does not close it.
  const rawClose = (name, from) => {
    const needle = '</' + name;
    let at = indexOfOnce(needle, from);
    while (at !== -1 && !/[\s/>]/.test(lower[at + needle.length] ?? '')) {
      at = lower.indexOf(needle, at + 1);
    }
    if (at === -1) missing.add(needle);
    return at;
  };
  let out = '';
  let i = 0;
  while (i < html.length) {
    const lt = html.indexOf('<', i);
    if (lt === -1) { out += html.slice(i); break; }
    out += html.slice(i, lt);
    i = lt;
    let end = -1;
    if (lower.startsWith('<!--', lt)) {
      const close = indexOfOnce('-->', lt + 4);
      if (close !== -1) end = close + 3;
    } else {
      const raw = /^<(script|style)(?![a-z0-9])/.exec(lower.slice(lt, lt + 10));
      if (raw) {
        const close = rawClose(raw[1], lt + 1);
        const gt = close === -1 ? -1 : lower.indexOf('>', close);
        if (gt !== -1) end = gt + 1;
      }
      if (end === -1 && /[a-z!/]/i.test(html[lt + 1] ?? '')) end = tagEnd(html, lt);
    }
    if (end === -1) { out += '<'; i = lt + 1; } else { out += ' '; i = end; }
  }
  return out.replace(/&nbsp;|&#160;/gi, ' ').replace(/&amp;/gi, '&');
}

function readCaptureText(jdsDir, row) {
  if (!jdsDir) return null;
  const nums = linkedReportNumbers(row.report, row.notes);
  const slug = captureSlug(row.company);
  // An unusable slug (e.g. the `?` placeholder employer) cannot vouch for a
  // capture, and a number alone has matched the wrong company before.
  if (nums.length !== 1 || slug === '') return null;
  const hit = findCaptureForReport(jdsDir, nums[0], { companySlug: slug });
  if (!hit || !READABLE_CAPTURE_EXTS.has(hit.ext.toLowerCase())) return null;
  try {
    const buf = readFileSync(hit.path);
    const text = buf.subarray(0, MAX_CAPTURE_BYTES).toString('utf-8');
    return /^\.html?$/i.test(hit.ext) ? htmlToText(text) : text;
  } catch {
    return null;
  }
}

/**
 * Compute the closing-soon report from tracker markdown.
 *
 * @param {string} trackerMd
 * @param {object} opts
 * @param {Date} opts.today - UTC-midnight Date.
 * @param {number} [opts.within] - Window in days (inclusive of today).
 * @param {Set<string>} opts.statuses - Canonical status labels to include.
 * @param {string|null} [opts.jdsDir] - Captures directory; null disables.
 */
export function computeClosingSoon(trackerMd, { today, within = DEFAULT_WITHIN_DAYS, statuses, jdsDir = null, lookup = loadStateLookup() }) {
  const lines = String(trackerMd || '').replace(/\r\n/g, '\n').split('\n');
  const colmap = resolveColumns(lines);
  const wanted = new Set([...statuses].map(s => s.toLowerCase()));

  const closingSoon = [];
  const overdue = [];
  const unparsed = [];
  let rowsChecked = 0;

  for (const line of lines) {
    const row = parseTrackerRow(line, colmap);
    if (!row) continue;
    const rawStatus = String(row.status || '').replace(/\*\*/g, '').trim();
    // Same states.yml lookup as --status, so an alias cell (e.g. `evaluada`) matches.
    const status = lookup.get(rawStatus.toLowerCase()) ?? rawStatus;
    if (!wanted.has(status.toLowerCase())) continue;
    rowsChecked++;

    let source = 'notes';
    let found = extractClosingDates(row.notes, today);
    // Notes win; the capture is consulted only when Notes carry no marker at
    // all, so a stale JD boilerplate "deadline" cannot contradict the user.
    if (found.dates.length === 0 && found.unparsed.length === 0) {
      const text = readCaptureText(jdsDir, row);
      if (text) {
        // JD prose ("deadline-driven team") is not a closing-date claim: only
        // date-shaped text that cannot be read is worth an `unparsed` entry.
        found = extractClosingDates(text, today, { strictMarkers: false });
        source = 'capture';
      }
    }

    const base = { num: row.num, company: row.company, role: row.role, status, source };
    if (found.dates.length === 0) {
      if (found.unparsed.length > 0) unparsed.push({ ...base, ...found.unparsed[0], markers: found.unparsed.length });
      continue;
    }

    // Several dates in one row: surface the earliest, since the cost of an
    // early warning is a glance and the cost of a late one is a lost posting.
    const sorted = [...found.dates].sort((a, b) => a.date.localeCompare(b.date));
    const first = sorted[0];
    const daysLeft = daysBetween(today, parseDate(first.date));
    const entry = { ...base, closes: first.date, daysLeft, marker: first.marker };
    const distinct = [...new Set(sorted.map(d => d.date))];
    if (distinct.length > 1) entry.allDates = distinct;

    if (daysLeft < 0) {
      const { daysLeft: _drop, ...rest } = entry;
      overdue.push({ ...rest, daysOverdue: -daysLeft });
    } else if (daysLeft <= within) {
      closingSoon.push(entry);
    }
  }

  closingSoon.sort((a, b) => a.daysLeft - b.daysLeft || a.num - b.num);
  overdue.sort((a, b) => a.daysOverdue - b.daysOverdue || a.num - b.num);
  unparsed.sort((a, b) => a.num - b.num);

  return { closingSoon, overdue, unparsed, rowsChecked };
}

/**
 * Interview days that closing dates pile up on.
 *
 * @param {object[]} closingSoon - `closingSoon` rows from computeClosingSoon.
 * @param {string} interviewsMd - data/active-interviews.md content.
 * @param {object} opts
 * @param {Date} opts.today - UTC-midnight Date.
 * @param {number} [opts.within] - Same window as the closing-soon report.
 * @param {number} [opts.clashMin] - Closing rows needed to call it a clash.
 * @returns {{date: string, interview: {company: string, role: string, time: string}, closing: object[]}[]}
 *   Soonest interview first. Interviews in a terminal Status, or whose Date/Time
 *   cell has no readable leading YYYY-MM-DD, are skipped.
 */
export function computeClashes(closingSoon, interviewsMd, { today, within = DEFAULT_WITHIN_DAYS, clashMin = DEFAULT_CLASH_MIN }) {
  const clashes = [];
  const rows = parseActiveInterviews(String(interviewsMd ?? '').replace(/\r\n/g, '\n'));
  for (const row of rows) {
    if (TERMINAL_INTERVIEW_RE.test(findColumn(row, 'status'))) continue;
    const time = findColumn(row, 'date/time').trim();
    const lead = /^(\d{4}-\d{2}-\d{2})(?!\d)/.exec(time);
    const when = lead ? parseDate(lead[1]) : null;
    if (!when) continue;
    const daysAway = daysBetween(today, when);
    if (daysAway < 0 || daysAway > within) continue;
    const day = isoDay(when);
    const eve = isoDay(new Date(when.getTime() - MS_PER_DAY));
    const closing = closingSoon.filter(r => r.closes === day || r.closes === eve);
    if (closing.length < clashMin) continue;
    clashes.push({
      date: day,
      interview: { company: findColumn(row, 'company').trim(), role: findColumn(row, 'role').trim(), time },
      closing,
    });
  }
  clashes.sort((a, b) => a.date.localeCompare(b.date));
  return clashes;
}

// --- CLI ---

const KNOWN_FLAGS = [
  '--within', '--status', '--today', '--tracker', '--jds',
  '--clashes', '--clash-min', '--interviews',
  '--json', '--summary', '--help', '-h',
];
const VALUE_FLAGS = ['--within', '--status', '--today', '--tracker', '--jds', '--clash-min', '--interviews'];

const USAGE = `Usage:
  node closing-soon.mjs                         # JSON report (default)
  node closing-soon.mjs --summary               # human-readable table
  node closing-soon.mjs --within <N>            # window in days (default ${DEFAULT_WITHIN_DAYS})
  node closing-soon.mjs --status <A[,B...]>     # canonical states to include (default ${DEFAULT_STATUS})
  node closing-soon.mjs --today <YYYY-MM-DD>    # evaluate as of a fixed date
  node closing-soon.mjs --tracker <path>        # a different applications.md
  node closing-soon.mjs --jds <dir>             # a different jds/ captures directory
  node closing-soon.mjs --clashes               # also flag interview days with closings piling up
  node closing-soon.mjs --clash-min <N>         # closings needed for a clash (default ${DEFAULT_CLASH_MIN}; needs --clashes)
  node closing-soon.mjs --interviews <path>     # a different active-interviews.md (needs --clashes)
  node closing-soon.mjs --help                  # show this message

The report reads Notes (and jds/ captures when Notes have no closing marker)
and writes nothing.`;

function fail(message) {
  console.error(message);
  process.exit(2);
}

function printSummary(report) {
  const { metadata, closingSoon, overdue, unparsed, clashes } = report;
  const clip = (s, n) => (String(s).length > n ? `${String(s).slice(0, n - 1)}\u2026` : String(s));
  const table = (rows, lastLabel, lastOf) => {
    console.log(`  ${'#'.padEnd(5)}${'Company'.padEnd(24)}${'Role'.padEnd(30)}${'Status'.padEnd(11)}${'Closes'.padEnd(12)}${lastLabel}`);
    for (const r of rows) {
      console.log(`  ${String(r.num).padEnd(5)}${clip(r.company, 22).padEnd(24)}${clip(r.role, 28).padEnd(30)}${clip(r.status, 9).padEnd(11)}${r.closes.padEnd(12)}${lastOf(r)}`);
    }
  };

  console.log(`\n${'='.repeat(86)}`);
  console.log('  Closing soon \u2014 career-ops');
  console.log(`  As of ${metadata.today} \u00b7 window ${metadata.within} day(s) \u00b7 status: ${metadata.statuses.join(', ')}`);
  console.log(`${'='.repeat(86)}\n`);

  console.log(`Closing within ${metadata.within} day(s): ${closingSoon.length}`);
  if (closingSoon.length) table(closingSoon, 'In', r => (r.daysLeft === 0 ? 'today' : `${r.daysLeft}d`));
  console.log(`\nOverdue (date already passed): ${overdue.length}`);
  if (overdue.length) table(overdue, 'Ago', r => `${r.daysOverdue}d`);
  console.log(`\nClosing marker found but date not readable: ${unparsed.length}`);
  for (const r of unparsed) {
    console.log(`  #${r.num} ${clip(r.company, 22)} \u2014 "${r.text}" (${r.reason}; ${r.source})`);
  }
  if (clashes) {
    console.log(`\nInterview-day clashes: ${clashes.length}`);
    for (const c of clashes) {
      const who = [c.interview.company, c.interview.role].filter(Boolean).join(' — ');
      console.log(`  ${c.date} ${who} (${c.interview.time})`);
      for (const r of c.closing) console.log(`    #${r.num} ${clip(r.company, 22)} — ${clip(r.role, 28)} (closes ${r.closes})`);
      console.log(`  -> ${c.closing.length} posting(s) close on or the day before this interview: submit these before the interview.`);
    }
  }
  console.log(`\nRows checked: ${metadata.rowsChecked}\n`);
}

if (isMainModule(import.meta.url)) {
  const args = process.argv.slice(2);
  validateFlags(args, KNOWN_FLAGS, USAGE, { valueFlags: VALUE_FLAGS, requireOperand: true });


  if (hasFlag(args, '--json') && hasFlag(args, '--summary')) {
    fail('Choose one of --json or --summary.');
  }

  const valueOf = (flag) => (hasFlag(args, flag) ? flagValue(args, flag) : null);

  let today;
  const todayArg = valueOf('--today');
  if (todayArg !== null) {
    today = parseDate(todayArg);
    if (!today) fail(`--today expects YYYY-MM-DD, received "${todayArg}".`);
  } else {
    today = parseDate(localToday());
  }

  let within = DEFAULT_WITHIN_DAYS;
  const withinArg = valueOf('--within');
  if (withinArg !== null) {
    if (!/^\d+$/.test(withinArg) || !Number.isSafeInteger(Number(withinArg))) {
      fail(`--within expects a non-negative whole number of days, received "${withinArg}".`);
    }
    within = Number(withinArg);
  }

  const clashesOn = hasFlag(args, '--clashes');
  if (!clashesOn && (hasFlag(args, '--clash-min') || hasFlag(args, '--interviews'))) {
    fail('--clash-min and --interviews only apply together with --clashes.');
  }
  let clashMin = DEFAULT_CLASH_MIN;
  const clashMinArg = valueOf('--clash-min');
  if (clashMinArg !== null) {
    if (!/^\d+$/.test(clashMinArg) || !Number.isSafeInteger(Number(clashMinArg)) || Number(clashMinArg) < 1) {
      fail(`--clash-min expects a whole number of at least 1, received "${clashMinArg}".`);
    }
    clashMin = Number(clashMinArg);
  }

  let statuses;
  try {
    statuses = resolveStatusFilter(valueOf('--status') ?? DEFAULT_STATUS);
  } catch (err) {
    fail(err.message);
  }


  const dataRoot = getCareerOpsRoot();
  const trackerPath = valueOf('--tracker') || resolveTrackerPath(dataRoot);
  const jdsDir = valueOf('--jds') || join(dataRoot, 'jds');

  const warnings = [];
  let trackerMd = '';
  if (existsSync(trackerPath)) {
    trackerMd = readFileSync(trackerPath, 'utf-8');
  } else {
    warnings.push(`No tracker found at ${trackerPath}; nothing to check.`);
  }

  const result = computeClosingSoon(trackerMd, { today, within, statuses, jdsDir });
  const report = {
    metadata: {
      today: isoDay(today),
      within,
      statuses: [...statuses],
      rowsChecked: result.rowsChecked,
      closingSoon: result.closingSoon.length,
      overdue: result.overdue.length,
      unparsed: result.unparsed.length,
    },
    closingSoon: result.closingSoon,
    overdue: result.overdue,
    unparsed: result.unparsed,
    warnings,
  };

  if (clashesOn) {
    const interviewsDefault = existsSync(join(dataRoot, 'data/active-interviews.md'))
      ? join(dataRoot, 'data/active-interviews.md')
      : join(dataRoot, 'active-interviews.md');
    // A relative --interviews override is resolved from the data root, not the cwd;
    // an absolute one is kept as given.
    const interviewsOverride = valueOf('--interviews');
    const interviewsPath = interviewsOverride ? resolve(dataRoot, interviewsOverride) : interviewsDefault;
    let interviewsMd = '';
    if (existsSync(interviewsPath)) {
      try {
        interviewsMd = readFileSync(interviewsPath, 'utf-8');
      } catch (err) {
        warnings.push(`Cannot read interviews file at ${interviewsPath} (${err.code || err.message}); no clashes to check.`);
      }
    } else {
      warnings.push(`No interviews file found at ${interviewsPath}; no clashes to check.`);
    }
    report.clashes = computeClashes(result.closingSoon, interviewsMd, { today, within, clashMin });
  }

  if (hasFlag(args, '--summary')) {
    printSummary(report);
    for (const w of warnings) console.log(w);
  } else {
    console.log(JSON.stringify(report, null, 2));
  }
}