Ctrl K
ComunidadGuías…

followup-seed.mjs

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

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env node
/**
 * followup-seed.mjs — Seed data/follow-ups.md when a row is marked Applied (#1430)
 *
 * WHY: the follow-up system was "born dead". Marking a tracker row Applied only
 * updated applications.md — data/follow-ups.md stayed empty until the user ran
 * the `followup` mode by hand and it happened to notice the row. In practice
 * that meant most applications never got a scheduled next-follow-up date at
 * all, silently defeating the entire cadence feature. This script closes that
 * gap: given an appNum (or --backfill for the whole tracker), it computes the
 * cadence-default next-follow-up date and writes it as a PIN DIRECTIVE line —
 * never a table row, because a table row in follow-ups.md means "a follow-up
 * was SENT", which seeding must not claim.
 *
 * Pin format (parsed by `parseNextOverrides` in followup-cadence.mjs):
 *   - next #<appNum> <YYYY-MM-DD> (set <YYYY-MM-DD>)
 * First date = seeded next-follow-up date. `(set …)` = the day the pin was
 * written. The LAST pin per application wins, so re-seeding with --force is
 * always safe — it just appends a fresh pin.
 *
 * Apply-date resolution order (see resolveAppliedDate): explicit --date, then
 * "Applied YYYY-MM-DD" in the row's notes, then the tracker's `date` column,
 * then today. Every result carries an `appDateSource` saying which of those it
 * came from, so a measured date is never mistaken for a guessed one.
 *
 * The `date` column used to be excluded here as "the evaluation date, not the
 * submission date". It is — but the fallback it was being excluded in favour of
 * was `today`, which is worse: an early date makes a follow-up fire early
 * (visible), while today makes an old application look new and silently resets
 * its follow-up clock (not visible). Preferring the labelled proxy over the
 * unlabelled guess is the trade that replaced it (#2607).
 *
 * Idempotency: by default, seeding a given appNum is a NO-OP FOREVER once
 * either a pin or a logged follow-up exists for it (exit 0, seeded:false).
 * Pass --force to append a fresh pin anyway.
 *
 * Usage:
 *   node followup-seed.mjs <appNum> [--date YYYY-MM-DD] [--force] [--dry-run] [--json]
 *   node followup-seed.mjs --backfill [--dry-run] [--json]
 *
 * Exit codes:
 *   0 success or idempotent no-op
 *   1 usage or validation error (incl. impossible --date, non-Applied row
 *     without --force, unknown flag)
 *   2 row not found (or tracker missing)
 *   4 lock timeout
 *
 * Env overrides (mirroring merge-tracker.mjs / followup-cadence.mjs):
 *   CAREER_OPS_TRACKER                     tracker path
 *   CAREER_OPS_FOLLOWUPS                   follow-ups path
 *   CAREER_OPS_PROFILE                     profile.yml path (cadence overrides)
 *   CAREER_OPS_FOLLOWUPS_LOCK              lock directory override
 *   CAREER_OPS_FOLLOWUPS_LOCK_TIMEOUT_MS   lock acquire timeout
 *   CAREER_OPS_FOLLOWUPS_LOCK_RETRY_MS     lock retry interval
 *   CAREER_OPS_FOLLOWUPS_LOCK_STALE_MS     stale-lock recovery threshold
 *   CAREER_OPS_OWNERLESS_GRACE_MS          ownerless-lock floor (lib/dir-lock.mjs; shared, not per-script)
 */

import { readFileSync, writeFileSync, existsSync, mkdirSync, rmSync, realpathSync } from 'fs';
import { join, dirname, basename, resolve, isAbsolute, relative, sep } from 'path';
import { fileURLToPath } from 'url';
import { createHash, randomUUID } from 'crypto';
// The lock protocol is lib/dir-lock.mjs, shared with every other core lock;
// this module only picks the follow-ups lock directory, timing and error.
import { acquireDirLock, releaseOrWarn } from './lib/dir-lock.mjs';
import { makeCliFailWith, renameSyncWithRetry } from './tracker-utils.mjs';
import { tmpdir } from 'os';
import { resolveColumns, parseTrackerRow } from './tracker-parse.mjs';
import { localToday } from './lib/local-today.mjs';
import {
  resolveCadenceConfig,
  loadFollowupsEnabled,
  normalizeStatus,
  parseAppliedDate,
  parseFollowups,
  parseNextOverrides,
  parseDate,
  addDays,
  isRealCalendarDate,
} from './followup-cadence.mjs';
import { flagValue } from './lib/cli-flags.mjs';
import { getCareerOpsRoot, canonicalizeTrackerPath, resolveTrackerPath as sharedResolveTrackerPath } from './path-resolver.mjs';
import { isMainModule } from './lib/is-main-module.mjs';

