Ctrl K
ComunidadGuías…

followup-cadence.mjs

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

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env node
/**
 * followup-cadence.mjs — Follow-up Cadence Tracker for career-ops
 *
 * Parses applications.md + follow-ups.md, calculates follow-up cadence
 * for active applications, extracts contacts, and flags overdue entries.
 *
 * Run: node followup-cadence.mjs             (JSON to stdout)
 *      node followup-cadence.mjs --summary   (human-readable dashboard)
 *      node followup-cadence.mjs --overdue-only
 *      node followup-cadence.mjs --applied-days 10
 */

import { readFileSync, existsSync } from 'fs';
import { join, dirname, relative, sep } from 'path';
import { fileURLToPath } from 'url';
import { loadYaml } from './lib/yaml-document.mjs';
import { loadCanonicalStates, foldStatusInput } from './tracker-utils.mjs';
import { resolveColumns, parseTrackerRow, firstLinkTarget } from './tracker-parse.mjs';
import { getCareerOpsRoot, getProfilePath, resolveTrackerPath } from './path-resolver.mjs';
import { localToday } from './lib/local-today.mjs';
import { flagValue, validateFlags } from './lib/cli-flags.mjs';
import { isMainModule } from './lib/is-main-module.mjs';
import { parseStatusLog, foldObservations } from './funnel-velocity.mjs';

// templates/states.yml is System Layer — resolved from the codebase, not from
// the user's data root (#3500).
const CODEBASE_ROOT = dirname(fileURLToPath(import.meta.url));
const CAREER_OPS = getCareerOpsRoot();
const APPS_FILE = resolveTrackerPath(CAREER_OPS);

const FOLLOWUPS_FILE = process.env.CAREER_OPS_FOLLOWUPS || join(CAREER_OPS, 'data/follow-ups.md');
const STATUS_LOG_FILE = join(dirname(APPS_FILE), 'status-log.tsv');
const PROFILE_FILE = getProfilePath();


// --- CLI args ---
const args = process.argv.slice(2);
const summaryMode = args.includes('--summary');
const overdueOnly = args.includes('--overdue-only');
// flagValue (not indexOf) so `--applied-days=10` is honored too — indexOf()
// can't see the `=` form and used to silently discard it, falling back to the
// default cadence for a value the caller did supply (#2401/#2402 defect class).
const appliedDaysRaw = flagValue(args, '--applied-days');

// Whole-string match, not a bare parseInt: parseInt('1.5', 10) is 1 and
// parseInt('10days', 10) is 10 — both truncate a bad value into a plausible
// one instead of rejecting it, which is the exact silent-wrong-answer shape
// this file is being fixed to close. Exported so a value's actual numeric
// effect can be asserted directly, rather than only "the CLI didn't error".
const APPLIED_DAYS_RE = /^\d+$/;
export function parseAppliedDaysOverride(raw) {
  return raw !== undefined && APPLIED_DAYS_RE.test(raw) ? positiveInteger(raw) : null;
}
const appliedDaysOverride = parseAppliedDaysOverride(appliedDaysRaw);

// --- Cadence config ---
export const DEFAULT_CADENCE = {
  applied_first: 7,
  applied_subsequent: 7,
  applied_max_followups: 2,
  responded_initial: 1,
  responded_subsequent: 3,
  interview_thankyou: 1,
};

const PROFILE_CADENCE_KEYS = {
  applied_first_days: 'applied_first',
  applied_subsequent_days: 'applied_subsequent',
  applied_max_followups: 'applied_max_followups',
  responded_initial_days: 'responded_initial',
  responded_subsequent_days: 'responded_subsequent',
  interview_thankyou_days: 'interview_thankyou',
};

function positiveInteger(value) {
  if (typeof value === 'string') {
    value = value.trim();
    if (!/^\+?\d+$/.test(value)) return null;
    value = Number(value);
  }
  return Number.isSafeInteger(value) && value >= 0 ? value : null;
}

export function loadProfileCadence(profilePath = PROFILE_FILE) {
  if (!profilePath || !existsSync(profilePath)) return {};
  let raw;
  try {
    raw = loadYaml(readFileSync(profilePath, 'utf-8')) || {};
  } catch {
    return {};
  }
  const source = raw.followup_cadence || {};
  const cadence = {};
  for (const [profileKey, cadenceKey] of Object.entries(PROFILE_CADENCE_KEYS)) {
    const parsed = positiveInteger(source[profileKey]);
    if (parsed !== null) cadence[cadenceKey] = parsed;
  }
  return cadence;
}

// follow_ups.enabled: false in the profile turns the follow-up features off.
// Anything else, including a missing or unreadable profile, keeps them on, so
// an install that never set the key behaves exactly as before.
export function loadFollowupsEnabled(profilePath = PROFILE_FILE) {
  if (!profilePath || !existsSync(profilePath)) return true;
  try {
    return loadYaml(readFileSync(profilePath, 'utf-8'))?.follow_ups?.enabled !== false;
  } catch {
    return true;
  }
}

export function resolveCadenceConfig({ profilePath = PROFILE_FILE, appliedDays = appliedDaysOverride } = {}) {
  const cadence = { ...DEFAULT_CADENCE, ...loadProfileCadence(profilePath) };
  const cliApplied = positiveInteger(appliedDays);
  if (cliApplied !== null) cadence.applied_first = cliApplied;
  return cadence;
}

const CADENCE = resolveCadenceConfig();

// --- Status normalization ---
//
// DERIVED from templates/states.yml, not a hand-copy of it. The map that used
// to live here was already missing every Turkish spelling the Go dashboard
// recognises, so the same tracker row normalized three different ways: the TUI
// read `Mülakat` as `interview`, this file left it as `mülakat` (matching no
// ACTIONABLE/ADVANCED set, so the row silently vanished from the funnel), and
// the web rejected it outright on writeback. tracker-utils already exposes the
// loader for exactly this — its docstring says "a new state or alias lands in
// one file and every consumer follows" — it just had no consumer here (#2704).
//
// Cached per process: these are short-lived CLI runs, so a single read is
// correct. A long-running consumer must NOT copy this pattern — see #2590,
// where caching states.yml for a server's lifetime pinned a stale roster.
let aliasMapCache = null;

/** alias/id/label (lowercased) → canonical lowercase id, from states.yml. */
function statusAliasMap() {
  if (aliasMapCache) return aliasMapCache;
  const map = new Map();
  try {
    for (const st of loadCanonicalStates(join(CODEBASE_ROOT, 'templates', 'states.yml'))) {
      const id = st.id.toLowerCase();
      map.set(foldStatusInput(id), id);
      if (st.label) map.set(foldStatusInput(st.label), id);
      for (const a of st.aliases) map.set(foldStatusInput(a), id);
    }
  } catch (err) {
    // A missing/malformed states.yml is a broken install. Degrade to
    // identity-normalization rather than resurrecting a hardcoded table: a
    // fallback copy is the same copy in disguise and drifts the same way.
    // Report it, though — degrading silently is what hid #3500, and here it
    // costs every localized status its place in the funnel.
    console.error(`[followup-cadence] cannot read canonical states from templates/states.yml: ${err.message}`);
    return (aliasMapCache = new Map());
  }
  return (aliasMapCache = map);
}

