#!/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(/(? 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 " 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); }