const CAREER_OPS = getCareerOpsRoot();

/** Canonical header written when data/follow-ups.md doesn't exist yet. */
export const FOLLOWUPS_HEADER = [
  '# Follow-ups',
  '',
  '| num | appNum | date | company | role | channel | contact | notes |',
  '|---|---|---|---|---|---|---|---|',
].join('\n');

const FOLLOWUPS_LOCK_PREFIX = 'career-ops-followups-';

/**
 * Minimum age before directory age alone may condemn an ownerless lock.
 * See `lockRecoveryVerdict` for why the age check needs a floor.
 *
 * Re-exported rather than redeclared: the floor is applied inside that function
 * now, so a local copy would be a constant this file no longer enforces.
 */
export { OWNERLESS_GRACE_MS } from './lib/dir-lock.mjs';

/** Structured error carrying an exit-code-mapping `code`. */
export class SeedError extends Error {
  constructor(code, message) {
    super(message);
    this.name = 'SeedError';
    this.code = code;
  }
}

function todayStr() {
  return localToday();
}

/**
 * Validate a `YYYY-MM-DD` string is both well-formed AND a real calendar date.
 *
 * `parseDate` (followup-cadence.mjs) happily accepts `2026-02-31` because the
 * underlying `Date` constructor silently rolls invalid days over into the next
 * month. Round-tripping the parsed date back through `toISOString` catches
 * that: a real date always survives the round trip unchanged.
 *
 * @param {string} str
 * @returns {boolean}
 */
export function isValidCalendarDate(str) {
  if (typeof str !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(str)) return false;
  const d = parseDate(str);
  if (!d || isNaN(d)) return false;
  return d.toISOString().slice(0, 10) === str;
}

/**
 * Resolve the date an application was actually submitted, AND say where that
 * date came from: explicit `--date` > "Applied YYYY-MM-DD" in the row's notes >
 * the tracker's `date` column > today.
 *
 * The `date` column used to be excluded on the grounds that it is the
 * evaluation date, not the submission date. True, but it was the wrong
 * conclusion, because the alternative was `todayStr()` — and today is not a
 * neutral default here, it is the single most damaging guess available:
 *
 *   - the evaluation date is early by however long the user took to apply,
 *     which makes a follow-up fire early. Visible, and harmless.
 *   - today makes the application look brand new, which silently RESETS the
 *     follow-up clock. Someone who applied three weeks ago drops out of the
 *     chase queue, and the pin looks exactly like a normal fresh entry.
 *
 * A wrong date you can see beats a wrong date you cannot, so the proxy is now
 * preferred over the guess and `appDateSource` says which one you got. That
 * matches followup-cadence.mjs's `resolveAppliedDate` (same fallback, same
 * labelling) and company-history.mjs's `dateBasis`, so all three agree.
 *
 * `today` remains the last resort for a row whose `date` column is missing or
 * unusable — but it is now labelled too, rather than being indistinguishable
 * from a measured date.
 *
 * A notes date that isn't a real calendar date (e.g. "Applied 2026-02-31")
 * throws rather than falling through: `parseDate` would return null for it and
 * the pin would be written with a literal "null" next-date.
 *
 * @param {{num?: number, notes?: string, date?: string}} row - Parsed tracker row.
 * @param {string|null|undefined} explicitDate - `--date` value, already validated.
 * @param {string|null|undefined} [statusChangeDate] - The day a caller moved the row
 *   to Applied. It ranks below the notes date, which the user wrote on purpose, and
 *   above the evaluation-date column, which is only a proxy.
 * @returns {{appliedDate: string, appDateSource: 'explicit'|'notes'|'status-change'|'evaluation-date'|'today'}}
 * @throws {SeedError} INVALID_DATE when the notes carry an impossible date.
 */