const ACTIONABLE_STATUSES = ['applied', 'responded', 'assessment', 'interview'];

export function normalizeStatus(raw) {
  // foldStatusInput, not a bare toLowerCase: JS lowercases the Turkish dotted
  // capital `İ` to `i` + U+0307 and the mark survives, so every all-caps
  // Turkish row missed every alias (#2704 review).
  const clean = foldStatusInput(String(raw).replace(/\s+\d{4}-\d{2}-\d{2}.*$/, ''));
  return statusAliasMap().get(clean) || clean;
}

// --- Date helpers ---
function today() {
  // LOCAL calendar day, UTC midnight anchor. toISOString() gives the UTC DAY,
  // so west of Greenwich an evening run answered "today" with tomorrow and
  // every daysSinceApp came out one high — the follow-up came due a day early
  // (#3070). Same fix as followup-seed (#2765) and set-status (#2932).
  //
  // The arithmetic below is unchanged: parseDate() still anchors at UTC
  // midnight, so only WHICH day this is moves.
  return new Date(localToday());
}

export function parseDate(dateStr) {
  if (!dateStr || !/^\d{4}-\d{2}-\d{2}$/.test(dateStr.trim())) return null;
  const s = dateStr.trim();
  const d = new Date(s);
  // Reject impossible calendar dates (2026-13-45, 2026-02-31): they match the
  // regex but produce an Invalid Date, which is TRUTHY — without this check it
  // slips through `if (!date)` guards and addDays().toISOString() throws,
  // killing the whole analysis over one bad row.
  if (Number.isNaN(d.getTime()) || d.toISOString().slice(0, 10) !== s) return null;
  return d;
}

// The tracker `date` column is often the evaluation date, while the real
// submission date is recorded in the notes as "Applied YYYY-MM-DD" (or
// "APPLIED ..."). Prefer that so cadence reflects when the application actually
// went out, not when the role was evaluated. Returns the first such date, or
// null when the notes don't carry one (caller falls back to the date column).
//
// The optional `~` accepts an estimated date ("Applied ~2026-06-09"), which is
// how an apply date reconstructed after the fact gets written. Skipping those
// silently fell back to the evaluation date — the exact wrong-age failure this
// lookup exists to prevent. The leading \b still refuses "reapplied".
//
// A bounded gap between "applied" and the date covers the channel phrasing
// career-ops' own apply modes write -- "Applied via Ashby 2026-08-31",
// "Applied on 2026-08-25 via Ashby" -- which the original adjacent-only match
// missed entirely, silently degrading to the evaluation-date fallback on the
// exact notes this project generates (#4084). Bounded to 40 chars total and
// unable to cross a sentence boundary (`.`/`;`/`?`/`!`) or a line break so it
// cannot reach into a neighbouring sentence or a different requisition's
// date; isCrossReferencedMention below reuses the same source so its "does
// the citation already have a date" check stays in sync with what this one
// actually matches.
//
// The {0,39} quantifier, not {0,40}: the mandatory separator right after it
// is part of the gap too, so the true maximum distance between "applied" and
// the date is quantifier-plus-one. Two CodeRabbit rounds on #4143:
//   - `?`/`!` join `.`/`;` as sentence boundaries the gap cannot cross --
//     "Applied? 2026-08-31" no longer reaches a foreign date.
//   - The mandatory separator is [^\S\r\n\u2028\u2029] (whitespace that is
//     not itself a line terminator), not \s: \s matches \r/\n/U+2028/U+2029
//     the same as a space, so "Applied via Ashby\n2026-08-31" would have
//     matched even with the gap itself excluding line terminators -- the
//     separator character is the one place a line break could still sneak
//     through.
const APPLIED_DATE_SOURCE = String.raw`\bapplied\b[^.;?!\r\n\u2028\u2029]{0,39}?[^\S\r\n\u2028\u2029]~?(\d{4}-\d{2}-\d{2})(?![\w-])`;
const APPLIED_DATE_RE = new RegExp(APPLIED_DATE_SOURCE, 'gi');
const APPLIED_DATE_HAS_DATE_RE = new RegExp(APPLIED_DATE_SOURCE, 'i');

// The trailing (?![\w-]) is the mirror of that leading \b: without it a
// malformed value ("2026-06-091", "2026-06-09-2026-06-10") is truncated to a
// plausible-looking date and then reported as a *measured* apply date. That is
// worse than no match at all — the evaluation-date fallback is at least labelled
// as inferred, whereas a truncated date is indistinguishable from a real one.
// Rejecting the bad candidate lets the scan continue to a later valid date.
//
// The token shape is necessary but not sufficient: "2026-06-31" and
// "2026-02-30" match it and are not real days.
//
// Whether that should be rejected here depends on the caller, so it is opt-in:
//   - followup-seed.mjs WANTS the raw candidate. It validates the date itself
//     and throws INVALID_DATE so a typo gets corrected rather than silently
//     absorbed — pinning a follow-up off a wrong date is worse than refusing.
//     Filtering here unconditionally would make that impossible date invisible
//     to it and turn a loud, fixable error into a silent wrong answer.
//   - the cadence report wants it skipped, because parseDate() rolls an
//     impossible date over (2026-06-31 becomes 2026-07-01), so it would become
//     a real but WRONG date labelled `notes` — i.e. measured, not inferred.
//     Falling back to the evaluation date is honest by comparison, and one bad
//     row must not fail a whole report.
//
// @param {string} notes
// @param {{requireValidCalendarDate?: boolean}} [options]
export function parseAppliedDate(notes, options = {}) {
  if (!notes) return null;
  const validateCalendar = options.requireValidCalendarDate === true;
  const text = String(notes);

  const matches = [];
  for (const m of text.matchAll(APPLIED_DATE_RE)) {
    if (!validateCalendar || isRealCalendarDate(m[1])) matches.push({ date: m[1], index: m.index });
  }
  if (matches.length === 0) return null;

  // Drop dates that belong to a DIFFERENT row before choosing. Notes routinely
  // cite a sibling requisition's timeline for context — "#154 is already live
  // in the same ATS (applied 2026-08-04)" — and that citation reads exactly
  // like this row's own apply date to a positional scan (#2607).
  const own = matches.filter(m => !isCrossReferencedMention(text, m.index));

  // First-wins is preserved among a row's OWN dates: a later status date must
  // not displace the submission date (see the fixture in test-all.mjs).
  // Cross-reference filtering is orthogonal to that ordering rule.
  if (own.length > 0) return own[0].date;

  // Every apply-date in the note belongs to another row, so this note does not
  // state when THIS row was submitted. Returning null is the honest answer —
  // the alternative is reporting a real but foreign date as
  // `appDateSource: 'notes'`, i.e. measured, which is precisely the failure the
  // header comment warns about.
  //
  // Both consumers degrade to a LABELLED evaluation-date fallback:
  // resolveAppliedDate below reports `appDateSource: 'evaluation-date-fallback'`,
  // and followup-seed.mjs reports `appDateSource: 'evaluation-date'`. That was
  // not true when this was written — seed fell through to today(), unlabelled,
  // which made an old application look new and silently reset its follow-up
  // clock. #2607 changed seed's fallback so the claim below actually holds.
  return null;
}

