Ctrl K
ComunidadGuías…

close-stale.mjs

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

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env node
/**
 * close-stale.mjs — close long-silent `Applied` tracker rows in bulk (#5794)
 *
 * A tracker used for a long search fills up with `Applied` rows nobody will ever
 * answer. Left as `Applied` they keep reading as "still in flight" to the funnel
 * (`stats.mjs`, `funnel-velocity.mjs`), the follow-up cadence and the dashboard.
 * The convention for closing one already exists — `modes/outcome.md` maps
 * `no_response` to `Discarded` — but only one row at a time. This script applies
 * it to every row that has gone quiet for longer than `--days`.
 *
 * Opt-in and zero-LLM: no network, no schema change.
 *
 * Dry run by default. Nothing is written unless `--confirm` is passed.
 *
 * A row is a candidate only when ALL hold:
 *   - its status resolves to `Applied` (aliases such as "Sent" count);
 *   - it is older than `--days` days (strictly: a row exactly N days old is
 *     kept). Age is measured from the LATEST of the Date column, an
 *     `Applied YYYY-MM-DD` date in Notes, and the day status-log.tsv recorded
 *     the move to Applied, so a late evaluation date never makes an application
 *     look older than it is.
 *
 * A candidate is SKIPPED (and reported with its reason) when it has any later
 * signal that somebody might still be answering:
 *   - `active-interview`   the company has an entry in data/active-interviews.md
 *                          (company match only, with or without the legal
 *                          form; a role mismatch must not close an
 *                          application the user is interviewing for);
 *   - `status-log`         data/status-log.tsv holds a transition after Applied;
 *   - `open-follow-up`     data/follow-ups.md has a follow-up logged for this row
 *                          within the last `--days` days, or an uncleared `next`
 *                          pin dated today or later;
 *   - `previously-auto-closed`  the row was closed by this script before and
 *                          reopened since (the note tag or a `close-stale`
 *                          ledger line is present): the user's reopen wins;
 *   - `duplicate-num`      two rows share the tracker # (#1704), so the ledger
 *                          cannot say which one a transition belongs to.
 *
 * Rows in any other state (Evaluated, Responded, Assessment, Interview, Offer,
 * Hired, Rejected, Discarded, SKIP) are never touched.
 *
 * `--confirm` rewrites each candidate row inside ONE tracker transaction (the
 * same lock and atomic replace dedup-tracker.mjs uses): the Status cell becomes
 * the target state, the fixed tag `no response (auto-closed YYYY-MM-DD, Nd)` is
 * appended to Notes, and one `source=close-stale` line per row is appended to
 * data/status-log.tsv. The tag is how these rows are told apart from real
 * rejections and found again if a reply arrives later. Re-running changes
 * nothing: closed rows are no longer Applied.
 *
 * Run: node close-stale.mjs                        (dry run, JSON to stdout)
 *      node close-stale.mjs --summary              (dry run, human-readable)
 *      node close-stale.mjs --days 45              (cutoff, default 60)
 *      node close-stale.mjs --status Rejected      (target state, default Discarded)
 *      node close-stale.mjs --confirm              (write)
 *      node close-stale.mjs --today 2026-10-17     (deterministic runs/tests)
 *
 * Paths follow CAREER_OPS_ROOT / CAREER_OPS_DATA_DIR / CAREER_OPS_TRACKER.
 */