export function resolveAppliedDate(row, explicitDate, statusChangeDate) {
  if (explicitDate) return { appliedDate: explicitDate, appDateSource: 'explicit' };
  const notesDate = parseAppliedDate(row?.notes);
  if (notesDate) {
    if (!isValidCalendarDate(notesDate)) {
      throw new SeedError('INVALID_DATE', `Application #${row?.num ?? '?'} notes carry an impossible "Applied ${notesDate}" date; fix the notes or pass --date`);
    }
    return { appliedDate: notesDate, appDateSource: 'notes' };
  }
  if (statusChangeDate) return { appliedDate: statusChangeDate, appDateSource: 'status-change' };
  // Only a usable calendar date qualifies: a blank or malformed `date` cell
  // must not become a pin with a "null" next-date, which is the same failure
  // the notes branch throws over.
  const columnDate = String(row?.date ?? '').trim();
  if (isValidCalendarDate(columnDate)) {
    return { appliedDate: columnDate, appDateSource: 'evaluation-date' };
  }
  return { appliedDate: todayStr(), appDateSource: 'today' };
}

/**
 * Format one pin directive line. The parser side lives in
 * followup-cadence.mjs's `OVERRIDE_RE` / `parseNextOverrides`.
 *
 * @param {number} appNum
 * @param {string} nextDate - YYYY-MM-DD
 * @param {string} setDate - YYYY-MM-DD
 * @returns {string}
 */
export function formatPinLine(appNum, nextDate, setDate) {
  return `- next #${appNum} ${nextDate} (set ${setDate})`;
}

// --- Path resolution (env override → option override → default) -----------

function resolveTrackerPath(override) {
  if (override) return override;
  return sharedResolveTrackerPath(CAREER_OPS);
}

function resolveFollowupsPath(override) {
  if (override) return override;
  if (process.env.CAREER_OPS_FOLLOWUPS) return process.env.CAREER_OPS_FOLLOWUPS;
  return join(CAREER_OPS, 'data/follow-ups.md');
}

function envInt(name, fallback) {
  const v = process.env[name];
  if (v === undefined || v === '') return fallback;
  const n = Number(v);
  return Number.isFinite(n) ? n : fallback;
}

// --- Tracker reading ---------------------------------------------------

function readTrackerRows(trackerPath) {
  const content = readFileSync(trackerPath, 'utf-8');
  const lines = content.split('\n');
  const parseOptions = { allowTabs: true, allowIndentation: true };
  const colmap = resolveColumns(lines, parseOptions);
  const rows = [];
  for (const line of lines) {
    const row = parseTrackerRow(line, colmap, parseOptions);
    if (row) rows.push(row);
  }
  return rows;
}

// --- Idempotency: pin OR an existing follow-up for appNum -----------------

function isAlreadySeeded(content, appNum) {
  if (!content) return false;
  if (parseNextOverrides(content).has(appNum)) return true;
  return parseFollowups(content).some(entry => entry.appNum === appNum);
}

// --- Locking (mirrors merge-tracker.mjs's tracker lock, scoped to follow-ups) --

function pathIsInside(childPath, parentDir) {
  const relativePath = relative(parentDir, childPath);
  return relativePath === '' || (relativePath !== '..' && !relativePath.startsWith(`..${sep}`) && !isAbsolute(relativePath));
}

function resolveFollowupsLockDir(envValue, lockKey) {
  const tmpRoot = realpathSync(tmpdir());
  const fallback = join(tmpRoot, `${FOLLOWUPS_LOCK_PREFIX}${lockKey}.lock`);
  if (!envValue || !isAbsolute(envValue)) return fallback;

  const candidate = resolve(envValue);
  const parentDir = dirname(candidate);
  const canonicalParent = existsSync(parentDir) ? realpathSync(parentDir) : resolve(parentDir);
  if (!pathIsInside(canonicalParent, tmpRoot)) return fallback;
  if (!basename(candidate).startsWith(FOLLOWUPS_LOCK_PREFIX)) return fallback;
  return candidate;
}

function resolveLockDir(explicitLockDir, followupsPath) {
  if (explicitLockDir) return explicitLockDir;
  // Canonicalize the parent so the key stays stable when the file is first created.
  const lockPath = join(canonicalizeTrackerPath(dirname(followupsPath)), basename(followupsPath));
  const lockKey = createHash('sha256').update(lockPath).digest('hex').slice(0, 16);
  return resolveFollowupsLockDir(process.env.CAREER_OPS_FOLLOWUPS_LOCK, lockKey);
}

/**
 * Acquire an exclusive filesystem lock covering the read-check-append
 * critical section for data/follow-ups.md, through the protocol every core
 * lock shares (lib/dir-lock.mjs).
 *
 * @param {string} lockDir
 * @param {string} followupsPath - Recorded in owner.json for diagnostics.
 * @param {{timeoutMs?: number, retryMs?: number, staleMs?: number}} [options] - `staleMs` is floored at OWNERLESS_GRACE_MS.
 * @returns {Promise<{release: Function}>}
 */
