set-status.mjs
Rama main · réplica de lectura del repositorio original.
#!/usr/bin/env node
/**
* set-status.mjs — canonical CLI to update a tracker row's status/note (#1428).
*
* data/applications.md is a shared surface with multiple readers and writers.
* One canonical write path is safer than N agents hand-editing markdown, so
* modes (apply Step 9, followup, batch) call this instead of editing the table.
*
* Usage:
* node set-status.mjs <tracker#|company> <state> [--note "..."] [--role "..."] [--force] [--dry-run] [--json]
*
* Row resolution:
* - --row N → exact match on the # column, stated explicitly
* - --report N → match the row whose Report cell links report #N
* - numeric argument → exact match on the # column; if the tracker has a
* duplicate # (see #1704 — merge-tracker.mjs bug, now fixed, that could
* assign the same # to two rows), --role narrows it, otherwise it fails
* ambiguous with a candidate list instead of silently editing whichever
* row was found first
* - a comma-separated list of numbers is not a company. The command updates
* one row, so it exits usage and prints one command per number
* - otherwise → company match (normalized, same key as merge-tracker dedup);
* multiple hits are narrowed with --role (fuzzy, role-matcher.mjs), and
* anything still ambiguous fails with a numbered candidate list.
*
* Why --row/--report exist:
* Tracker row IDs and report IDs are two independent counters sharing one
* number space. reserve-report-num.mjs treats tracker row IDs as occupied
* when allocating a report number, so the sequences leapfrog and never
* realign; every row added WITHOUT an evaluation report (backfilled rows,
* #1799) widens the gap permanently. A bare numeric selector is therefore
* genuinely ambiguous — "97" may mean row #97 or report #97, which are
* different applications — and the report-number-mismatch guard below fires
* on every such call once the counters have diverged. A guard that fires
* almost always trains callers to reach for --force, which disables it
* everywhere including the cases it was written for.
*
* --row and --report remove the ambiguity instead of suppressing the check.
* Both state which number space the caller means, so the mismatch guard is
* skipped as ANSWERED rather than overridden — unlike --force, which
* silences it while the ambiguity is still real.
*
* State validation is strict against templates/states.yml (labels, ids, and
* aliases resolve to the canonical label; anything else is rejected before the
* tracker is touched). --note appends to the Notes cell with "; " and is
* idempotent — re-running the same command is always safe. With --replace-note
* OLD, --note NEW instead replaces whole notes at "; " boundaries (including
* idempotent retries); a missing match fails without writing anything.
*
* The read-modify-write runs under the shared tracker lock (tracker-utils.mjs,
* same lock as merge-tracker.mjs) and the file is replaced atomically. Only the
* Status and Notes cells of the matched row change; every other byte of the
* tracker round-trips untouched.
*
* Exit codes: 0 success (including no-op re-runs) · 1 usage error,
* non-canonical state, unreadable states.yml, or non-retryable lock/write failure ·
* 2 row not found or unreadable tracker · 3 ambiguous company match ·
* 4 tracker lock timeout (busy — retry later).
*
* --undo takes back the row's last logged status change: it restores the
* prior status and appends a retraction line ("-" to-state) to the ledger, so
* every ledger reader drops the change as if it never happened. It refuses
* when the tracker no longer shows that change's status (someone edited the
* row since) and when the change has no known prior status.
*
* When the new status is Applied, the JSON output carries
* `"followupSeedCandidate": true` — the hook point for seeding
* data/follow-ups.md with the default cadence (#1430, not implemented here).
*
* When the new status is Interview and no JD is archived yet for the row
* (no embedded `## Job Description` section, no jds/ capture), this also
* triggers `archive-posting.mjs --report=N <url>` — the last reliable moment
* the posting is still likely live, and the last moment before interview-prep
* would go looking for a JD that may no longer exist (#4506). The outcome
* travels in the JSON output as `jdArchiveTriggered`. Same never-fails-the-
* status-change policy as follow-up seeding. With --detach-jd-archive the
* archive is started and not waited for (`jdArchiveTriggered.detached`), still
* bounded by CAREER_OPS_JD_ARCHIVE_TIMEOUT_MS. The web UI and the dashboard
* pass it: they kill this script after 30s, and the archive alone can take
* longer than that.
*
* Every real status change also appends one line to the transition ledger
* (status-log.tsv, sibling of the tracker file):
* {tracker#}\t{date}\t{from}\t{to}\t{source}\t
* Source is `set-status` unless --source names the caller delegating here.
* Date defaults to today; pass --on YYYY-MM-DD when the transition actually
* happened earlier ("they replied Tuesday"). The append is observation-only:
* if it fails, a warning goes to stderr and the exit code is unchanged — the
* tracker remains the source of truth for state. Read by funnel-velocity.mjs.
*
* Two rules the reader enforces that this writer never has to think about,
* because it canonicalizes the prior status and always writes its own source.
* Any other producer does have to, so they are stated here:
* - An unknown from- or to-state is the sentinel "-", never an empty cell.
* funnel-velocity.mjs reads the two columns differently: a from of "-"
* parses to null, meaning no prior state, while a to of "-" is preserved
* as the literal "-", meaning an unknown target. Any other value goes
* through resolveCanonicalState, so an empty cell is rejected as
* `unknown from-state ""` or `unknown to-state ""` for its own column,
* and the row is dropped.
* - The source column is a closed set, and VALID_SOURCES in
* funnel-velocity.mjs is the authority on its members. Deliberately not
* enumerated here: a copy of that list in prose is wrong the first time a
* writer is added, and it would be wrong in three files at once.
* A value outside the set parses but is excluded from day-math. The row
* is not lost and the exclusion is not silent: it is kept as an
* observation, recorded in unknownSources, and printed with its line
* number under dataQuality. Namespacing a source (say "backfill:notes")
* therefore keeps the row out of the day-math figures; put that detail in
* the note column.
*/
import { readFileSync, existsSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
import { createHash } from 'node:crypto';
import { extractTrackerReportNumbers, parseMarkdownLinks, soleMarkdownLink, resolveColumns, parseTrackerRow, normalizeTextKey, splitTrackerCells, trackerRowSeparator, trackerRowIdentity } from './tracker-parse.mjs';
import { roleFuzzyMatch } from './role-matcher.mjs';
import { localToday } from './lib/local-today.mjs';
import {
resolveTrackerPath, writeFileAtomic, loadCanonicalStates, resolveCanonicalState, stripStatusDecoration, statusTextAfterDate,
prependNote, hasNoteEntry, normalizeCompany, cell, CLI_EXIT, makeCliFailWith, makeCliFailUsage, acquireTrackerLockForCli,
appendLedgerRows, companiesMatchIgnoringCorporateForm,
} from './tracker-utils.mjs';
import { getCareerOpsRoot, resolveTrackerPathForWrite } from './path-resolver.mjs';
import {
claimStatusActivity, appendStatusTransition, statusLogPath, seedFollowupOnApplied, archiveJdOnInterview,
} from './lib/status-hooks.mjs';
import { parseStatusLog, replayObservations } from './funnel-velocity.mjs';
// Two roots. CODE_ROOT holds templates/states.yml, which ships with the code;
// DATA_ROOT is the user's, and getCareerOpsRoot() is the only thing that honours
// CAREER_OPS_ROOT / CAREER_OPS_DATA_DIR / the .career-ops-data marker.
//
// One constant named CAREER_OPS did both, so resolveTrackerPath() looked inside
// the checkout. AGENTS.md calls this script "the canonical (locked, validated,
// atomic) write path" and #2901 converged the web layer's /api/status onto it —
// so on any configured data root the one supported way to change a status
// answered "No tracker found at <CHECKOUT>/applications.md", naming a file the
// user never configured. Same defect #3715 fixed in the analysis scripts.
const CODE_ROOT = dirname(fileURLToPath(import.meta.url));
const DATA_ROOT = getCareerOpsRoot();
const STATES_FILE = join(CODE_ROOT, 'templates/states.yml');
// LOCK_TIMEOUT is not destructured here — that exit path is raised inside
// acquireTrackerLockForCli() itself (tracker-utils.mjs), via CLI_EXIT.LOCK_TIMEOUT.
const { OK: EXIT_OK, USAGE: EXIT_USAGE, NOT_FOUND: EXIT_NOT_FOUND, AMBIGUOUS: EXIT_AMBIGUOUS } = CLI_EXIT;
const USAGE = `Usage: node set-status.mjs <tracker#|company> <state> [--note "..."] [--role "..."] [--on YYYY-MM-DD] [--force] [--dry-run] [--json]
node set-status.mjs --row N <state> [...] (explicit tracker row ID)
node set-status.mjs --report N <state> [...] (explicit report ID)
node set-status.mjs --report-link N <state> [...] (strict Report-cell identity)
node set-status.mjs <tracker#|company> --undo [--dry-run] [--json]
<tracker#|company> Row selector: tracker # (exact) or company name (normalized match)
A list such as 14,78 is not a company. Run one command per row.
<state> Canonical state from templates/states.yml (aliases accepted)
--row N Select by tracker # explicitly (unambiguous; skips the mismatch guard)
--report N Select the row whose Report cell links report #N
--report-link N Select an exact numeric Report link; reject malformed cells
and unknown current states (used by the dashboard)
--note "..." Append to the Notes cell ("; "-separated, idempotent)
--note=VALUE Pass a literal note, including text beginning with --
--replace-note "OLD" Replace whole OLD notes with --note text; fail if neither is present
--role "..." Disambiguate when several rows share the company (fuzzy match)
--on YYYY-MM-DD Real event date for the status-log entry (defaults to today —
pass it when the transition happened earlier than it's recorded)
--source NAME Attribution for the transition ledger: set-status (default)
or web (a caller delegating to this script)
--expect-tracker HASH Require the tracker's exact UTF-8 content to match this
lowercase SHA-256 hash; checked under the writer lock
--expect-row HASH Require the selected row, every cell but Status, to match this
hash (trackerRowIdentity in tracker-parse.mjs); checked under
the writer lock
--expect-status STATE Require the row's current status to be STATE, unless it
is already <state>; checked under the writer lock
--undo Take back the row's last logged status change: restore the
prior status and retract the change from status-log.tsv.
Replaces <state>
--force Allow a numeric selector despite a report-link mismatch, or despite a
report-less row whose number another row claims as its report link
--dry-run Resolve and validate, but write nothing
--repair-status-log On an idempotent Applied retry, repair a missing ledger observation
--repair-followup On an idempotent Applied retry, repair a missing follow-up pin
--detach-jd-archive On a transition into Interview, start the JD archive in the
background instead of waiting for it (used by the web UI
and the dashboard, whose status calls time out at 30s)
--json Machine-readable output on stdout (errors included)
Tracker row IDs and report IDs are separate counters that diverge permanently
once any row exists without a report. Prefer --row/--report (or the company
name) over a bare number, and prefer any of them over --force.
Examples:
node set-status.mjs --report 12 Applied
node set-status.mjs --report 12 Interview --note "recruiter screen booked"
node set-status.mjs "Acme Corp" Rejected --on 2026-08-01
node set-status.mjs "Acme Corp" Applied --role "Platform Engineer"
node set-status.mjs --row 7 Discarded --dry-run
node set-status.mjs --row 7 --undo`;
/**
* Render the canonical states from states.yml for `--help`.
*
* A broken states.yml degrades to a pointer rather than throwing: that failure
* belongs to the run that tries to WRITE a state, not to `--help`.
*
* @returns {string} The states section, or a pointer line when unreadable.
*/
function renderStatesSection() {
let states;
try {
states = loadCanonicalStates(STATES_FILE);
} catch {
return `\nCanonical states: see ${STATES_FILE}`;
}
if (!states.length) return `\nCanonical states: see ${STATES_FILE}`;
const width = Math.max(...states.map(st => st.label.length));
const lines = states.map((st) => {
const terminal = st.terminal ? ' (terminal)' : '';
const desc = st.description ? ` ${st.description}` : '';
return ` ${st.label.padEnd(width)}${desc}${terminal}`;
});
return [
'',
'Canonical states (aliases also accepted — see templates/states.yml):',
...lines,
'',
' A terminal state ends the application. Discarded is YOUR decision or a',
' closed req; Rejected is theirs; SKIP means never applied for.',
].join('\n');
}
// ── argument parsing ─────────────────────────────────────────────
const rawArgs = process.argv.slice(2);
const positional = [];
const flags = { note: null, replaceNote: null, role: null, on: null, row: null, report: null, reportLink: null, source: null, force: false, dryRun: false, undo: false, repairStatusLog: false, repairFollowup: false, detachJdArchive: false, json: false, expectTracker: null, expectRow: null, expectStatus: null };
const VALUE_FLAGS = { '--note': 'note', '--replace-note': 'replaceNote', '--role': 'role', '--on': 'on', '--row': 'row', '--report': 'report', '--report-link': 'reportLink', '--source': 'source', '--expect-tracker': 'expectTracker', '--expect-row': 'expectRow', '--expect-status': 'expectStatus' };
/**
* Is the caller asking for help, rather than passing "--help" as a VALUE?
*
* Runs before the main loop so help answers a line that still carries the bad
* arguments from a failed run. Value positions are skipped but never validated
* — validation stays in the loop, which owns the error messages and exit codes.
*
* @param {string[]} args - argv slice.
* @returns {boolean}
*/
function wantsHelp(args) {
for (let i = 0; i < args.length; i++) {
const a = args[i];
if (Object.hasOwn(VALUE_FLAGS, a)) { i++; continue; } // skip this flag's value
if (a === '--help' || a === '-h') return true;
}
return false;
}
// Exits 0 so `cmd --help` works in CI smoke checks and `|| true` idioms.
if (wantsHelp(rawArgs)) {
console.log(`${USAGE}\n${renderStatesSection()}`);
process.exit(EXIT_OK);
}
// Who is driving this write. A caller that delegates here instead of touching
// the tracker itself — the web status route — needs its ledger rows to stay
// distinguishable from a CLI run's.
//
// The allow-list is narrow on purpose. The value is written to a file
// funnel-velocity.mjs parses positionally and gates on its own source
// allow-list, so an unrecognized label would be persisted here and then
// silently dropped there. Rejecting it at the boundary keeps the two ends from
// disagreeing about what a valid source is.
const WRITER_SOURCES = new Set(['set-status', 'web', 'reply-watch']);
// Before the parse loop: failUsage runs on the first bad token, and a const
// is not hoisted the way the previous function declaration was.
const failUsage = makeCliFailUsage({
argv: rawArgs,
usage: USAGE,
defaultMessage: 'Expected 2 arguments: <tracker#|company> <state>',
});
for (let i = 0; i < rawArgs.length; i++) {
const a = rawArgs[i];
if (a.startsWith('--note=')) {
flags.note = a.slice('--note='.length);
} else if (a in VALUE_FLAGS) {
// Never consume a following flag as the value: "--note --dry-run" would
// silently disable dry-run and turn a preview into a real write.
const value = rawArgs[i + 1];
if (value === undefined || value.startsWith('--')) {
failUsage(`Missing value for ${a}`);
}
// --row/--report name a row by number; a non-numeric value is a typo, and
// silently treating it as "no match" would hide the mistake.
if ((a === '--row' || a === '--report' || a === '--report-link') && !/^\d+$/.test(value)) {
failUsage(`${a} expects a positive integer, got "${value}"`);
}
if (a === '--source' && !WRITER_SOURCES.has(value)) {
failUsage(`--source expects one of ${[...WRITER_SOURCES].join(', ')}, got "${value}"`);
}
if ((a === '--expect-tracker' || a === '--expect-row') && (value.length !== 64 || !/^[a-f0-9]{64}$/.test(value))) {
failUsage(`${a} expects a lowercase SHA-256 hash (64 hex characters)`);
}
flags[VALUE_FLAGS[a]] = value;
i++;
}
else if (a === '--force') { flags.force = true; }
else if (a === '--dry-run') { flags.dryRun = true; }
else if (a === '--undo') { flags.undo = true; }
else if (a === '--repair-status-log') { flags.repairStatusLog = true; }
else if (a === '--repair-followup') { flags.repairFollowup = true; }
else if (a === '--detach-jd-archive') { flags.detachJdArchive = true; }
else if (a === '--json') { flags.json = true; }
else if (a === '-h' || a.startsWith('--')) { failUsage(`Unknown flag: ${a}`); }
else { positional.push(a); }
}
if (flags.replaceNote !== null && (!cell(flags.replaceNote) || flags.note === null || !cell(flags.note))) {
failUsage('--replace-note requires non-empty OLD and --note NEW values');
}
// --row and --report ARE the selector, so they replace the positional one.
// Accepting both would leave two competing answers to "which row?"; refuse
// rather than pick, since picking wrong writes to the wrong application.
if ([flags.row, flags.report, flags.reportLink].filter(value => value !== null).length > 1) {
failUsage('--row, --report and --report-link are mutually exclusive selectors');
}
const explicitSelector = flags.row !== null || flags.report !== null || flags.reportLink !== null;
if (flags.undo) {
const conflicting = [
[flags.note !== null, '--note'], [flags.replaceNote !== null, '--replace-note'], [flags.on !== null, '--on'],
[flags.repairStatusLog, '--repair-status-log'], [flags.repairFollowup, '--repair-followup'],
].filter(([set]) => set).map(([, name]) => name);
if (conflicting.length) failUsage(`--undo cannot be combined with ${conflicting.join(', ')}`);
if (positional.length !== (explicitSelector ? 0 : 1)) {
failUsage('--undo takes the row selector only, not a state');
}
} else if (explicitSelector) {
if (positional.length !== 1) {
failUsage(positional.length === 0
? `Expected the state after ${flags.row !== null ? '--row' : flags.report !== null ? '--report' : '--report-link'}`
: `With ${flags.row !== null ? '--row' : flags.report !== null ? '--report' : '--report-link'} the only positional argument is the state, got ${positional.length}`);
}
} else if (positional.length !== 2) {
failUsage(positional.length === 0 ? null : `Expected 2 arguments (selector, state), got ${positional.length}`);
}
// --on must be a real, non-future calendar date — validated before anything
// touches the tracker, same as state validation below.
if (flags.on !== null) {
const m = /^\d{4}-\d{2}-\d{2}$/.test(flags.on);
const d = m ? new Date(`${flags.on}T00:00:00Z`) : null;
const roundTrips = d && !Number.isNaN(d.getTime()) && d.toISOString().slice(0, 10) === flags.on;
if (!roundTrips) failUsage(`--on expects a real date as YYYY-MM-DD, got "${flags.on}"`);
// LOCAL today, not the UTC day. At a positive UTC offset the UTC day is
// still yesterday for the first hours of the local day, so comparing against
// it rejected the user's own today: `TZ=Pacific/Auckland --on 2026-08-16`
// failed with "date is in the future" on 2026-08-16 (#2932). The round-trip
// check above deliberately stays on UTC — that is date PARSING, not "what
// day is it here".
if (flags.on > localToday()) failUsage(`--on date is in the future: "${flags.on}"`);
}
const selector = explicitSelector ? null : positional[0];
const stateInput = explicitSelector ? positional[0] : positional[1];
// A bare positional number is the ambiguous case the mismatch guard exists for.
// --row/--report are numeric too but carry an explicit number space, so they
// must not be treated as ambiguous.
const isBareNumericSelector = selector !== null && /^\d+$/.test(selector);
// Shared with every other canonical tracker-writer CLI (tracker-utils.mjs) so
// the JSON-vs-human error contract can't drift between them.
const failWith = makeCliFailWith(flags.json);
// ── state validation (before anything touches the tracker) ──────
let states;
try {
states = loadCanonicalStates(STATES_FILE);
} catch (err) {
failWith(EXIT_USAGE, 'states-error', `Cannot load canonical states from ${STATES_FILE}: ${err.message}`);
}
const requestedStatus = flags.undo ? null : resolveCanonicalState(stateInput, states);
if (!flags.undo && !requestedStatus) {
const valid = states.map(s => s.label).join(' · ');
failWith(EXIT_USAGE, 'invalid-state', `"${stateInput}" is not a canonical state. Valid states: ${valid}`);
}
// "14,78" is one argv token. It is not a company, and this command writes one
// row. Checked after the state is known, so a bad state still wins, and before
// the tracker is opened, so a missing file does not hide the usage error.
// "Acme, Inc." stays a company name: a non-numeric piece is not an id list.
function trackerIdList(value) {
if (typeof value !== 'string' || !value.includes(',')) return null;
const parts = value.split(',').map((part) => part.trim());
if (parts.length < 2) return null;
if (!parts.every((part) => part === '' || /^\d+$/.test(part))) return null;
if (!parts.some((part) => /^\d+$/.test(part))) return null;
return parts;
}
if (selector !== null) {
const idList = trackerIdList(selector);
if (idList) {
const nums = idList.filter((part) => /^\d+$/.test(part));
const shown = nums.slice(0, 8);
const action = flags.undo ? '--undo' : requestedStatus;
// These numbers are tracker rows. A bare `14 Applied` is rejected when that
// row's report id differs, so the command that means "tracker row" is --row.
const lines = shown.map((id) => `node set-status.mjs --row ${id} ${action}`);
const extra = nums.length - shown.length;
const more = extra > 0 ? `\n... and ${extra} more` : '';
const empty = idList.some((part) => part === '') ? ' The list has an empty slot.' : '';
failUsage(`set-status updates one row.${empty} Run one command per tracker number:\n${lines.join('\n')}${more}`);
}
}
// ── tracker access ───────────────────────────────────────────────
const APPS_FILE = resolveTrackerPath(DATA_ROOT);
if (!existsSync(APPS_FILE)) {
// The read path falls back to <root>/applications.md when nothing exists yet.
// A first evaluation writes data/applications.md (or CAREER_OPS_TRACKER).
// Naming the fallback points the first status change at a file that will never appear.
const writePath = resolveTrackerPathForWrite(DATA_ROOT);
failWith(EXIT_NOT_FOUND, 'no-tracker', `No tracker found at ${writePath}. The tracker is created when you evaluate your first offer.`);
}
/**
* Reduce a selector's candidate list to exactly one row, or exit.
*
* Every selector path shares one shape: match, optionally narrow by --role,
* refuse to guess between survivors, return the unique row. Only the predicate
* and the two messages differ.
*
* Centralising it matters more than the duplication it removes. **Failing
* closed on 2+ candidates is the #1704 fix** — a stale tracker # reused across
* two rows makes "the first match" a silent coin flip on which company gets
* edited. While that behaviour lived in three copies, a future change that
* reintroduced first-match-wins in one branch would have been invisible in the
* other two. There is now one place to get it wrong, and one place to test.
*
* Note --role only ever *narrows* here; it never validates a lone match. That
* is deliberate and load-bearing: the #2009 check downstream compares the
* resolved row against --role precisely because a selector matching exactly
* one row never reaches the narrowing branch. Do not "fix" that by validating
* here — the two checks answer different questions.
*
* @param {object[]} matches - Rows matching the selector, before --role narrowing.
* @param {object} messages - Selector-specific failure text.
* @param {string} messages.notFound - Message when nothing matched.
* @param {(count: number, listing: string) => string} messages.ambiguous - Message when 2+ survive.
* @returns {object} The single matched row. Exits the process on 0 or 2+ matches.
*/
function resolveCandidates(matches, { notFound, ambiguous }) {
if (matches.length === 0) {
failWith(EXIT_NOT_FOUND, 'not-found', notFound);
}
if (matches.length > 1 && flags.role) {
const narrowed = matches.filter(r => roleFuzzyMatch(r.role, flags.role));
if (narrowed.length === 1) return narrowed[0];
// Fall through with the original list so the candidates stay visible.
}
if (matches.length > 1) {
const candidates = matches.map(r => ({ num: r.num, company: r.company, role: r.role }));
const listing = candidates.map(c => `#${c.num}\t${c.company}\t${c.role}`).join('\n');
failWith(EXIT_AMBIGUOUS, 'ambiguous', ambiguous(matches.length, listing), { candidates });
}
return matches[0];
}
/**
* Find the tracker row matching the CLI selector.
*
* @param {object[]} rows - Parsed data rows (parseTrackerRow output + lineIdx).
* @returns {object} The single matched row. Exits the process on 0 or 2+ matches.
*/
function resolveRow(rows) {
// --report-link is the dashboard's strict identity selector: only the
// dedicated Report cell may identify the row, and every candidate must have
// exactly one well-formed link. References in Notes or malformed cells are
// rejected instead of being skipped in favour of another row.
if (flags.reportLink !== null) {
const matches = [];
for (const row of rows) {
if (!parseMarkdownLinks(String(row.report ?? '')).some(link => link.label === flags.reportLink)) continue;
if (!soleMarkdownLink(row.report)) {
failWith(EXIT_AMBIGUOUS, 'malformed-report', `Malformed Report cell for report #${flags.reportLink}: expected exactly one report link`);
}
matches.push(row);
}
return resolveCandidates(matches, {
notFound: `No tracker row has Report link #${flags.reportLink}`,
ambiguous: () => `Report #${flags.reportLink} occurs in multiple tracker rows`,
});
}
// --report N: resolve through the Report cell, which is the number space a
// caller reading a report filename actually has in hand.
if (flags.report !== null) {
const num = parseInt(flags.report, 10);
return resolveCandidates(
rows.filter(r => extractTrackerReportNumbers(r.report, r.notes).includes(num)),
{
notFound: `No tracker row links report #${num}. (Report IDs and tracker row IDs differ — ` +
'use --row N to select by tracker #.)',
ambiguous: (count, listing) =>
`Report #${num} is linked by ${count} tracker rows — pass --role to disambiguate:\n${listing}`,
},
);
}
// --row N and a bare numeric selector both match the # column; they differ
// only in whether the mismatch guard below treats the number as ambiguous.
if (flags.row !== null || isBareNumericSelector) {
const num = parseInt(flags.row !== null ? flags.row : selector, 10);
return resolveCandidates(
rows.filter(r => r.num === num),
{
notFound: `No tracker row with #${num}`,
// #1704: a stale tracker # reused across 2+ rows means "the first
// match" is a silent coin flip on which company gets edited. Refuse to
// guess; require --role or the company selector instead.
ambiguous: (count, listing) =>
`#${num} is a duplicate tracker number shared by ${count} rows (see #1704) — ` +
`pass --role to disambiguate, or use the company name instead:\n${listing}`,
},
);
}
const key = normalizeCompany(selector);
if (!key) failUsage(`Selector "${selector}" is empty after normalization`);
// Exact key first. A legal suffix (Inc, LLC) is a different key, so fall
// back to the same corporate-form rule outcome and dedup already use.
// Only when nothing matched exactly. Two hits stay ambiguous.
let matches = rows.filter(r => normalizeCompany(r.company) === key);
if (matches.length === 0) {
matches = rows.filter(r => companiesMatchIgnoringCorporateForm(selector, r.company));
}
return resolveCandidates(
matches,
{
notFound: `No tracker row with company matching "${selector}"`,
ambiguous: (count, listing) =>
`Company "${selector}" matches ${count} rows — pass the # or narrow with --role:\n${listing}`,
},
);
}
// ── locked read-modify-write ─────────────────────────────────────
// Shared with mark-pdf-ready.mjs (tracker-utils.mjs): dry-run never writes,
// so it must not hold the exclusive lock — a read-only preview should not
// block (or be blocked by) merge-tracker or another writer.
const lock = await acquireTrackerLockForCli(APPS_FILE, { dryRun: flags.dryRun, failWith });
let content;
try {
content = readFileSync(APPS_FILE, 'utf-8');
} catch (err) {
failWith(EXIT_NOT_FOUND, 'read-failure', `Cannot read tracker at ${APPS_FILE}: ${err.message}`);
}
// Guard the whole file, not just the selected row: reply-watch also checks
// receipt notes on other rows before confirming a proposal. A write anywhere
// between that check and acquiring this lock invalidates the reviewed snapshot.
if (flags.expectTracker !== null
&& createHash('sha256').update(content, 'utf8').digest('hex') !== flags.expectTracker) {
failWith(EXIT_AMBIGUOUS, 'tracker-changed', 'Tracker changed since review; review the current tracker before retrying');
}
const lines = content.split('\n');
const parseOptions = { allowTabs: true, allowIndentation: true };
const colmap = resolveColumns(lines, parseOptions);
const rows = [];
for (let i = 0; i < lines.length; i++) {
const row = parseTrackerRow(lines[i], colmap, parseOptions);
if (row) rows.push({ ...row, lineIdx: i });
else if (flags.reportLink !== null && colmap.report != null) {
const report = splitTrackerCells(lines[i])[colmap.report] ?? '';
if (parseMarkdownLinks(String(report)).some(link => link.label === flags.reportLink)) {
failWith(EXIT_AMBIGUOUS, 'malformed-row', `Tracker row linking report #${flags.reportLink} has missing cells`);
}
}
}
if (rows.length === 0) {
failWith(EXIT_NOT_FOUND, 'empty-tracker', `Tracker at ${APPS_FILE} has no data rows`);
}
const target = resolveRow(rows);
// A BARE numeric selector is often copied from a report filename. If the row ID
// disagrees with its local report link, silently updating that row can affect
// the wrong application. Company selectors remain usable, and --force records an
// explicit decision to proceed despite the mismatch.
//
// --row/--report are exempt by construction, not by override: the caller has
// already said which number space they mean, so there is no ambiguity left to
// guard. That distinction is what keeps the check meaningful — on a tracker
// whose counters have diverged, a guard that fires on every numeric call just
// teaches callers to pass --force, which disables it everywhere including the
// cases it was written for.
if (isBareNumericSelector && !flags.force) {
const reportNums = extractTrackerReportNumbers(target.report, target.notes);
const mismatched = reportNums.filter(num => num !== target.num);
if (mismatched.length > 0) {
failWith(
EXIT_AMBIGUOUS,
'report-number-mismatch',
`Tracker #${target.num} points to report ID(s) ${reportNums.map(num => `#${num}`).join(', ')}. ` +
`Say which you meant: --row ${target.num} (tracker row) or ` +
`--report ${reportNums[0]} (report ID). ` +
'The company selector also works; --force overrides the check instead of answering it.',
{ trackerNum: target.num, reportNums },
);
}
// The check above compares the matched row's report link against its own #.
// A backfilled row (#1799) has no link, so reportNums is empty, `mismatched`
// is empty, and the check passes with nothing compared — while a DIFFERENT
// row may link exactly this number as its report.
//
// That combination is not hypothetical: it is what merge-tracker.mjs's
// "Tracker #N already used; assigning #M" fallback produces. The backfilled
// row occupying #N is what pushes the evaluated row to #M, so the row a stale
// numeric selector lands on is precisely the report-less one this check could
// not see. Bare "#N" then names two applications at once and must not write.
if (reportNums.length === 0) {
const num = parseInt(selector, 10);
const linkers = rows.filter(r => r !== target && extractTrackerReportNumbers(r.report, r.notes).includes(num));
if (linkers.length > 0) {
const listing = linkers.map(r => `#${r.num}\t${r.company}\t${r.role}`).join('\n');
failWith(
EXIT_AMBIGUOUS,
'report-number-ambiguous',
`"${num}" is ambiguous: tracker row #${num} (${target.company} — ${target.role}) has no report, ` +
`but report #${num} is linked by:\n${listing}\n` +
`Say which you meant: --row ${num} (the row) or --report ${num} (the report).`,
{ trackerNum: target.num, reportNum: num, linkedBy: linkers.map(r => ({ num: r.num, company: r.company, role: r.role })) },
);
}
}
}
// --role is an explicit statement of which opening the caller means, but
// resolveRow only consults it to break ties between 2+ candidates. A selector
// matching exactly one row therefore returned that row without ever checking
// it against --role, silently rewriting a status the caller never asked for.
// That is the wrong-row mutation in #2009: the intended requisition may not be
// in the tracker at all (fuzzy-deduped away, or never merged), so the lone
// survivor for that company absorbs the update instead. Fail closed and let
// --force record an explicit decision, matching the report-mismatch guard.
// Exact-title equality must be checked separately: roleFuzzyMatch is a DEDUP
// predicate, and it deliberately returns false for two titles whose overlap is
// entirely baseline vocabulary (["platform","engineer"]) so that same-titled
// sibling reqs never auto-merge. That makes it unusable on its own here — it
// would reject --role "Platform Engineer" against a row that IS exactly that.
// The collapse must drop PUNCTUATION, never letters. `[^a-z0-9]` dropped every
// letter outside the Latin range, so any title written entirely in Japanese,
// Arabic or Cyrillic keyed to '' — two different titles then compared equal
// ('' === '') and the guard wrote the status to a row it had never actually
// matched (#2670). normalizeTextKey is the Unicode-aware normalizer company
// matching already used; it also folds NFKC, so a decomposed title still
// matches its composed row.
const normalizeRoleText = s => normalizeTextKey(
String(s ?? '')
// NFKC first: normalizeTextKey folds it too, but only AFTER this pre-map, so
// a fullwidth #/++ would reach the collapse unrecognized and be stripped as
// punctuation — "C# Engineer" and "C++ Engineer" both keying to
// "c engineer". Fullwidth forms are ordinary Japanese typography, so this is
// the same shipped-market surface as the rest of #2670. Folding here also
// makes the ASCII and fullwidth spellings of one title match each other.
.normalize('NFKC')
// Preserve symbols that distinguish real titles before collapsing generic
// punctuation — otherwise "C# Engineer" and "C++ Engineer" both fold to
// "c engineer" and the exact-equality path treats them as the same row.
.replace(/\+\+/g, ' plusplus ')
.replace(/#/g, ' sharp '),
' ',
);
const roleMatchesTarget = normalizeRoleText(target.role) === normalizeRoleText(flags.role)
|| roleFuzzyMatch(target.role, flags.role);
if (flags.role && !flags.force && !roleMatchesTarget) {
failWith(
EXIT_AMBIGUOUS,
'role-mismatch',
`Tracker #${target.num} (${target.company}) is "${target.role}", which does not match --role "${flags.role}". ` +
'The row you meant may not be in the tracker. Re-run with --force to update this row anyway.',
{ trackerNum: target.num, rowRole: target.role, requestedRole: flags.role },
);
}
const oldStatus = target.status;
// A status outside states.yml (a hand-typed legacy value) is compared as
// written rather than refused, so the caller still gets "status changed" and
// not a usage error for a row it was right to show.
const statusKey = value => resolveCanonicalState(value, states) ?? String(value ?? '').trim();
// The ledger takes a canonical state or "-", and a row with anything else is
// dropped by its readers. A legacy cell (`Applied 2026-01-02`) is a known state
// once its date is off; copied raw, the change was lost to the funnel and to
// --undo. dedup-tracker.mjs logs its promotions the same way.
const ledgerState = value => resolveCanonicalState(stripStatusDecoration(value), states) ?? '-';
// The change --undo takes back is the one a retraction line would pop: the
// last surviving entry of this row's ledger replay. Read under the lock, so no
// writer can append between this read and the retraction below.
let undone = null;
if (flags.undo) {
let ledger = '';
try {
ledger = readFileSync(statusLogPath(APPS_FILE), 'utf-8');
} catch (err) {
if (err.code !== 'ENOENT') failWith(EXIT_USAGE, 'read-failure', `Cannot read status log: ${err.message}`);
}
undone = replayObservations(parseStatusLog(ledger, states).observations).get(target.num)?.at(-1) ?? null;
if (!undone) {
failWith(EXIT_NOT_FOUND, 'nothing-to-undo', `#${target.num} has no logged status change to undo`);
}
if (statusKey(oldStatus) !== undone.to) {
failWith(EXIT_AMBIGUOUS, 'undo-stale',
`#${target.num} is ${oldStatus}, but its last logged change was to ${undone.to}. Set the status you want instead.`,
{ currentStatus: oldStatus, lastLogged: undone.to });
}
if (!undone.from) {
failWith(EXIT_USAGE, 'undo-unknown-prior',
`#${target.num}'s last logged change (to ${undone.to}) has no recorded prior status. Set the status you want instead.`);
}
}
const newStatus = undone ? undone.from : requestedStatus;
if (flags.reportLink !== null && !resolveCanonicalState(oldStatus, states)) {
failWith(EXIT_USAGE, 'invalid-current-state', `Tracker #${target.num} has an unrecognized current status: "${oldStatus}"`);
}
// A caller that asked a human first (reply-watch) holds a snapshot of the row
// it showed. The row can be replaced or edited while the prompt is open, and
// the yes belongs to the application on screen, not to a reusable row number.
// Unlike --expect-tracker, these look at the selected row only, so a write to
// an unrelated row in the meantime does not void the answer.
// Identity first: an edited row is refused even when it already carries the
// new status, because the consent was never about that row.
if (flags.expectRow !== null && trackerRowIdentity(target) !== flags.expectRow) {
failWith(EXIT_AMBIGUOUS, 'row-changed', `Tracker #${target.num} changed since review; review it again before retrying`);
}
if (flags.expectStatus !== null && statusKey(oldStatus) !== newStatus
&& statusKey(oldStatus) !== statusKey(flags.expectStatus)) {
failWith(EXIT_AMBIGUOUS, 'status-changed',
`Tracker #${target.num} is now ${oldStatus}, not ${flags.expectStatus}; review it again before retrying`,
{ currentStatus: oldStatus });
}
const rawLine = lines[target.lineIdx];
const separator = trackerRowSeparator(rawLine);
// A tab is a cell delimiter in a legacy TSV row. Neutralize tabs only there;
// in a pipe table they remain ordinary note text.
const note = flags.note != null
? cell(separator === '\t' ? flags.note.replace(/\t/g, ' ') : flags.note)
: null;
// ── advisory session-activity claim (#4532) ──────────────────────
//
// Concurrent sessions have no mutual awareness today: two sessions can each
// pick up the same tracker row and do redundant or conflicting work, with the
// collision only surfacing after the fact (#4506/#4507 — two sessions each
// wrote a batch/tracker-additions/*.tsv for the same report under a
// differently spelled company name). session-activity.mjs is a purely
// advisory signal modeled on reserve-report-num.mjs's sentinel pattern: it
// never blocks or changes the exit code, it only lets this run warn when
// another session already claimed the same row.
//
// The claim and its keying live in lib/status-hooks.mjs, so a writer that
// commits a status inside its own transaction takes the same one. Skipped
// entirely on --dry-run, matching the JD-archive-style triggers elsewhere in
// this file: a preview claims nothing.
const releaseActivityClaim = flags.dryRun ? () => {} : claimStatusActivity(target, `set-status → ${newStatus}`);
let statusChanged = false;
let noteChanged = false;
let statusLogged = false;
let statusLogRepaired = false;
let statusLogRepair = null;
const replacedNote = flags.replaceNote !== null ? cell(flags.replaceNote) : null;
try {
// Rebuild only the matched line: change the Status cell, append the note, keep
// every other cell exactly as parsed.
const parts = splitTrackerCells(rawLine);
while (parts.length <= Math.max(colmap.status, colmap.notes ?? 0)) parts.push('');
const replaceCellValue = (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} `;
}
};
statusChanged = parts[colmap.status].trim() !== newStatus;
if (statusChanged) replaceCellValue(colmap.status, newStatus);
// Text after a date in the old cell is no part of any state, and replacing the
// cell would lose it. It moves to Notes, as normalize-statuses.mjs moves it,
// unless a whole Notes entry already holds it.
if (statusChanged && colmap.notes != null && statusTextAfterDate(oldStatus)) {
const kept = (parts[colmap.notes] ?? '').trim();
const moved = oldStatus.trim();
if (!hasNoteEntry(kept, moved)) replaceCellValue(colmap.notes, prependNote(kept, moved));
}
if (note) {
if (colmap.notes == null) {
failWith(EXIT_USAGE, 'no-notes-column', 'Tracker has no Notes column — cannot apply --note');
}
const existing = (parts[colmap.notes] ?? '').trim();
if (replacedNote !== null) {
// Use the same whole-note boundaries as append idempotency. Prefer the
// longer complete span so NEW containing OLD (including "; ") is not
// expanded again on retry, and OLD containing NEW is still replaced.
const alternatives = [note, replacedNote].sort((a, b) => b.length - a.length)
.map(text => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
const wholeNotes = new RegExp(`(^|; )(${alternatives.join('|')})(?=; |$)`, 'g');
let found = false;
const updated = existing.replace(wholeNotes, (_match, prefix, entry) => {
found = true;
return prefix + (entry === replacedNote ? note : entry);
});
if (!found) {
failWith(EXIT_USAGE, 'replace-note-not-found', 'Neither --replace-note nor --note matches a whole note in the Notes cell');
}
replaceCellValue(colmap.notes, updated);
noteChanged = updated !== existing;
} else {
// Delimiter-aware idempotency: the note counts as already present only when
// it appears as a whole "; "-delimited entry (or as the entire field) — a
// bare substring of a longer entry ("sent" inside "sent CV") must not
// suppress a genuinely new note. Matching the full note text at entry
// boundaries (instead of splitting the field into segments) keeps retries
// idempotent even when the note itself contains "; ".
if (!hasNoteEntry(existing, note)) {
replaceCellValue(colmap.notes, existing && existing !== '—' && existing !== '-' ? `${existing}; ${note}` : note);
noteChanged = true;
}
}
}
if ((statusChanged || noteChanged) && !flags.dryRun) {
if (separator === '\t') {
const closing = rawLine.trimEnd().endsWith('|');
lines[target.lineIdx] = parts[0] + '|' + parts.slice(1, closing ? -1 : undefined).join('\t')
+ (closing ? '|' + parts.at(-1) : '');
} else {
lines[target.lineIdx] = parts.join('|');
}
try {
writeFileAtomic(APPS_FILE, lines.join('\n'));
} catch (err) {
// Same structured error contract as every other failure path — a raw
// stack trace on stdout/stderr would break --json consumers.
failWith(EXIT_USAGE, 'write-failure', `Cannot write tracker at ${APPS_FILE}: ${err.message}`);
}
}
// ── status-log append (transition ledger, read by funnel-velocity.mjs) ──
// Observation trail only: the tracker stays the source of truth for STATE,
// the ledger records WHEN transitions happened. A failed append is a warning,
// never a failure — the status write above already succeeded. Sibling of the
// tracker file so CAREER_OPS_TRACKER redirects (tests, custom layouts) keep
// the ledger next to the tracker it describes. Inside the lock window, so
// concurrent writers can't interleave lines.
// An undo retracts the entry it selected even when that entry left the
// status as it was (Applied → Applied), or the next undo would pick it again.
// The retraction is the undo, so a failed append puts the old status back.
if (undone && !flags.dryRun) {
statusLogged = appendStatusTransition(APPS_FILE, {
num: target.num,
date: localToday(),
from: '-',
to: '-',
source: flags.source ?? 'set-status',
note: `undo ${undone.from} → ${undone.to}`,
});
if (!statusLogged) {
let restored = !statusChanged;
if (statusChanged) {
lines[target.lineIdx] = rawLine;
try {
writeFileAtomic(APPS_FILE, lines.join('\n'));
restored = true;
} catch { /* reported below */ }
}
failWith(EXIT_USAGE, 'undo-not-logged', restored
? `Could not log the undo for #${target.num}, so it stays ${oldStatus}`
: `Could not log the undo for #${target.num}, and could not put it back to ${oldStatus}; it now reads ${newStatus}`);
}
} else if (statusChanged && !flags.dryRun) {
// LOCAL today: the UTC day is TOMORROW for a west-of-Greenwich evening run,
// so this appended a status-log row dated a day that had not happened yet
// (#2932, mirroring #2765). status-log.tsv is what funnel-velocity reads for
// time-between-stages, so a future-dated transition skews the interval it
// measures rather than just looking odd in the file.
statusLogged = appendStatusTransition(APPS_FILE, {
num: target.num,
date: flags.on ?? localToday(),
from: ledgerState(oldStatus),
to: newStatus,
source: flags.source ?? 'set-status',
});
}
// A dashboard call that received the partial-success error above can retry
// the same Applied action. The tracker is already Applied, so there is no
// normal transition to log; append one correction observation only when the
// effective timeline does not already end at Applied. This is append-only
// and idempotent, and the correction source is folded by funnel-velocity.mjs
// onto the repaired stage.
if (flags.repairStatusLog && !statusChanged && newStatus === 'Applied' && !flags.dryRun) {
const logPath = statusLogPath(APPS_FILE);
const timeline = [];
let inspectionError = null;
try {
const log = readFileSync(logPath, 'utf-8');
for (const line of log.split(/\r?\n/)) {
const cells = line.trimEnd().split('\t');
if (cells[0] !== String(target.num) || cells.length < 5) continue;
const to = cells[3];
const source = cells[4];
if (to === '-') {
timeline.pop();
} else if (source === 'correction') {
const index = timeline.map(entry => entry.to).lastIndexOf(to);
if (index >= 0) timeline[index] = { to, date: cells[1] };
else timeline.push({ to, date: cells[1] });
} else {
timeline.push({ to, date: cells[1] });
}
}
} catch (err) {
if (err.code !== 'ENOENT') inspectionError = err;
}
if (inspectionError) {
const error = `status-log inspection failed: ${inspectionError.message}`;
statusLogRepair = { attempted: true, repaired: false, error };
console.error(`⚠ ${error}`);
} else if (timeline.at(-1)?.to === 'Applied') {
statusLogRepair = { attempted: true, repaired: false, reason: 'already-present' };
} else if (timeline.length === 0) {
// No earlier ledger entry for this row: nothing proves a transition was
// lost, so do not invent history from the tracker date alone.
statusLogRepair = { attempted: true, repaired: false, reason: 'no-history' };
} else {
// Prefer an explicit event date, then the latest effective ledger date,
// then the tracker's own dated row. Never invent today's date for an
// existing Applied row whose history is older or absent.
const eventDate = flags.on ?? timeline.at(-1).date;
if (!eventDate) {
const error = 'status-log repair needs an event date (--on or a dated tracker row)';
statusLogRepair = { attempted: true, repaired: false, error };
console.error(`⚠ ${error}`);
} else {
try {
appendLedgerRows(logPath, `${target.num}\t${eventDate}\t-\tApplied\tcorrection\t\n`);
statusLogged = true;
statusLogRepaired = true;
statusLogRepair = { attempted: true, repaired: true };
} catch (err) {
const error = `status-log repair failed: ${err.message}`;
statusLogRepair = { attempted: true, repaired: false, error };
console.error(`⚠ ${error}`);
}
}
}
}
} finally {
lock?.release();
// Release the claim once the write (and its log append) has finished, so
// it never outlives the run — reached even if something above threw.
releaseActivityClaim();
}
const changed = statusChanged || noteChanged;
// ── follow-up seeding (#1430) ────────────────────────────────────
//
// The transition into Applied is where the first follow-up gets scheduled.
// set-status.mjs used to only ANNOUNCE that — `followupSeedCandidate: true` —
// and nothing consumed the flag: the only callers of followup-seed.mjs were
// modes/apply.md and modes/followup.md, both agent instructions. So recording
// an application from the web UI, or from this CLI directly, wrote the tracker
// and the ledger correctly and scheduled nothing, silently (#3459).
//
// Seeding HERE rather than in each caller is what makes that one fix instead of
// three: #2901 converged /api/status onto this script, so the web path inherits
// it, and so does every future caller that delegates here rather than editing
// the table.
//
// AFTER the tracker lock is released, deliberately: the seeder re-reads the
// row this run just wrote (lib/status-hooks.mjs has the rest of the why).
//
// A seeding failure NEVER fails the status change. The write has already
// committed and the caller's exit code is about that write — same policy as
// the status-log append above.
let followupSeeded = null;
if (!undone && (statusChanged || flags.repairFollowup) && newStatus === 'Applied') {
followupSeeded = await seedFollowupOnApplied(target.num, {
trackerPath: APPS_FILE,
// --on is the day the user says the application was sent. Without it,
// a fresh transition passes today as the status-change date, which
// resolveAppliedDate ranks below an "Applied YYYY-MM-DD" in the notes and
// above the evaluation-date column. A repair retry passes neither,
// because the apply happened on an earlier run.
date: flags.on,
statusChangeDate: statusChanged ? localToday() : undefined,
dryRun: flags.dryRun,
quiet: flags.json,
});
}
// ── JD archive trigger on Interview (#4506-direction-2) ─────────────
//
// The last reliable moment the posting is still likely live
// (lib/status-hooks.mjs has the full reasoning). Same policy as the follow-up
// seeding above: never fails the status change, never re-archives on an
// idempotent re-run into an already-Interview row (statusChanged gates that),
// and does nothing on a dry run.
let jdArchiveTriggered = null;
if (!undone && statusChanged && newStatus === 'Interview' && !flags.dryRun) {
jdArchiveTriggered = archiveJdOnInterview(target, {
trackerPath: APPS_FILE,
detach: flags.detachJdArchive,
json: flags.json,
});
}
// ── report ───────────────────────────────────────────────────────
const result = {
changed,
num: target.num,
company: target.company,
role: target.role,
oldStatus,
newStatus,
...(note != null ? { note } : {}),
...(replacedNote !== null ? { replacedNote } : {}),
...(flags.dryRun ? { dryRun: true } : {}),
// Fire the #1430 hook on an actual transition INTO Applied or an explicit
// lifecycle repair retry. The seeder itself is idempotent, so a repair retry
// cannot stack duplicate follow-ups.
// followupSeedCandidate is kept for any consumer already reading it; the
// seeding it used to merely advertise now actually happens, and its outcome
// travels beside it.
...(undone ? { undone: { from: undone.from, to: undone.to, date: undone.date } } : {}),
...(!undone && statusChanged && newStatus === 'Applied' ? { followupSeedCandidate: true } : {}),
...(followupSeeded ? { followupSeeded } : {}),
...(jdArchiveTriggered ? { jdArchiveTriggered } : {}),
...(statusChanged && !flags.dryRun ? { statusLogged } : {}),
...(statusLogRepaired ? { statusLogRepaired } : {}),
...(statusLogRepair ? { statusLogRepair } : {}),
tracker: APPS_FILE,
};
const oldLabel = resolveCanonicalState(oldStatus, states);
if (!undone && statusChanged && !flags.dryRun && states.find(s => s.label === oldLabel)?.terminal) {
console.warn(`⚠ #${target.num} was ${oldLabel}, a terminal state. Reopened as ${newStatus}.`);
}
if (flags.json) {
// JSON must be the last thing written to stdout; machine callers parse stdout as one JSON document.
console.log(JSON.stringify(result, null, 2));
} else {
const verb = undone ? (flags.dryRun ? 'would undo' : 'undid') : flags.dryRun ? 'would set' : changed ? 'set' : 'already';
console.log(`✅ #${target.num} ${target.company} — ${target.role}: ${verb} ${oldStatus} → ${newStatus}${note ? ` (note: ${note})` : ''}`);
// Only when seeding did NOT happen. The advisory predates the seeding above
// and asked the user to do by hand what now runs for them; leaving it
// unconditional would read as a contradiction right under "Follow-up seeded".
if (!undone && statusChanged && !flags.dryRun && newStatus === 'Applied' && !followupSeeded?.seeded && followupSeeded?.reason !== 'disabled') {
console.error('ℹ️ Status is Applied — consider seeding follow-ups in data/follow-ups.md (#1430: node followup-cadence.mjs)');
}
}
process.exit(EXIT_OK);