#!/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 # age cutoff in days (default ${DEFAULT_DAYS}) node close-stale.mjs --status # 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 # 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} [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, }; }