function acquireFollowupsLock(lockDir, followupsPath, options = {}) {
  return acquireDirLock(lockDir, {
    timeoutMs: options.timeoutMs ?? 60_000,
    retryMs: options.retryMs ?? 75,
    staleMs: options.staleMs ?? 10 * 60_000,
    owner: { followups: followupsPath },
    timeoutError: () => new SeedError('LOCK_TIMEOUT', `Timed out waiting for follow-ups lock at ${lockDir}`),
  });
}

// --- Atomic write (mirrors writeFileAtomic in tracker.mjs / merge-tracker.mjs) --

function writeFileAtomic(filePath, content) {
  const tmpPath = join(dirname(filePath), `.${basename(filePath)}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`);
  try {
    writeFileSync(tmpPath, content);
    renameSyncWithRetry(tmpPath, filePath);
  } catch (err) {
    rmSync(tmpPath, { force: true });
    throw err;
  }
}

function appendPins(existingContent, pinLines) {
  const joined = pinLines.join('\n');
  if (existingContent == null) {
    return `${FOLLOWUPS_HEADER}\n${joined}\n`;
  }
  return existingContent + (existingContent.endsWith('\n') ? '' : '\n') + joined + '\n';
}

// --- Core: seed one application --------------------------------------------

/**
 * Seed data/follow-ups.md with a pin directive for one Applied application.
 *
 * Silent-no-op-forever by default: once a pin or a follow-up table row exists
 * for `appNum`, subsequent calls return `{seeded:false, reason:'already-seeded'}`
 * without touching the file, unless `options.force` is set.
 *
 * @param {number} appNum
 * @param {object} [options]
 * @param {string} [options.date] - Explicit apply date (YYYY-MM-DD), already validated.
 * @param {string} [options.statusChangeDate] - The day the row moved to Applied, see resolveAppliedDate.
 * @param {boolean} [options.force] - Bypass idempotency guard and the Applied-status guard.
 * @param {boolean} [options.assumeApplied] - Relax the Applied-status guard ONLY, keeping
 *   idempotency intact. For a caller that is about to make the row Applied and wants a
 *   truthful preview of what will happen: `force` would answer that question by also
 *   suppressing `already-seeded`, so the preview would promise a pin the real run then
 *   declines to write. This says "treat the status as Applied", not "write regardless".
 * @param {boolean} [options.dryRun] - Compute and report, but write nothing (no lock taken).
 * @param {string} [options.trackerPath]
 * @param {string} [options.followupsPath]
 * @param {string} [options.profilePath]
 * @param {string} [options.lockDir]
 * @param {number} [options.lockTimeoutMs]
 * @param {number} [options.lockRetryMs]
 * @param {number} [options.lockStaleMs]
 * @returns {Promise<object>}
 */
/**
 * Run `fn` while holding the cross-process lock on a follow-ups file.
 *
 * EXISTS BECAUSE "TAKE A LOCK" IS NOT ENOUGH HERE (#3034). The lock directory
 * is DERIVED — sha256 of the resolved follow-ups path, under tmpdir — so two
 * writers that each take "a lock" on separately computed directories exclude
 * nothing at all. Any second implementation has to reproduce that derivation
 * exactly, and a derivation copied into another codebase is a second body of
 * the same truth: it works until the day one side changes.
 *
 * So the derivation never leaves this file. Callers outside the core (the web
 * dashboard's follow-up log and override routes) import this function and get
 * the same directory by construction.
 *
 * TWO PROPERTIES CALLERS DEPEND ON, both deliberate:
 *
 *   1. The lock is released on EVERY path, including a throw. A long-lived
 *      process that returns a 500 without releasing would block the user's own
 *      CLI until staleMs elapses, so the `finally` lives here rather than in
 *      each caller's hands.
 *
 *   2. It is NOT reentrant. Holding this lock and then invoking a core script
 *      that also takes it (`followup-seed.mjs` itself) self-deadlocks until the
 *      timeout. Callers that write the file directly are safe; callers that
 *      orchestrate core scripts must not wrap them in this.
 *
 * An in-process queue is not a substitute: it serialises one process against
 * itself and is blind to every other one. The two compose — queue inside this
 * lock — and neither replaces the other.
 *
 * @param {string} followupsPath - Path to the follow-ups file to guard.
 * @param {() => Promise<T>|T} fn - Runs while the lock is held.
 * @param {object} [options]
 * @param {string} [options.lockDir] - Explicit lock directory (tests).
 * @param {number} [options.timeoutMs=60000]
 * @param {number} [options.retryMs=75]
 * @param {number} [options.staleMs=600000]
 * @returns {Promise<T>} Whatever `fn` returned.
 * @template T
 */