import { readFileSync, existsSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';

import {
  resolveColumns, parseTrackerRow, splitTrackerCells, trackerRowSeparator, normalizeTextKey,
} from './tracker-parse.mjs';
import {
  loadCanonicalStates, resolveCanonicalState, openTrackerTransaction, appendLedgerRows, cell,
  companiesMatchIgnoringCorporateForm,
} from './tracker-utils.mjs';
import { getCareerOpsRoot, resolveTrackerPath } from './path-resolver.mjs';
import { parseActiveInterviews, findColumn } from './process-quality.mjs';
import {
  parseFollowups, parseNextOverrides, resolveNextOverride, parseClearedDirectives, isRetired,
  parseAppliedDate, isRealCalendarDate,
} from './followup-cadence.mjs';
import { parseStatusLog, foldObservations } from './funnel-velocity.mjs';
import { statusLogPath } from './lib/status-hooks.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';

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

export const DEFAULT_DAYS = 60;
export const DEFAULT_TARGET_STATUS = 'Discarded';
/** The status-log `source` for these transitions; a member of VALID_SOURCES in funnel-velocity.mjs. */
export const LEDGER_SOURCE = 'close-stale';
/** Every tag this script writes starts with this, which is how a reopened row is recognised. */
export const NOTE_TAG_PREFIX = 'no response (auto-closed';
const SAMPLE_SIZE = 10;

const KNOWN_FLAGS = ['--days', '--status', '--today', '--confirm', '--json', '--summary', '--help', '-h'];
const VALUE_FLAGS = ['--days', '--status', '--today'];

const USAGE = `Usage:
  node close-stale.mjs                      # dry run, JSON report
  node close-stale.mjs --summary            # dry run, human-readable report
  node close-stale.mjs --days <N>           # age cutoff in days (default ${DEFAULT_DAYS})
  node close-stale.mjs --status <state>     # target state (default ${DEFAULT_TARGET_STATUS}; any canonical state except Applied)
  node close-stale.mjs --confirm            # write the changes (default is a dry run)
  node close-stale.mjs --today <YYYY-MM-DD> # evaluate as of a fixed date
  node close-stale.mjs --help               # show this message

Only Applied rows older than the cutoff with no later signal are closed. Each
gets the note tag "${NOTE_TAG_PREFIX} YYYY-MM-DD, Nd)" and one status-log line.`;

// ── date helpers (strings in, strings out: no local-timezone arithmetic) ──

/** Whole days from one YYYY-MM-DD to another, both read at UTC midnight. */
export function daysBetween(fromIso, toIso) {
  const a = parseDate(fromIso);
  const b = parseDate(toIso);
  if (!a || !b) return null;
  return Math.round((b.getTime() - a.getTime()) / 86_400_000);
}

/** The ISO day `days` before `iso`. */
export function isoMinusDays(iso, days) {
  const d = parseDate(iso);
  return new Date(d.getTime() - days * 86_400_000).toISOString().slice(0, 10);
}

/** First valid YYYY-MM-DD in a cell, or null. */
function cellDate(value) {
  const m = /\d{4}-\d{2}-\d{2}/.exec(String(value ?? ''));
  return m && isRealCalendarDate(m[0]) ? m[0] : null;
}

/** The note tag for a closure made on `today` with a `days` cutoff. */
export function noteTag(today, days) {
  return `${NOTE_TAG_PREFIX} ${today}, ${days}d)`;
}

// ── signal loaders (pure over text, so tests can drive them directly) ──

/**
 * Company cells that have an entry in data/active-interviews.md.
 *
 * @param {string} content
 * @returns {string[]}
 */
export function interviewCompanies(content) {
  return parseActiveInterviews(content).map((row) => findColumn(row, 'company'));
}

function hasInterview(company, interviewNames) {
  const key = normalizeTextKey(company);
  return interviewNames.some((name) => normalizeTextKey(name) === key
    || companiesMatchIgnoringCorporateForm(name, company));
}

/**
 * Whether data/follow-ups.md holds an open follow-up for the tracker row.
 *
 * "Open" means somebody is still working the application: a follow-up logged
 * within the cutoff window, or a `next #N` pin dated today or later that no
 * later follow-up has superseded and no `cleared` directive has retired. The
 * pin `followup-seed.mjs` writes when a row turns Applied dates a week after the
 * application, so on a stale row it is long past and does not count.
 */
export function hasOpenFollowup(num, { entries, pins, cleared }, { today, cutoff }) {
  const mine = entries.filter((e) => e.appNum === num);
  const last = mine.map((e) => e.date).filter((d) => cellDate(d)).sort().pop() ?? null;
  if (last && last >= cutoff) return true;
  const pinDate = resolveNextOverride(pins.get(num), last);
  if (pinDate && pinDate >= today && !isRetired(cleared.get(num), last)) return true;
  return false;
}

/** Fold a status-log into per-row timelines; `null` when there is no log. */
function loadTimelines(logContent, states) {
  if (!logContent) return new Map();
  return foldObservations(parseStatusLog(logContent, states).observations);
}

// ── core ──

/**
 * Classify every tracker row. Pure: no I/O and no clock read (today is injected).
 *
 * @param {string} trackerMd
 * @param {object} ctx
 * @param {string} ctx.today - YYYY-MM-DD
 * @param {number} ctx.days
 * @param {object[]} ctx.states - loadCanonicalStates() output
 * @param {string[]} [ctx.interviewCompanies]
 * @param {{entries: object[], pins: Map, cleared: Map}} [ctx.followups]
 * @param {Map<number, object[]>} [ctx.timelines] - folded status-log
 * @returns {{appliedRows: number, candidates: object[], skipped: object[], recent: number, undated: number}}
 */
export function classifyRows(trackerMd, ctx) {
  const { today, days, states } = ctx;
  const interviewNames = ctx.interviewCompanies ?? [];
  const followups = ctx.followups ?? { entries: [], pins: new Map(), cleared: new Map() };
  const timelines = ctx.timelines ?? new Map();
  const cutoff = isoMinusDays(today, days);

  const lines = String(trackerMd ?? '').split('\n');
  const options = { allowTabs: true, allowIndentation: true };
  const colmap = resolveColumns(lines, options);

  const rows = [];
  for (let i = 0; i < lines.length; i++) {
    const row = parseTrackerRow(lines[i], colmap, options);
    if (row) rows.push({ ...row, lineIdx: i });
  }
  const numCounts = new Map();
  for (const r of rows) numCounts.set(r.num, (numCounts.get(r.num) ?? 0) + 1);

  const out = { appliedRows: 0, candidates: [], skipped: [], recent: 0, undated: 0, colmap, lines };
  for (const row of rows) {
    if (resolveCanonicalState(row.status, states) !== 'Applied') continue;
    out.appliedRows++;

    const timeline = timelines.get(row.num) ?? [];
    const lastAppliedIdx = timeline.map((o) => o.to).lastIndexOf('Applied');
    const loggedApplied = lastAppliedIdx === -1 ? null
      : (timeline[lastAppliedIdx].dayMath && isRealCalendarDate(timeline[lastAppliedIdx].date) ? timeline[lastAppliedIdx].date : null);
    const notesApplied = parseAppliedDate(row.notes, { requireValidCalendarDate: true });
    const referenceDate = [cellDate(row.date), notesApplied, loggedApplied].filter(Boolean).sort().pop() ?? null;
    if (!referenceDate) { out.undated++; continue; }

    const ageDays = daysBetween(referenceDate, today);
    // Strictly older than the cutoff: a row exactly `days` old is kept.
    if (ageDays <= days) { out.recent++; continue; }

    const info = { num: row.num, company: row.company, role: row.role, date: row.date, referenceDate, ageDays, lineIdx: row.lineIdx };
    const skip = (reason) => out.skipped.push({ ...info, reason });

    if (numCounts.get(row.num) > 1) { skip('duplicate-num'); continue; }
    if (String(row.notes ?? '').includes(NOTE_TAG_PREFIX) || timeline.some((o) => o.source === LEDGER_SOURCE)) {
      skip('previously-auto-closed'); continue;
    }
    if (hasInterview(row.company, interviewNames)) { skip('active-interview'); continue; }
    const later = lastAppliedIdx === -1
      ? timeline.filter((o) => o.to !== 'Applied' && o.date >= referenceDate)
      : timeline.slice(lastAppliedIdx + 1).filter((o) => o.to !== 'Applied');
    if (later.length > 0) { skip('status-log'); continue; }
    if (hasOpenFollowup(row.num, followups, { today, cutoff })) { skip('open-follow-up'); continue; }

    out.candidates.push(info);
  }
  return out;
}

/**
 * Replace one cell's value, keeping the padding around it (same rule as
 * set-status.mjs's replaceCellValue).
 */
function replaceCellValue(parts, index, value) {
  const original = parts[index] ?? '';
  const trimmed = original.trim();
  if (trimmed) {
    const start = original.indexOf(trimmed);
    parts[index] = original.slice(0, start) + value + original.slice(start + trimmed.length);
  } else if (original.length >= 2) {
    const half = Math.floor(original.length / 2);
    parts[index] = original.slice(0, half) + value + original.slice(half);
  } else {
    parts[index] = ` ${value} `;
  }
}

/**
 * Rewrite the candidate lines: Status cell -> target, tag appended to Notes.
 * Mutates and returns `lines`; touches no other line and no other cell.
 */
export function applyClosures(lines, colmap, candidates, { targetStatus, tag }) {
  for (const c of candidates) {
    const rawLine = lines[c.lineIdx];
    const separator = trackerRowSeparator(rawLine);
    const parts = splitTrackerCells(rawLine);
    while (parts.length <= Math.max(colmap.status, colmap.notes)) parts.push('');
    replaceCellValue(parts, colmap.status, targetStatus);
    const existing = (parts[colmap.notes] ?? '').trim();
    if (!existing.includes(tag)) {
      replaceCellValue(parts, colmap.notes, existing && existing !== '—' && existing !== '-' ? `${existing}; ${tag}` : tag);
    }
    if (separator === '\t') {
      const closing = rawLine.trimEnd().endsWith('|');
      lines[c.lineIdx] = parts[0] + '|' + parts.slice(1, closing ? -1 : undefined).join('\t')
        + (closing ? '|' + parts.at(-1) : '');
    } else {
      lines[c.lineIdx] = parts.join('|');
    }
  }
  return lines;
}

// ── CLI ──

function fail(message, code = 2) {
  console.error(`❌ ${message}`);
  process.exit(code);
}

/**
 * Contents of a file that may legitimately not exist yet.
 *
 * ENOENT is the only failure that means '' here. A user who has never logged a
 * follow-up has no data/follow-ups.md, and that absence is real information:
 * the open-follow-up gate genuinely has nothing to say about any row.
 *
 * Every OTHER failure means the file is THERE and could not be read, and ''
 * would be a lie with teeth. All three of this script's skip signals are read
 * through here — the open-follow-up gate, the active-interview gate and the
 * status-log gate — and each one skips a row by finding evidence IN the file.
 * No evidence found is indistinguishable from no file, so an unreadable
 * follow-ups file does not stop the run: it silently switches off the
 * protection and keeps closing rows. With --confirm that writes `Discarded`
 * over applications the user is still following up on, or still interviewing
 * for, which is the one outcome this script's own header promises cannot
 * happen.
 *
 * So anything that is not ENOENT ends the run. A gate that cannot be evaluated
 * is not a gate that passed.
 */
function readIfExists(path) {
  try {
    return readFileSync(path, 'utf-8');
  } catch (err) {
    if (err.code === 'ENOENT') return '';
    fail(`cannot read ${path}: ${err.message}. This file decides which rows are skipped, `
      + 'so closing rows without it would disable that check rather than apply it.');
    return '';  // unreachable: fail() exits
  }
}

function loadContext({ dataRoot, trackerPath, today, days, states }) {
  const interviewsPath = existsSync(join(dataRoot, 'data/active-interviews.md'))
    ? join(dataRoot, 'data/active-interviews.md')
    : join(dataRoot, 'active-interviews.md');
  const followupsPath = process.env.CAREER_OPS_FOLLOWUPS || join(dataRoot, 'data/follow-ups.md');
  const followupsMd = readIfExists(followupsPath);
  return {
    today,
    days,
    states,
    interviewCompanies: interviewCompanies(readIfExists(interviewsPath)),
    followups: {
      entries: parseFollowups(followupsMd),
      pins: parseNextOverrides(followupsMd),
      cleared: parseClearedDirectives(followupsMd),
    },
    timelines: loadTimelines(readIfExists(statusLogPath(trackerPath)), states),
  };
}

function skippedByReason(skipped) {
  const counts = {};
  for (const s of skipped) counts[s.reason] = (counts[s.reason] ?? 0) + 1;
  return counts;
}

function publicRow(r) {
  const { lineIdx, ...rest } = r;
  return rest;
}

function printSummary(report) {
  const m = report.metadata;
  console.log(`\nclose-stale — ${m.confirm ? 'CONFIRMED RUN' : 'dry run'} as of ${m.today}\n`);
  console.log(`  Applied rows:            ${m.appliedRows}`);
  console.log(`  Older than ${m.days} days:      ${m.candidates + m.skipped}  (cutoff: before ${m.cutoff})`);
  console.log(`  ${m.confirm ? 'Closed' : 'Would close'} -> ${m.targetStatus}:   ${m.confirm ? m.closed : m.candidates}`);
  console.log(`  Skipped (later signal):  ${m.skipped}`);
  for (const [reason, n] of Object.entries(m.skippedByReason)) console.log(`    - ${reason}: ${n}`);
  if (m.undated) console.log(`  Applied rows with no readable date (left alone): ${m.undated}`);
  if (report.candidates.length) {
    console.log(`\n  Sample (${Math.min(SAMPLE_SIZE, report.candidates.length)} of ${report.candidates.length}, oldest first):`);
    for (const c of report.candidates.slice(0, SAMPLE_SIZE)) {
      console.log(`    #${c.num}  ${c.referenceDate}  ${c.ageDays}d  ${c.company} — ${c.role}`);
    }
  }
  if (!m.confirm && m.candidates > 0) console.log(`\n  Re-run with --confirm to close ${m.candidates} row(s).`);
  console.log('');
}

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);

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

  let days = DEFAULT_DAYS;
  const daysArg = valueOf('--days');
  if (daysArg !== null) {
    if (!/^\d+$/.test(daysArg) || !Number.isSafeInteger(Number(daysArg)) || Number(daysArg) < 1) {
      fail(`--days expects a positive whole number of days, received "${daysArg}".`);
    }
    days = Number(daysArg);
  }

  let states;
  try {
    states = loadCanonicalStates(STATES_FILE);
  } catch (err) {
    fail(`Cannot load canonical states from ${STATES_FILE}: ${err.message}`);
  }
  const statusArg = valueOf('--status') ?? DEFAULT_TARGET_STATUS;
  const targetStatus = resolveCanonicalState(statusArg, states);
  if (!targetStatus) {
    fail(`"${statusArg}" is not a canonical state. Valid states: ${states.map((s) => s.label).join(' · ')}`);
  }
  if (targetStatus === 'Applied') fail('--status Applied would close nothing: pick the state to move stale rows into.');

  const confirm = hasFlag(args, '--confirm');
  const dataRoot = getCareerOpsRoot();
  const trackerPath = resolveTrackerPath(dataRoot);
  const tag = noteTag(today, days);

  const warnings = [];
  let report;
  if (!existsSync(trackerPath)) {
    warnings.push(`No tracker found at ${trackerPath}; nothing to close.`);
    report = buildReport({ classified: { appliedRows: 0, candidates: [], skipped: [], recent: 0, undated: 0 }, today, days, targetStatus, confirm, closed: 0, warnings, trackerPath });
  } else {
    let tx = null;
    let content;
    if (confirm) {
      try {
        tx = await openTrackerTransaction(trackerPath);
      } catch (err) {
        fail(`Cannot acquire the tracker lock: ${err.message}`, err?.code === 'LOCK_TIMEOUT' ? 4 : 2);
      }
    }
    try {
      // Read inside the try so a read failure still releases the lock.
      content = confirm ? tx.read() : readFileSync(trackerPath, 'utf-8');
      // Signals are read INSIDE the lock window on --confirm, so a status-log
      // line a concurrent set-status appends cannot be missed.
      const ctx = loadContext({ dataRoot, trackerPath, today, days, states });
      const classified = classifyRows(content, ctx);
      let closed = 0;
      if (classified.candidates.length && classified.colmap?.notes == null) {
        throw new Error('Tracker has no Notes column — cannot append the auto-closed tag.');
      }
      if (confirm && classified.candidates.length) {
        const newLines = applyClosures([...classified.lines], classified.colmap, classified.candidates, { targetStatus, tag });
        tx.replace(newLines.join('\n'));
        closed = classified.candidates.length;
        // Observation trail only: the tracker stays the source of truth, so a
        // failed append is a warning (same policy as set-status.mjs).
        try {
          const text = classified.candidates
            .map((c) => `${c.num}\t${today}\tApplied\t${targetStatus}\t${LEDGER_SOURCE}\t${cell(`${days}d`)}\n`)
            .join('');
          appendLedgerRows(statusLogPath(trackerPath), text);
        } catch (err) {
          warnings.push(`status-log append failed (tracker was updated): ${err.message}`);
        }
      }
      report = buildReport({ classified, today, days, targetStatus, confirm, closed, warnings, trackerPath });
    } catch (err) {
      // fail() exits the process, which would skip the finally below and leave
      // the tracker lock behind until it goes stale.
      tx?.close();
      fail(err.message);
    } finally {
      tx?.close();
    }
  }

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

function buildReport({ classified, today, days, targetStatus, confirm, closed, warnings, trackerPath }) {
  const candidates = [...classified.candidates].sort((a, b) => b.ageDays - a.ageDays || a.num - b.num);
  return {
    metadata: {
      today,
      days,
      cutoff: isoMinusDays(today, days),
      targetStatus,
      confirm,
      trackerPath,
      appliedRows: classified.appliedRows,
      candidates: candidates.length,
      skipped: classified.skipped.length,
      skippedByReason: skippedByReason(classified.skipped),
      recent: classified.recent,
      undated: classified.undated,
      closed,
      noteTag: noteTag(today, days),
    },
    candidates: candidates.map(publicRow),
    skipped: classified.skipped.map(publicRow),
    warnings,
  };
}