// How far back to look for a row reference. Long enough to span a clause like
// "#154 Sr PM M&A is already live in the same ATS (applied ...)", short enough
// that an unrelated "#123" earlier in a long note does not reach forward and
// disqualify a genuine date.
const CROSS_REF_LOOKBACK = 120;

// A `#NNN` immediately preceded by a req/job/posting/reference label is an ATS
// identifier for THIS row, not a pointer at another tracker row. Anchored at the
// end so it only matches a label sitting directly before the `#`, and the
// separator excludes `.!?` so a sentence boundary cannot be swallowed into it.
// Same vocabulary as tracker-parse.mjs's REQ_NUMBER_RE (used by merge-tracker.mjs), which reads the same
// Notes column.
const REQ_LABELLED_HASH_RE = /\b(?:job\s*id|posting\s*id|requisition|req|jr|job|posting|ref(?:erence)?|r_)[\s:_-]*$/i;

/**
 * Whether the apply-date at `index` is being cited ABOUT ANOTHER ROW.
 *
 * Heuristic, and deliberately a narrow one: a `#NNN` row reference shortly
 * before the date, with no sentence boundary between them, means the date is
 * inside that reference's clause. A sentence break ends the reference's scope,
 * so "Sibling #140 was slow. Applied 2026-08-06." is correctly read as this
 * row's own date.
 *
 * A SEMICOLON OR PIPE IS A BOUNDARY ONLY ONCE THE REFERENCE HAS ITS OWN DATE.
 * These are the separators this Notes column actually uses, and they do two
 * different jobs depending on what came before:
 *
 *   "#154 Sr PM (applied 2026-08-04); applied 2026-06-15"
 *        the citation already carries its date, so the second one is a new
 *        subject — this row's own. Reading the whole note as #154's discards a
 *        real measured date, and this is the MORE common shape: two roles live
 *        at one employer, and the note naming the sibling is usually the same
 *        note that records this submission.
 *
 *   "#154 is already live; applied 2026-08-04"
 *        the citation has no date yet, so the one after the separator is still
 *        its own — a semicolon joins independent clauses within a sentence and
 *        the subject carries across it. Treating it as a break here adopts a
 *        foreign date and reports it as MEASURED.
 *
 * Where the reading is genuinely ambiguous the tie goes to "cross-referenced",
 * because the two errors are not symmetric — a false positive degrades to a
 * fallback that is LABELLED as not-measured, while a false negative reports
 * another row's date as this row's measured one.
 *
 * Both failure directions are survivable, which is why a heuristic is
 * acceptable here: a false positive degrades to the evaluation date, labelled
 * as a fallback by both consumers (see the note in parseAppliedDate), and a
 * false negative is just the pre-#2607 behaviour. Neither invents a date.
 *
 * @param {string} text
 * @param {number} index - offset of the "applied" match within `text`
 * @returns {boolean}
 */