export async function withFollowupsLock(followupsPath, fn, options = {}) {
  const resolvedPath = resolveFollowupsPath(followupsPath);
  const lockDir = resolveLockDir(options.lockDir, resolvedPath);
  const lock = await acquireFollowupsLock(lockDir, resolvedPath, {
    timeoutMs: options.timeoutMs ?? envInt('CAREER_OPS_FOLLOWUPS_LOCK_TIMEOUT_MS', 60_000),
    retryMs: options.retryMs ?? envInt('CAREER_OPS_FOLLOWUPS_LOCK_RETRY_MS', 75),
    staleMs: options.staleMs ?? envInt('CAREER_OPS_FOLLOWUPS_LOCK_STALE_MS', 10 * 60_000),
  });
  try {
    return await fn();
  } finally {
    releaseOrWarn(lock);
  }
}

const PIN_LINE = /^-\s+next\s+#(\d+)\s+(\d{4}-\d{2}-\d{2})(?:\s+\(set\s+(\d{4}-\d{2}-\d{2})\))?(?:\s*[—–-]\s*(.*))?\s*$/i;

function pinLinePattern(appNum) {
  return new RegExp(`^-\\s+next\\s+#${appNum}\\s`, 'i');
}

function lastPin(content, appNum) {
  let found = null;
  for (const line of String(content ?? '').split('\n')) {
    const match = line.match(PIN_LINE);
    if (!match || Number(match[1]) !== appNum) continue;
    found = { date: match[2], setDate: match[3] || match[2], why: (match[4] || '').trim() };
  }
  return found;
}

function withoutPins(content, appNum) {
  const pattern = pinLinePattern(appNum);
  return String(content ?? '').split('\n').filter((line) => !pattern.test(line)).join('\n');
}

function appendPinLine(content, appNum, line) {
  const base = content == null ? `${FOLLOWUPS_HEADER}\n` : withoutPins(content, appNum);
  return `${base.endsWith('\n') ? base : `${base}\n`}${line}\n`;
}

function pinText(appNum, date, setDate, why) {
  const line = formatPinLine(appNum, date, setDate);
  return why ? `${line} — ${why}` : line;
}

export async function replaceNextPin(appNum, options = {}) {
  if (!Number.isInteger(appNum) || appNum <= 0) {
    throw new SeedError('USAGE', `Invalid appNum: ${appNum}`);
  }
  if (!isRealCalendarDate(options.date)) {
    throw new SeedError('INVALID_DATE', `next date must be a real calendar date in YYYY-MM-DD form: ${options.date}`);
  }
  const why = String(options.why ?? '').trim();
  if (/[\t\r\n]/.test(why)) {
    throw new SeedError('USAGE', 'pin note cannot contain a tab or newline');
  }
  const followupsPath = resolveFollowupsPath(options.followupsPath);
  const setDate = localToday(options.now instanceof Date ? options.now : new Date());
  return withFollowupsLock(followupsPath, () => {
    const existing = existsSync(followupsPath) ? readFileSync(followupsPath, 'utf8') : null;
    const current = existing == null ? null : lastPin(existing, appNum);
    if (current && current.date === options.date && current.why === why) {
      return { changed: false, date: options.date, setDate: current.setDate };
    }
    mkdirSync(dirname(followupsPath), { recursive: true });
    writeFileAtomic(followupsPath, appendPinLine(existing, appNum, pinText(appNum, options.date, setDate, why)));
    return { changed: true, date: options.date, setDate };
  }, { timeoutMs: options.lockTimeoutMs, lockDir: options.lockDir });
}

export async function clearNextPin(appNum, options = {}) {
  if (!Number.isInteger(appNum) || appNum <= 0) {
    throw new SeedError('USAGE', `Invalid appNum: ${appNum}`);
  }
  const followupsPath = resolveFollowupsPath(options.followupsPath);
  return withFollowupsLock(followupsPath, () => {
    if (!existsSync(followupsPath)) return { cleared: false };
    const existing = readFileSync(followupsPath, 'utf8');
    const next = withoutPins(existing, appNum);
    if (next === existing) return { cleared: false };
    writeFileAtomic(followupsPath, next);
    return { cleared: true };
  }, { timeoutMs: options.lockTimeoutMs, lockDir: options.lockDir });
}

export async function seedFollowup(appNum, options = {}) {
  if (!Number.isInteger(appNum) || appNum <= 0) {
    throw new SeedError('USAGE', `Invalid appNum: ${appNum}`);
  }
  if (options.date != null && !isValidCalendarDate(options.date)) {
    throw new SeedError('INVALID_DATE', `--date must be a real calendar date in YYYY-MM-DD form: ${options.date}`);
  }

  if (!loadFollowupsEnabled(options.profilePath)) {
    return { seeded: false, appNum, pin: null, nextDate: null, reason: 'disabled', ...(options.dryRun ? { dryRun: true } : {}) };
  }

  const trackerPath = resolveTrackerPath(options.trackerPath);
  const followupsPath = resolveFollowupsPath(options.followupsPath);

  if (!existsSync(trackerPath)) {
    throw new SeedError('ROW_NOT_FOUND', `Tracker not found at ${trackerPath}`);
  }
  const rows = readTrackerRows(trackerPath);
  const row = rows.find(r => r.num === appNum);
  if (!row) {
    throw new SeedError('ROW_NOT_FOUND', `Application #${appNum} not found in ${trackerPath}`);
  }

  const normalized = normalizeStatus(row.status);
  if (normalized !== 'applied' && !options.force && !options.assumeApplied) {
    throw new SeedError('NOT_APPLIED', `Application #${appNum} is not Applied (status: "${row.status.trim()}"); use --force to seed anyway`);
  }

  // appDateSource travels with the result so a caller can tell a measured apply
  // date from a proxy or a guess — the same contract followup-cadence.mjs and
  // company-history.mjs already expose.
  const { appliedDate, appDateSource } = resolveAppliedDate(row, options.date, options.statusChangeDate);
  const cadence = resolveCadenceConfig({ profilePath: options.profilePath });
  const nextDate = addDays(parseDate(appliedDate), cadence.applied_first);
  const setDate = todayStr();
  const pin = formatPinLine(appNum, nextDate, setDate);

  if (options.dryRun) {
    const existingContent = existsSync(followupsPath) ? readFileSync(followupsPath, 'utf-8') : '';
    if (isAlreadySeeded(existingContent, appNum) && !options.force) {
      return { seeded: false, appNum, pin: null, nextDate, appliedDate, appDateSource, setDate, reason: 'already-seeded', dryRun: true };
    }
    return { seeded: true, appNum, pin, nextDate, appliedDate, appDateSource, setDate, dryRun: true };
  }

  const lockDir = resolveLockDir(options.lockDir, followupsPath);
  const lock = await acquireFollowupsLock(lockDir, followupsPath, {
    timeoutMs: options.lockTimeoutMs ?? envInt('CAREER_OPS_FOLLOWUPS_LOCK_TIMEOUT_MS', 60_000),
    retryMs: options.lockRetryMs ?? envInt('CAREER_OPS_FOLLOWUPS_LOCK_RETRY_MS', 75),
    staleMs: options.lockStaleMs ?? envInt('CAREER_OPS_FOLLOWUPS_LOCK_STALE_MS', 10 * 60_000),
  });

  try {
    const existingContent = existsSync(followupsPath) ? readFileSync(followupsPath, 'utf-8') : null;
    if (existingContent != null && isAlreadySeeded(existingContent, appNum) && !options.force) {
      return { seeded: false, appNum, pin: null, nextDate, appliedDate, appDateSource, setDate, reason: 'already-seeded' };
    }

    mkdirSync(dirname(followupsPath), { recursive: true });
    writeFileAtomic(followupsPath, appendPins(existingContent, [pin]));
    return { seeded: true, appNum, pin, nextDate, appliedDate, appDateSource, setDate };
  } finally {
    releaseOrWarn(lock);
  }
}

// --- Core: backfill all Applied rows ---------------------------------------

/**
 * Seed every tracker row whose status normalizes to `applied` that doesn't
 * already have a pin or follow-up table row. Non-Applied rows are skipped
 * silently. Idempotent — re-running seeds nothing new.
 *
 * @param {object} [options] - Same shape as seedFollowup's options (minus `date`/appNum).
 * @returns {Promise<{seeded: object[], skipped: object[]}>}
 */