function isCrossReferencedMention(text, index) {
  const windowStart = Math.max(0, index - CROSS_REF_LOOKBACK);
  const window = text.slice(windowStart, index);
  let refEnd = -1;
  // A `#NNN` glued to a preceding word character or hyphen is an external tag
  // ("job-search#7", "gh#12"), not a pointer at a tracker row. Reading it as a
  // row reference discarded the row's own date: "job-search#7; Applied
  // 2026-09-21" attributed the date to row #7.
  for (const m of window.matchAll(/(?<![\w-])#\d+\b/g)) {
    // The lookbehind cannot see past the slice, so a tag whose `#` lands exactly
    // on the window's first character is checked against the original text.
    if (m.index === 0 && /[\w-]/.test(text[windowStart - 1] ?? '')) continue;
    // A `#NNN` tagged as a req/job/posting/reference id is not a row reference:
    // "Req #1311 - applied 2026-08-06" is this row's own posting id followed by
    // this row's own date, and reading it as a cross-reference would discard a
    // genuine date. The label vocabulary is the one merge-tracker.mjs already
    // recognises in this same Notes column (tracker-parse.mjs REQ_NUMBER_RE), kept in sync by
    // being written the same way rather than imported — merge-tracker's regex
    // also captures the id itself, which is not wanted here.
    if (REQ_LABELLED_HASH_RE.test(window.slice(0, m.index))) continue;
    refEnd = m.index + m[0].length;
  }
  if (refEnd === -1) return false;

  const sinceRef = window.slice(refEnd);
  // A sentence break always ends the reference's scope.
  if (/[.!?]\s/.test(sinceRef)) return false;

  // A semicolon or pipe ends it too — but only once the reference has already
  // been GIVEN a date. Those are the separators this Notes column actually uses,
  // and they do two different jobs depending on what came before:
  //
  //   "#154 Sr PM (applied 2026-08-04); applied 2026-06-15"
  //        the citation already has its date, so the second one is a new
  //        subject: this row's own. Reading the whole note as #154's loses a
  //        real measured date, and that is the more common shape — two roles
  //        live at one employer, and the note naming the sibling is usually the
  //        same note recording this submission.
  //
  //   "#154 is already live; applied 2026-08-04"
  //        the citation has NO date yet, so the one after the semicolon is
  //        still its own: a semicolon joins independent clauses within a
  //        sentence and the subject carries across it. Treating it as a break
  //        here adopts a foreign date and reports it as MEASURED, which is the
  //        failure this whole function exists to prevent.
  //
  // "Has the reference already been satisfied?" is what separates them, and it
  // is checked against the text before the LAST separator so a date belonging
  // to the citation cannot be read as belonging to a later clause.
  //
  // No trailing-whitespace requirement, unlike the sentence rule above. A full
  // stop needs one to avoid firing on "3.5" or "e.g.", but `;` and `|` do not
  // appear inside numbers or abbreviations, and a hand-typed note writes
  // ";applied 2026-06-15" as readily as "; applied 2026-06-15".
  const lastSeparator = [...sinceRef.matchAll(/[;|]/g)].pop();
  if (lastSeparator) {
    const beforeSeparator = sinceRef.slice(0, lastSeparator.index);
    if (APPLIED_DATE_HAS_DATE_RE.test(beforeSeparator)) return false;
  }
  return true;
}

// True only when YYYY-MM-DD names a day that exists. Round-tripping through a
// UTC Date and comparing the parts back catches out-of-range months/days as
// well as month-length and leap-year violations, which a range check misses.
export function isRealCalendarDate(iso) {
  if (!/^\d{4}-\d{2}-\d{2}$/.test(String(iso ?? ''))) return false;
  const [y, mo, d] = iso.split('-').map(Number);
  if (mo < 1 || mo > 12 || d < 1) return false;
  // setUTCFullYear rather than Date.UTC: Date.UTC maps years 0-99 onto
  // 1900-1999, which would reject a literal ISO year below 0100 (0096-02-29 is
  // a real leap day). The absolute setter keeps the year as written.
  const dt = new Date(0);
  dt.setUTCFullYear(y, mo - 1, d);
  dt.setUTCHours(0, 0, 0, 0);
  return dt.getUTCFullYear() === y && dt.getUTCMonth() === mo - 1 && dt.getUTCDate() === d;
}

// The day each row moved to Applied, read with funnel-velocity.mjs's ledger
// rules so corrections and retractions resolve the same way in both reports.
export function statusLogAppliedDates(statusLogContent) {
  const dates = new Map();
  if (!statusLogContent) return dates;
  let states;
  try {
    states = loadCanonicalStates(join(CODEBASE_ROOT, 'templates', 'states.yml'));
  } catch (err) {
    console.error(`[followup-cadence] cannot read canonical states, ignoring status-log.tsv: ${err.message}`);
    return dates;
  }
  for (const [num, timeline] of foldObservations(parseStatusLog(statusLogContent, states).observations)) {
    const applied = timeline.filter(o => o.to === 'Applied' && o.dayMath && isRealCalendarDate(o.date)).pop();
    if (applied) dates.set(num, applied.date);
  }
  return dates;
}

// Which date the cadence is measured from, and where it came from. A caller
// cannot otherwise tell a real application date from the evaluation-date proxy,
// so an inferred age reads exactly like a measured one — and acting on that
// silently-wrong number is what pushes a live application into "cold".
//
// Notes rank above the ledger, as in followup-seed.mjs: a transition logged
// without --on carries the day it was logged, not the day it was sent.
export function resolveAppliedDate(app, statusLogDate = null) {
  // requireValidCalendarDate: an impossible date in the notes must degrade to
  // the labelled fallback rather than be reported as a measured date. One bad
  // row must not fail the whole report, so this skips rather than throws —
  // unlike followup-seed.mjs, which refuses to pin a follow-up off a bad date.
  const fromNotes = parseAppliedDate(app?.notes, { requireValidCalendarDate: true });
  if (fromNotes) return { appliedDate: fromNotes, appDateSource: 'notes' };
  if (statusLogDate) return { appliedDate: statusLogDate, appDateSource: 'status-log' };
  return { appliedDate: app?.date ?? null, appDateSource: 'evaluation-date-fallback' };
}

export function daysBetween(d1, d2) {
  return Math.floor((d2 - d1) / (1000 * 60 * 60 * 24));
}

export function addDays(date, days) {
  // Null-safe: parseDate() returns null for unparseable/impossible dates —
  // degrade to "no scheduled date" instead of crashing (new Date(null) would
  // silently be the 1970 epoch).
  if (!(date instanceof Date) || Number.isNaN(date.getTime())) return null;
  const result = new Date(date);
  result.setUTCDate(result.getUTCDate() + days);
  return result.toISOString().split('T')[0];
}

// --- Parse applications.md ---
// Content-based core so any consumer (stats.mjs, tests) can classify rows
// from in-memory strings without touching disk. The disk-backed wrapper
// below is what the CLI path uses.
function parseTrackerContent(content) {
  const lines = String(content ?? '').split('\n');
  const parseOptions = { allowTabs: true, allowIndentation: true };
  const colmap = resolveColumns(lines, parseOptions);
  const entries = [];
  for (const line of lines) {
    const row = parseTrackerRow(line, colmap, parseOptions);
    if (row) entries.push(row);
  }
  return entries;
}

// --- Parse follow-ups.md ---
// Two formats coexist in the log (both append-only):
//   1. Table rows:  | num | appNum | date | company | role | channel | contact | notes |
//   2. Legacy bullets written by early web builds: `- YYYY-MM-DD · #NUM Company — note`
// Bullets carry no channel/contact/role (mapped to Other/''/''), and bullets
// without a `#NUM` are skipped — they can't be attributed to an application.
// Pin-directive lines (`- next #...`) and the header/separator rows are also
// excluded — the header's `num` cell isn't numeric and the separator's dashes
// aren't either, so both fail the `isNaN` check below and never enter `entries`.
const BULLET_RE = /^-\s+(\d{4}-\d{2}-\d{2})\s+·\s+#(\d+)\s+(.+?)(?:\s+—\s+(.*))?$/;

export function parseFollowups(content) {
  const entries = [];
  for (const line of String(content ?? '').split('\n')) {
    if (line.startsWith('|')) {
      const parts = line.split('|').map(s => s.trim());
      if (parts.length < 8) continue;
      const num = parseInt(parts[1]);
      if (isNaN(num)) continue;
      const appNum = parseInt(parts[2]);
      if (isNaN(appNum)) continue; // unattributable row would poison per-app grouping
      entries.push({
        num,
        appNum,
        date: parts[3],
        company: parts[4],
        role: parts[5],
        channel: parts[6],
        contact: parts[7],
        notes: parts[8] || '',
      });
      continue;
    }
    const m = line.match(BULLET_RE);
    if (!m) continue;
    entries.push({
      num: null,
      appNum: parseInt(m[2]),
      date: m[1],
      company: m[3],
      role: '',
      channel: 'Other',
      contact: '',
      notes: m[4] || '',
    });
  }
  return entries;
}

// `parseFollowups` is the disk-agnostic content parser upstream/main and its
// callers use internally (analyzeFromContent, external scripts); the branch's
// test-all.mjs imports it as `parseFollowupsContent`. Same function, two names.
export { parseFollowups as parseFollowupsContent };

// --- Next-date overrides (pins) ---
// A user can PIN an application's next follow-up date, taking precedence over
// the computed cadence (a pin even revives a cold application) until a
// follow-up logged on/after the pin's set-date resumes the normal schedule.
// Stored in data/follow-ups.md as directive lines:
//   - next #42 2026-07-10 (set 2026-07-02)
//   - next #42 2026-07-10 (set 2026-07-02) — why the date was pinned
// The `(set …)` part records when the pin was made; if omitted (hand-written)
// it defaults to the pinned date itself. The LAST pin line per application wins.
//
// A trailing `— note` is accepted and ignored. Pins are written by hand as
// often as by `followup-seed.mjs`, and a hand-written pin almost always wants
// to record WHY the date moved. Anchoring the pattern immediately after the
// `(set …)` group made every annotated pin fail to match — silently, since a
// non-matching line is indistinguishable from an ordinary bullet. The failure
// mode is the dangerous direction: the pin vanishes, the computed cadence
// takes over, and the application reports overdue when the user had
// explicitly deferred it.
const OVERRIDE_RE = /^-\s+next\s+#(\d+)\s+(\d{4}-\d{2}-\d{2})(?:\s+\(set\s+(\d{4}-\d{2}-\d{2})\))?(?:\s*[—–-].*)?\s*$/i;

export function parseNextOverrides(content) {
  const byApp = new Map();
  for (const line of content.split('\n')) {
    const m = line.match(OVERRIDE_RE);
    if (!m) continue;
    const date = m[2];
    if (!parseDate(date)) continue; // an impossible pinned date never poisons the analysis
    const appNum = parseInt(m[1]);
    byApp.set(appNum, { appNum, date, setDate: m[3] || date });
  }
  return byApp;
}

// The pin applies until a follow-up is logged AFTER it. Ties favor the pin:
// "log a follow-up, then pin the next date" is the common same-day flow.
export function resolveNextOverride(override, lastFollowupDate) {
  if (!override) return null;
  if (lastFollowupDate && lastFollowupDate > override.setDate) return null;
  return override.date;
}

// --- Retire directives ---
// Not every application has a reachable human behind it. A cold ATS submission
// with no contact on file has no follow-up channel at all, yet the cadence
// keeps reporting it overdue every week forever. A dashboard whose overdue
// count is mostly un-actionable rows trains the user to stop reading it, which
// costs far more than the rows themselves.
//
// A retire directive drops ONE application out of the cadence:
//   - cleared #42 2026-08-04 — no contact on file, no warm path
// The date records when the retirement was made. This closes the follow-up
// loop only — it does NOT close the application. The tracker row keeps its
// status and any inbound reply is still caught by reply-watch.
//
// Like a pin, a retirement is revoked by a follow-up logged after it, so
// re-engaging a retired application resumes its normal cadence with no
// bookkeeping. The LAST directive per application wins, and a retirement
// outranks a pin on the same application: retiring is the more explicit
// "stop surfacing this", and reviving it is a one-line edit either way.
const CLEARED_RE = /^-\s+cleared\s+#(\d+)\s+(\d{4}-\d{2}-\d{2})(?:\s*[—–-].*)?\s*$/i;

export function parseClearedDirectives(content) {
  const byApp = new Map();
  for (const line of String(content ?? '').split('\n')) {
    const m = line.match(CLEARED_RE);
    if (!m) continue;
    const setDate = m[2];
    if (!parseDate(setDate)) continue; // an impossible date never poisons the analysis
    const appNum = parseInt(m[1]);
    byApp.set(appNum, { appNum, setDate });
  }
  return byApp;
}

// Mirrors resolveNextOverride's revival rule, including the same-day tie:
// "log a final follow-up, then retire" is the common flow, so a follow-up
// dated the same day as the retirement does not undo it.
export function isRetired(cleared, lastFollowupDate) {
  if (!cleared) return false;
  if (lastFollowupDate && lastFollowupDate > cleared.setDate) return false;
  return true;
}

// --- Extract contacts from notes ---
// Outreach recorded in notes is usually a NAME, not an email — LinkedIn, the
// most common channel, never produces one. An email-only parser therefore
// reports `contacts: []` for rows that do have a human attached, and "no
// contact" becomes indistinguishable from "contact with no email on file".
// That inverts the meaning of the field: an empty list reads as "outreach is
// untried here" when outreach has in fact been tried and has not converted.
//
// Emitted shape is `{ name, email, channel }`. `email` stays first-class (and
// remains non-null for email contacts) so existing consumers keep working;
// `channel` is additive.
// `+` must be in the local part. \w is [A-Za-z0-9_], so the previous class silently TRUNCATED a
// plus-addressed address at the plus — `mayank+6a88…@reply.cutshort.io` was captured as
// `6a88…@reply.cutshort.io`, a different mailbox that would bounce. Recruiting platforms route
// replies through exactly this form (CutShort, Greenhouse, Lever, Workable all use
// `name+token@reply.domain`), so the addresses most worth capturing were the ones being corrupted
// — and corrupted into something that still looks like a valid address, so nothing notices.
const EMAIL_RE = /[\w.+%-]+@[\w.-]+\.\w+/g;

// Name-shaped contacts are gated on an explicit outreach verb or role word, so
// a capitalized company name ("Acme Corp") can never be mistaken for a person.
// Both name parts allow an internal hyphen or apostrophe ("Mary-Jane
// O'Brien") — dropping such a name would report "no contact" for a row that
// plainly names a person, the very silence this parser exists to remove.
const OUTREACH_NAME_RE = /\b(?:recruiter|hiring manager|messaged|contacted|emailed|called|reached out to|spoke with|outreach)\b[\s(]*([A-Z][a-z]*(?:[-'’][A-Z]?[a-z]+)*(?:\s+[A-Z][a-z]*(?:[-'’][A-Z]?[a-z]+)*)+)/g;

// One note can record several separate outreach events ("Messaged X on
// LinkedIn; called Y"). Resolving a contact against the WHOLE note attributes
// the first channel word it finds to every contact, so the second person is
// silently credited to the wrong channel. Split into statements and resolve
// each independently.
//
// The sentence split only fires on a period followed by whitespace and a
// capital, so it cannot break an address like `jane.doe@acme.com`.
function splitStatements(notes) {
  return String(notes).split(/[;\n]+|\.\s+(?=[A-Z])/).filter(s => s.trim());
}

/**
 * The candidate's own addresses, so a note that mentions them is not reported as somebody to chase.
 *
 * Tracker notes routinely cite your own mailbox while recording where a search ran — e.g. "searched
 * both accounts (personal + me@work.example) for this thread" written to establish that a reply was
 * NOT received. extractContacts has no concept of self, so it reads that as a repliable human and
 * the row advertises a contact that cannot be written to. That is worse than reporting no contact:
 * it makes an un-chaseable row look actionable.
 *
 * Read from config/profile.yml rather than hardcoded, so it stays correct per profile. Fails open —
 * an absent or unreadable profile yields an empty set, because a missing profile must never start
 * deleting real contacts.
 */
export function loadSelfIdentities(profilePath = PROFILE_FILE) {
  if (!profilePath || !existsSync(profilePath)) return new Set();
  let raw;
  try {
    raw = loadYaml(readFileSync(profilePath, 'utf-8')) || {};
  } catch {
    return new Set();
  }
  const out = new Set();
  const push = (v) => { if (typeof v === 'string' && v.includes('@')) out.add(v.trim().toLowerCase()); };
  push(raw.candidate?.email);
  // Only iterate an actual array. A hand-edited profile can reasonably hold a mapping here
  // (`alternate_emails: { work: me@example.com }`), and `for...of` on an object throws — at module
  // load, because SELF_IDENTITIES is initialised at import time. That would take followup-cadence
  // down entirely over a cosmetic config mistake, which is the opposite of the fail-open behaviour
  // this function promises everywhere else.
  const alternates = raw.candidate?.alternate_emails;
  if (Array.isArray(alternates)) for (const alt of alternates) push(alt);
  return out;
}

const SELF_IDENTITIES = loadSelfIdentities();

export function extractContacts(notes, selfIdentities = SELF_IDENTITIES) {
  if (!notes) return [];
  const byEmail = new Map();  // normalized email -> contact
  const byName = new Map();   // normalized name  -> contact
  const contacts = [];

  const add = ({ name, email, channel }) => {
    const emailKey = email ? email.toLowerCase() : null;
    const nameKey = name ? name.toLowerCase() : null;

    // Same address recorded twice is one contact; fill in a name/channel the
    // earlier mention lacked rather than emitting a duplicate.
    const byEmailHit = emailKey ? byEmail.get(emailKey) : null;
    const byNameHit = nameKey ? byName.get(nameKey) : null;

    // A name-only and an email-only record can be created separately, then a
    // later statement names BOTH and proves they are the same person. Fold the
    // two records into one and drop the redundant entry, or the result reports
    // two contacts where the note itself says there is one.
    if (byEmailHit && byNameHit && byEmailHit !== byNameHit) {
      byEmailHit.name = byEmailHit.name || byNameHit.name;
      byEmailHit.email = byEmailHit.email || byNameHit.email;
      byEmailHit.channel = byEmailHit.channel || byNameHit.channel;
      const idx = contacts.indexOf(byNameHit);
      if (idx !== -1) contacts.splice(idx, 1);
      // Repoint every key that pointed at the discarded record.
      for (const [k, v] of byEmail) if (v === byNameHit) byEmail.set(k, byEmailHit);
      for (const [k, v] of byName) if (v === byNameHit) byName.set(k, byEmailHit);
    }

    const existing = byEmailHit || byNameHit || null;
    if (existing) {
      if (!existing.name && name) existing.name = name;
      if (!existing.email && email) existing.email = email;
      if (!existing.channel && channel) existing.channel = channel;
      if (existing.email) byEmail.set(existing.email.toLowerCase(), existing);
      if (existing.name) byName.set(existing.name.toLowerCase(), existing);
      return;
    }

    const contact = { name: name ?? null, email: email ?? null, channel: channel ?? null };
    contacts.push(contact);
    if (emailKey) byEmail.set(emailKey, contact);
    if (nameKey) byName.set(nameKey, contact);
  };

  for (const span of splitStatements(notes)) {
    const emails = span.match(EMAIL_RE) || [];
    const names = [...span.matchAll(OUTREACH_NAME_RE)].map(m => m[1].trim());
    if (!emails.length && !names.length) continue;
    const channel = detectChannel(span, emails.length > 0);

    // One person and one address in the same statement is one contact, not an
    // email-only entry plus a separate name-only duplicate.
    if (names.length === 1 && emails.length === 1) {
      add({ name: names[0], email: emails[0], channel });
      continue;
    }

    for (const email of emails) {
      // Fall back to the older "Emailed Name at <addr>" shape for a name that
      // sits next to the address without a listed outreach verb.
      let name = null;
      const beforeEmail = span.substring(0, span.indexOf(email));
      const nameMatch = beforeEmail.match(/(?:emailed|contact[:\s]+|to\s+)([A-Z][a-z]+ ?[A-Z]?[a-z]*)\s*(?:at|@|$)/i);
      if (nameMatch) name = nameMatch[1].trim();
      add({ name, email, channel });
    }
    for (const name of names) add({ name, email: null, channel });
  }

  // A note citing your own mailbox is not a person to follow up with.
  return contacts.filter((c) => !(c.email && selfIdentities.has(c.email.toLowerCase())));
}

// The channel a single statement names, when it names one. Null rather than a
// guess: an unspecified channel is not evidence of any particular one. An
// address in the statement implies email only when no channel word says otherwise.
function detectChannel(span, hasEmail = false) {
  if (/\blinkedin\b/i.test(span)) return 'linkedin';
  if (/\bphone\b|\bcalled\b/i.test(span)) return 'phone';
  if (/\bemail(ed)?\b/i.test(span) || hasEmail) return 'email';
  return null;
}

// Display label for a contact: the email when there is one, otherwise the name.
// The summary table reads this instead of `.email` directly, so a name-only
// contact shows the person rather than a literal "null".
export function contactLabel(contact) {
  if (!contact) return '-';
  return contact.email || contact.name || '-';
}

// --- Resolve report path ---
export function resolveReportPath(reportField, appsFile = APPS_FILE, repoRoot = CAREER_OPS) {
  const target = firstLinkTarget(reportField);
  if (target == null) return null;
  // Two shapes name the same file. Writers emit a repo-root link,
  // `(reports/file.md)`, and merge-tracker rewrites it relative to the tracker
  // directory, `(../reports/file.md)` (PR #760). Try the tracker-relative path
  // first. A link with no `..` segment is also tried from the repo root, which
  // is where the unmigrated shape actually lives. A `..` segment is not given
  // that second try, so the link cannot walk out of reports/.
  const candidates = [join(dirname(appsFile), target)];
  if (!target.startsWith('/') && !target.split(/[\\/]/).includes('..')) {
    const fromRoot = join(repoRoot, target);
    if (fromRoot !== candidates[0]) candidates.push(fromRoot);
  }
  for (const fullPath of candidates) {
    const repoRelative = relative(repoRoot, fullPath).split(sep).join('/');
    if (repoRelative.startsWith('../') || repoRelative === '..' || !repoRelative.startsWith('reports/')) continue;
    if (existsSync(fullPath)) return repoRelative;
  }
  return null;
}

// --- Compute urgency ---
// For responded/assessment/interview, logged follow-ups CLEAR the overdue state and the
// clock restarts from the last touch (re-overdue every responded_subsequent
// days) — matching the cadence table in modes/followup.md ("Responded: every
// 3 days · Assessment uses the responded cadence · Interview: thank-you, then
// every 3 days, no limit").
export function computeUrgency(status, daysSinceApp, daysSinceLastFollowup, followupCount) {
  if (status === 'applied') {
    if (followupCount >= CADENCE.applied_max_followups) return 'cold';
    if (followupCount === 0 && daysSinceApp >= CADENCE.applied_first) return 'overdue';
    if (followupCount > 0 && daysSinceLastFollowup !== null && daysSinceLastFollowup >= CADENCE.applied_subsequent) return 'overdue';
    return 'waiting';
  }
  if (status === 'responded' || status === 'assessment') {
    if (daysSinceLastFollowup !== null) {
      return daysSinceLastFollowup >= CADENCE.responded_subsequent ? 'overdue' : 'waiting';
    }
    if (daysSinceApp < CADENCE.responded_initial) return 'urgent';
    if (daysSinceApp >= CADENCE.responded_subsequent) return 'overdue';
    return 'waiting';
  }
  if (status === 'interview') {
    if (daysSinceLastFollowup !== null) {
      return daysSinceLastFollowup >= CADENCE.responded_subsequent ? 'overdue' : 'waiting';
    }
    return daysSinceApp >= CADENCE.interview_thankyou ? 'overdue' : 'waiting';
  }
  return 'waiting';
}

// --- Compute next follow-up date ---
export function computeNextFollowupDate(status, appDate, lastFollowupDate, followupCount) {
  if (status === 'applied') {
    if (followupCount >= CADENCE.applied_max_followups) return null; // cold
    if (followupCount === 0) return addDays(parseDate(appDate), CADENCE.applied_first);
    if (lastFollowupDate) return addDays(parseDate(lastFollowupDate), CADENCE.applied_subsequent);
    return addDays(parseDate(appDate), CADENCE.applied_first);
  }
  if (status === 'responded' || status === 'assessment') {
    if (lastFollowupDate) return addDays(parseDate(lastFollowupDate), CADENCE.responded_subsequent);
    return addDays(parseDate(appDate), CADENCE.responded_initial);
  }
  if (status === 'interview') {
    // After the thank-you is logged, subsequent touches follow the responded
    // cadence (modes/followup.md: "Every 3 days · No limit").
    if (lastFollowupDate) return addDays(parseDate(lastFollowupDate), CADENCE.responded_subsequent);
    return addDays(parseDate(appDate), CADENCE.interview_thankyou);
  }
  return null;
}

// --- Main analysis ---
// Content-based core so consumers outside this CLI (stats.mjs, tests) can
// reuse the exact same cadence/urgency math — including the 'cold'
// classification — without duplicating it or touching disk. `followupsContent`
// missing/empty (the common case when data/follow-ups.md doesn't exist yet)
// degrades gracefully: every app gets followupCount 0, so 'cold' (which
// requires followupCount >= applied_max_followups) simply never triggers —
// no error, no guessing, matching the same "absent optional file = pass
// through" convention used elsewhere in this project.
export function analyzeFromContent(trackerContent, followupsContent = '', { enabled = true, statusLogContent = '' } = {}) {
  if (!enabled) {
    return {
      followupsEnabled: false,
      metadata: { analysisDate: today().toISOString().split('T')[0], totalTracked: 0, actionable: 0, overdue: 0, urgent: 0, cold: 0, waiting: 0, retired: 0 },
      entries: [],
      cadenceConfig: CADENCE,
      cadenceDefaults: DEFAULT_CADENCE,
    };
  }
  const apps = parseTrackerContent(trackerContent);
  if (apps.length === 0) {
    // cadenceDefaults rides along on the error. It is a constant, so it is just
    // as valid with no applications as with a hundred, and this is the ONE
    // state where a consumer cannot do without it: on a first run the web
    // cadence form has no profile overrides to fall back on either, so
    // withholding it renders six empty fields with nothing to type back in.
    // The rest of the normal shape rides along too, zeroed, so a reader that
    // skips `error` (the web follow-ups page) renders an empty tracker instead
    // of "cadence unavailable".
    return {
      error: 'No applications found in tracker.',
      noData: true,
      metadata: {
        analysisDate: today().toISOString().split('T')[0],
        totalTracked: 0, actionable: 0, overdue: 0, urgent: 0, cold: 0, waiting: 0, retired: 0,
      },
      entries: [],
      cadenceConfig: CADENCE,
      cadenceDefaults: DEFAULT_CADENCE,
    };
  }

  const followups = parseFollowups(followupsContent);
  const overrides = parseNextOverrides(String(followupsContent ?? ''));
  const cleared = parseClearedDirectives(followupsContent);
  const appliedOn = statusLogAppliedDates(statusLogContent);

  // Group follow-ups by app number
  const followupsByApp = new Map();
  for (const fu of followups) {
    if (!followupsByApp.has(fu.appNum)) followupsByApp.set(fu.appNum, []);
    followupsByApp.get(fu.appNum).push(fu);
  }

  const now = today();
  const entries = [];

  for (const app of apps) {
    const normalized = normalizeStatus(app.status);
    if (!ACTIONABLE_STATUSES.includes(normalized)) continue;

    // appDateSource travels with the entry so a consumer can tell a measured
    // age from one inferred off the evaluation date.
    const { appliedDate, appDateSource } = resolveAppliedDate(app, appliedOn.get(app.num));
    const appDate = parseDate(appliedDate);
    if (!appDate) continue;

    const daysSinceApp = daysBetween(appDate, now);
    const appFollowups = followupsByApp.get(app.num) || [];
    const followupCount = appFollowups.length;

    // Find most recent follow-up (sorted date-desc; also exposed per entry so
    // the web dashboard can render history without a second parser).
    let lastFollowupDate = null;
    let daysSinceLastFollowup = null;
    const sortedFollowups = [...appFollowups].sort((a, b) => (a.date > b.date ? -1 : 1));
    if (sortedFollowups.length > 0) {
      lastFollowupDate = sortedFollowups[0].date;
      const lastDate = parseDate(lastFollowupDate);
      if (lastDate) daysSinceLastFollowup = daysBetween(lastDate, now);
    }

    let urgency = computeUrgency(normalized, daysSinceApp, daysSinceLastFollowup, followupCount);
    let nextFollowupDate = computeNextFollowupDate(normalized, appliedDate, lastFollowupDate, followupCount);

    // A pinned next-date takes precedence over the computed cadence (explicit
    // user intent — it even revives a cold application) until a follow-up
    // logged after the pin resumes the normal schedule.
    const nextOverride = resolveNextOverride(overrides.get(app.num), lastFollowupDate);
    if (nextOverride) {
      nextFollowupDate = nextOverride;
      urgency = daysBetween(parseDate(nextOverride), now) >= 0 ? 'overdue' : 'waiting';
    }

    // A retirement outranks a pin: it means "there is no channel here", which
    // no computed or pinned date can make true.
    const retired = isRetired(cleared.get(app.num), lastFollowupDate);
    if (retired) {
      urgency = 'retired';
      nextFollowupDate = null;
    }

    const nextDate = nextFollowupDate ? parseDate(nextFollowupDate) : null;
    const daysUntilNext = nextDate ? daysBetween(now, nextDate) : null;

    const contacts = extractContacts(app.notes);
    const reportPath = resolveReportPath(app.report);

    entries.push({
      num: app.num,
      date: app.date,
      appliedDate,
      appDateSource,
      company: app.company,
      // Intermediary channel (#1596): agency name when the application went
      // through an intermediary, null for a direct application (the tracker's
      // `—` placeholder and the no-Via-column case both normalize to null, so
      // consumers never learn the sentinel). When set, follow-ups chase the
      // agency contact, not the company.
      via: app.via && app.via !== '—' ? app.via : null,
      role: app.role,
      status: normalized,
      score: app.score,
      notes: app.notes,
      reportPath,
      contacts,
      daysSinceApplication: daysSinceApp,
      daysSinceLastFollowup,
      followupCount,
      followups: sortedFollowups,
      urgency,
      nextFollowupDate,
      nextOverride,
      daysUntilNext,
    });
  }

  const urgencyOrder = { urgent: 0, overdue: 1, waiting: 2, cold: 3 };
  const byPressure = (a, b) =>
    (urgencyOrder[a.urgency] ?? 9) - (urgencyOrder[b.urgency] ?? 9)
    || (a.daysUntilNext ?? Infinity) - (b.daysUntilNext ?? Infinity)
    || b.daysSinceApplication - a.daysSinceApplication
    || a.num - b.num;
  entries.sort(byPressure);

  // Retired applications are counted but not listed — surfacing them in the
  // entries array would defeat the point of retiring them, and the count keeps
  // the retirement visible enough to be reconsidered.
  const retiredCount = entries.filter(e => e.urgency === 'retired').length;
  const active = entries.filter(e => e.urgency !== 'retired');

  const filtered = overdueOnly
    ? active.filter(e => e.urgency === 'overdue' || e.urgency === 'urgent')
    : active;

  return {
    metadata: {
      analysisDate: now.toISOString().split('T')[0],
      totalTracked: apps.length,
      actionable: active.length,
      overdue: active.filter(e => e.urgency === 'overdue').length,
      urgent: active.filter(e => e.urgency === 'urgent').length,
      cold: active.filter(e => e.urgency === 'cold').length,
      waiting: active.filter(e => e.urgency === 'waiting').length,
      retired: retiredCount,
    },
    entries: filtered,
    cadenceConfig: CADENCE,
    // The EFFECTIVE cadence above is defaults+profile overrides. Consumers that
    // need to show what a value would be WITHOUT the user's override (the web
    // settings form's placeholder) need the pure defaults too — sourcing that
    // placeholder from cadenceConfig would render a user's own override as the
    // default they'd be reverting to. Emitting both is what lets the web stop
    // hand-copying DEFAULT_CADENCE (#2369).
    cadenceDefaults: DEFAULT_CADENCE,
  };
}

// --- Main analysis (disk-backed CLI entry point) ---
function analyze() {
  if (!loadFollowupsEnabled()) return analyzeFromContent('', '', { enabled: false });
  const trackerContent = existsSync(APPS_FILE) ? readFileSync(APPS_FILE, 'utf-8') : '';
  const followupsContent = existsSync(FOLLOWUPS_FILE) ? readFileSync(FOLLOWUPS_FILE, 'utf-8') : '';
  const statusLogContent = existsSync(STATUS_LOG_FILE) ? readFileSync(STATUS_LOG_FILE, 'utf-8') : '';
  return analyzeFromContent(trackerContent, followupsContent, { enabled: loadFollowupsEnabled(), statusLogContent });
}

// --- Summary mode ---
function printSummary(result) {
  if (result.followupsEnabled === false) {
    console.log('\nFollow-ups are turned off (follow_ups.enabled: false in config/profile.yml).\n');
    return;
  }
  if (result.error) {
    console.log(`\n${result.error}\n`);
    return;
  }

  const { metadata, entries } = result;

  console.log(`\n${'='.repeat(70)}`);
  console.log(`  Follow-up Cadence Dashboard — ${metadata.analysisDate}`);
  console.log(`  ${metadata.totalTracked} total applications, ${metadata.actionable} actionable`);
  console.log(`${'='.repeat(70)}\n`);

  if (entries.length === 0) {
    console.log('  No active applications to track. Apply to some roles first.\n');
    return;
  }

  // Status summary
  const urgencyIcon = { urgent: 'URGENT', overdue: 'OVERDUE', waiting: 'waiting', cold: 'COLD' };
  console.log(`  ${metadata.urgent} urgent | ${metadata.overdue} overdue | ${metadata.waiting} waiting | ${metadata.cold} cold\n`);

  // Table header
  console.log('  ' + '#'.padEnd(5) + 'Company'.padEnd(16) + 'Status'.padEnd(12) + 'Days'.padEnd(6) + 'F/U'.padEnd(5) + 'Next'.padEnd(13) + 'Urgency'.padEnd(10) + 'Contact');
  console.log('  ' + '-'.repeat(80));

  for (const e of entries) {
    const urgLabel = urgencyIcon[e.urgency] || e.urgency;
    const nextStr = e.nextFollowupDate || '-';
    const contactStr = e.contacts.length > 0 ? contactLabel(e.contacts[0]) : '-';
    console.log(
      '  ' +
      String(e.num).padEnd(5) +
      e.company.substring(0, 15).padEnd(16) +
      e.status.padEnd(12) +
      String(e.daysSinceApplication).padEnd(6) +
      String(e.followupCount).padEnd(5) +
      nextStr.padEnd(13) +
      urgLabel.padEnd(10) +
      contactStr
    );
  }

  console.log('');
}

// ── CLI flags + help ────────────────────────────────────────────────

// --json is the DEFAULT output form, not a mode switch: it names what the
// script already does with no flag at all. It is listed here because callers
// pass it explicitly — web/src/app/api/followups/route.ts and
// web/src/app/api/followups/cadence/route.ts both spawn `[script, '--json']`
// — and validateFlags() rejects any flag not on this list, so omitting it
// made both routes exit 1 and read as "no follow-ups" (#3196 added the
// validation without the flag the web already passed).
const KNOWN_FLAGS = ['--summary', '--json', '--overdue-only', '--applied-days', '--help', '-h'];
const VALUE_FLAGS = ['--applied-days'];

const USAGE = `Usage:
  node followup-cadence.mjs                    # full JSON analysis to stdout
  node followup-cadence.mjs --json             # same as above, JSON is the default
  node followup-cadence.mjs --summary          # human-readable dashboard (wins over --json)
  node followup-cadence.mjs --overdue-only     # only show overdue/urgent entries
  node followup-cadence.mjs --applied-days 10  # override applied_first cadence (days)
  node followup-cadence.mjs --help|-h          # print this usage block and exit`;

// --- Run (CLI only; guarded so the module is safely importable for tests) ---
if (isMainModule(import.meta.url)) {
  // Must run inside this guard, not at module top level: CADENCE (above) is a
  // module-level singleton built at import time, and test-all.mjs section 12
  // dynamic-imports this module in-process to read it — validating the host
  // process's own argv there would false-positive on the test runner's flags.
  validateFlags(args, KNOWN_FLAGS, USAGE, { valueFlags: VALUE_FLAGS, requireOperand: true });

  // positiveInteger() (used to build CADENCE above) treats a NaN/negative value
  // as "absent" and silently keeps the default — exactly the wrong-answer-at-
  // exit-0 shape this fix exists to close. A value that was actually SUPPLIED
  // must fail loudly instead of being swallowed the same way a bad flag NAME
  // used to be.
  if (appliedDaysRaw !== undefined && !(Number.isFinite(appliedDaysOverride) && appliedDaysOverride >= 0)) {
    console.error(`Error: --applied-days requires a non-negative integer, got "${appliedDaysRaw}"`);
    process.exit(1);
  }

  const result = analyze();

  // --summary wins when both are given: it is the flag that asks for something
  // other than the default, so it is the one carrying an intention.
  if (summaryMode) {
    printSummary(result);
  } else {
    console.log(JSON.stringify(result, null, 2));
  }

  // An empty tracker is a new user, not a failure. analyze-patterns.mjs and the
  // other tracker readers exit 0 here too; exiting 1 made the web dashboard read
  // a first run as "cadence unavailable".
  if (result.error && !result.noData) process.exit(1);
}