export async function seedBackfill(options = {}) {
  if (!loadFollowupsEnabled(options.profilePath)) return { seeded: [], skipped: [], reason: 'disabled' };
  const trackerPath = resolveTrackerPath(options.trackerPath);
  const followupsPath = resolveFollowupsPath(options.followupsPath);

  if (!existsSync(trackerPath)) {
    throw new SeedError('ROW_NOT_FOUND', `Tracker not found at ${trackerPath}`);
  }
  const rows = readTrackerRows(trackerPath);
  const appliedRows = rows.filter(r => normalizeStatus(r.status) === 'applied');
  const cadence = resolveCadenceConfig({ profilePath: options.profilePath });
  const setDate = todayStr();

  function planFor(row) {
    const { appliedDate, appDateSource } = resolveAppliedDate(row, null);
    const nextDate = addDays(parseDate(appliedDate), cadence.applied_first);
    return { appNum: row.num, pin: formatPinLine(row.num, nextDate, setDate), nextDate, appliedDate, appDateSource, setDate };
  }

  if (options.dryRun) {
    const existingContent = existsSync(followupsPath) ? readFileSync(followupsPath, 'utf-8') : '';
    const seeded = [];
    const skipped = [];
    for (const row of appliedRows) {
      if (isAlreadySeeded(existingContent, row.num) && !options.force) {
        skipped.push({ appNum: row.num, reason: 'already-seeded' });
        continue;
      }
      try {
        seeded.push({ ...planFor(row), dryRun: true });
      } catch (err) {
        if (err instanceof SeedError && err.code === 'INVALID_DATE') {
          skipped.push({ appNum: row.num, reason: 'invalid-notes-date', detail: err.message });
        } else {
          throw err;
        }
      }
    }
    return { seeded, skipped, dryRun: true };
  }

  const lockDir = resolveLockDir(options.lockDir, followupsPath);
  const lock = await acquireFollowupsLock(lockDir, followupsPath, {
    timeoutMs: options.lockTimeoutMs ?? envInt('CAREER_OPS_FOLLOWUPS_LOCK_TIMEOUT_MS', 60_000),
    retryMs: options.lockRetryMs ?? envInt('CAREER_OPS_FOLLOWUPS_LOCK_RETRY_MS', 75),
    staleMs: options.lockStaleMs ?? envInt('CAREER_OPS_FOLLOWUPS_LOCK_STALE_MS', 10 * 60_000),
  });

  try {
    const existingContent = existsSync(followupsPath) ? readFileSync(followupsPath, 'utf-8') : null;
    const checkContent = existingContent ?? '';
    const seeded = [];
    const skipped = [];
    const newPins = [];
    for (const row of appliedRows) {
      if (isAlreadySeeded(checkContent, row.num) && !options.force) {
        skipped.push({ appNum: row.num, reason: 'already-seeded' });
        continue;
      }
      let plan;
      try {
        plan = planFor(row);
      } catch (err) {
        if (err instanceof SeedError && err.code === 'INVALID_DATE') {
          skipped.push({ appNum: row.num, reason: 'invalid-notes-date', detail: err.message });
          continue;
        }
        throw err;
      }
      seeded.push(plan);
      newPins.push(plan.pin);
    }

    if (newPins.length > 0) {
      mkdirSync(dirname(followupsPath), { recursive: true });
      writeFileAtomic(followupsPath, appendPins(existingContent, newPins));
    }

    return { seeded, skipped };
  } finally {
    releaseOrWarn(lock);
  }
}

// --- CLI ---------------------------------------------------------------

const USAGE = `Usage:
  node followup-seed.mjs <appNum> [--date YYYY-MM-DD] [--force] [--dry-run] [--json]
  node followup-seed.mjs --backfill [--force] [--dry-run] [--json]

  --date <YYYY-MM-DD>  override applied date (single-app only)
  --backfill           seed all Applied rows lacking pins/logs
  --force              re-seed even if already pinned/logged
  --dry-run            compute without writing follow-ups.md
  --json               machine-readable JSON output
  --help, -h           print this and exit`;

function parseCliArgs(argv) {
  const opts = { force: false, dryRun: false, json: false, backfill: false, date: null, appNum: null, help: false };
  const positionals = [];
  for (let i = 0; i < argv.length; i++) {
    const a = argv[i];
    if (a === '--date' || a.startsWith('--date=')) {
      const val = flagValue(argv.slice(i, i + 2), '--date');
      if (val === undefined) throw new SeedError('USAGE', '--date requires a value');
      opts.date = val;
      if (a === '--date') i++;
    } else if (a === '--help' || a === '-h') {
      opts.help = true;
    } else if (a === '--force') {
      opts.force = true;
    } else if (a === '--dry-run') {
      opts.dryRun = true;
    } else if (a === '--json') {
      opts.json = true;
    } else if (a === '--backfill') {
      opts.backfill = true;
    } else if (a.startsWith('--')) {
      throw new SeedError('USAGE', `Unknown flag: ${a}`);
    } else {
      positionals.push(a);
    }
  }

  if (opts.help) return opts;

  if (opts.backfill) {
    if (positionals.length !== 0) {
      throw new SeedError('USAGE', '--backfill does not take a positional appNum');
    }
    if (opts.date != null) {
      throw new SeedError('USAGE', '--date cannot be combined with --backfill (each row resolves its own apply date from its notes)');
    }
  } else {
    if (positionals.length !== 1) {
      throw new SeedError('USAGE', 'Usage: node followup-seed.mjs <appNum> [--date YYYY-MM-DD] [--force] [--dry-run] [--json]');
    }
    const raw = positionals[0];
    const n = parseInt(raw, 10);
    if (isNaN(n) || n <= 0 || String(n) !== raw.trim()) {
      throw new SeedError('USAGE', `Invalid appNum: ${raw}`);
    }
    opts.appNum = n;
  }

  return opts;
}

const EXIT_CODES = {
  USAGE: 1,
  INVALID_DATE: 1,
  NOT_APPLIED: 1,
  ROW_NOT_FOUND: 2,
  LOCK_TIMEOUT: 4,
};

/**
 * How the apply date was arrived at, for the human-readable line — empty for a
 * date that was actually measured.
 *
 * The whole point of tracking the source is that the user can see when a
 * cadence is being counted from a proxy rather than from the real submission
 * date, so an unmeasured date has to say so where they will actually look. In
 * --json it travels as `appDateSource`.
 */
function appDateNote(source) {
  if (source === 'evaluation-date') return ', from the evaluation date — notes carry no apply date';
  if (source === 'today') return ', assumed today — no apply date and no evaluation date';
  return '';
}

function reportSingle(result, json) {
  if (json) {
    console.log(JSON.stringify(result));
    return;
  }
  if (result.reason === 'disabled') {
    console.log('⏭️  Follow-ups are turned off (follow_ups.enabled: false in config/profile.yml); nothing seeded.');
    return;
  }
  const dryTag = result.dryRun ? ' [dry-run]' : '';
  if (result.seeded) {
    console.log(`✅ Seeded #${result.appNum}: next follow-up ${result.nextDate} (applied ${result.appliedDate}${appDateNote(result.appDateSource)}, set ${result.setDate})${dryTag}`);
  } else {
    console.log(`⏭️  #${result.appNum} already seeded — no-op (${result.reason})${dryTag}`);
  }
}

function reportBackfill(result, json) {
  if (json) {
    console.log(JSON.stringify(result));
    return;
  }
  if (result.reason === 'disabled') {
    console.log('⏭️  Follow-ups are turned off (follow_ups.enabled: false in config/profile.yml); nothing seeded.');
    return;
  }
  const dryTag = result.dryRun ? ' [dry-run]' : '';
  console.log(`✅ Backfill${dryTag}: seeded ${result.seeded.length}, skipped ${result.skipped.length}`);
  for (const s of result.seeded) console.log(`  + #${s.appNum}: next ${s.nextDate}${appDateNote(s.appDateSource)}`);
  for (const s of result.skipped) console.log(`  - #${s.appNum}: ${s.reason}`);
}

async function main() {
  const argv = process.argv.slice(2);
  const jsonRequested = argv.includes('--json');
  // Bound from the raw argv rather than from opts.json: a parse that fails mid-argv
  // never settles opts.json, and the two disagree for `--date --json` (#5198).
  const failWith = makeCliFailWith(jsonRequested);
  let opts;
  try {
    opts = parseCliArgs(argv);
  } catch (err) {
    failWith(EXIT_CODES[err.code] ?? 1, err.code || 'USAGE', err.message);
    return;
  }

  if (opts.help) {
    console.log(USAGE);
    process.exit(0);
  }

  try {
    if (opts.backfill) {
      const result = await seedBackfill({ dryRun: opts.dryRun, force: opts.force });
      reportBackfill(result, opts.json);
    } else {
      const result = await seedFollowup(opts.appNum, { date: opts.date, force: opts.force, dryRun: opts.dryRun });
      reportSingle(result, opts.json);
    }
    process.exit(0);
  } catch (err) {
    const code = err.code || 'ERROR';
    const exitCode = EXIT_CODES[code] ?? 1;
    failWith(exitCode, code, err.message);
  }
}

// Run (CLI only; guarded so the module is safely importable for tests).
if (isMainModule(import.meta.url)) {
  main();
}