reeval-candidates.mjs
Rama main · réplica de lectura del repositorio original.
#!/usr/bin/env node
/**
* reeval-candidates.mjs — list tracker rows scored against older candidate evidence
*
* A tracker row's score is fixed when the role is evaluated. The primary
* sources that evaluation reads keep changing afterwards (`expand` adds proof
* points, `add` adds a project, the profile gains a target), and nothing says
* which still-open rows were scored before that change. This script lists them,
* so the user can decide which ones to evaluate again (#5669).
*
* Zero LLM, read-only: it never re-evaluates, never takes the tracker lock and
* never writes the tracker, a report or any other file.
*
* PRIMARY SOURCES (the AGENTS.md "Primary / user-authored" tier that carries
* facts): `cv.md`, `article-digest.md`, `config/profile.yml` (through
* getProfilePath(), so CAREER_OPS_PROFILE is honored) and `modes/_profile.md`,
* all under the data root. A missing file is reported and ignored.
*
* WHEN A SOURCE LAST CHANGED
* - git: the file is tracked in the git repository that contains it and has
* no uncommitted changes -> the committer date of the last commit that
* touched it. Committer date, not author date: it is never earlier than the
* moment the content entered the history (a rebase or amend moves it
* forward, which can only list a row too eagerly, never hide one).
* - mtime: everything else (no git, an untracked or gitignored file, which is
* the default layout since the checkout ignores user files, or a tracked
* file with uncommitted edits) -> the file's modification time. This is
* APPROXIMATE: a copy, sync, restore or checkout moves it without any change
* to the content, and the output labels it `mtime` for that reason.
* Both are reduced to a calendar day in the local timezone, the same day the
* evaluation date was written in.
*
* WHEN A ROW WAS LAST EVALUATED: the latest of
* 1. the row's Date column (merge-tracker.mjs rewrites it to the
* re-evaluation date when a re-evaluation updates the row),
* 2. every `Re-eval YYYY-MM-DD` marker in Notes (merge-tracker's mergeNotes()
* appends one per re-evaluation),
* 3. each linked report's header date: the first bold header field whose
* value is an ISO date (`**Date:** 2026-09-14`, in any label language),
* or, when the header has none, the date in the report filename
* (`{###}-{slug}-{YYYY-MM-DD}.md`).
* The latest wins because a re-evaluation writes its score through to the row
* in both directions, so the score on the row is the most recent one. A row
* whose report link points at a missing file still uses (1) and (2); a row
* with no readable date at all is listed under `undated`, never dropped.
*
* RULE: a row is a candidate when at least one source changed on a LATER
* calendar day than its evaluation date. A change on the same day is not
* listed: the evaluation date has no time of day, so the order is unknown.
*
* Run: node reeval-candidates.mjs (JSON to stdout)
* node reeval-candidates.mjs --summary (human-readable table)
* node reeval-candidates.mjs --status Evaluated,Applied
* node reeval-candidates.mjs --tracker path/to/applications.md
*/
import { execFileSync } from 'child_process';
import { existsSync, readFileSync, realpathSync, statSync } from 'fs';
import { basename, dirname, isAbsolute, join, relative, resolve } from 'path';
import { fileURLToPath } from 'url';
import { resolveColumns, parseTrackerRow, parseMarkdownLinks } from './tracker-parse.mjs';
import { loadCanonicalStates, resolveCanonicalState, trackerLinkDirsFor } from './tracker-utils.mjs';
import { flagValue, hasFlag, validateFlags } from './lib/cli-flags.mjs';
import { localToday } from './lib/local-today.mjs';
import { parseDate } from './lib/parse-date.mjs';
import { isMainModule } from './lib/is-main-module.mjs';
import { canonicalizeTrackerPath, getCareerOpsRoot, getProfilePath, resolveTrackerPath } from './path-resolver.mjs';
const CODE_ROOT = dirname(fileURLToPath(import.meta.url));
const STATES_FILE = join(CODE_ROOT, 'templates/states.yml');
export const DEFAULT_STATUS = 'Evaluated';
/** Primary sources, by the name the output uses. Order is the display order. */
export const SOURCE_NAMES = ['cv.md', 'article-digest.md', 'config/profile.yml', 'modes/_profile.md'];
/** Only the report header is read; a report's archived JD can run long. */
const MAX_REPORT_BYTES = 64 * 1024;
const ISO_DAY_RE = /^\d{4}-\d{2}-\d{2}$/;
// `**Date:** 2026-09-14`, `**Date :** …` (fr), `**Date**: …`.
const HEADER_DATE_RE = /^\s*\*\*[^*\n]+?\*\*\s*:?\s*(\d{4}-\d{2}-\d{2})(?!\d)/;
const FILENAME_DATE_RE = /(\d{4}-\d{2}-\d{2})\.md$/i;
// merge-tracker.mjs mergeNotes(): `Re-eval 2026-10-02 (3.8→4.1)`.
const REEVAL_MARKER_RE = /\bre-?eval(?:uated|uation)?\b[\s:]*(\d{4}-\d{2}-\d{2})(?!\d)/gi;
// Variables that tell git where a repository is. With one of them inherited
// (a git hook sets GIT_DIR), the working directory would stop deciding which
// repository is asked about the file.
const GIT_LOCATION_ENV = [
'GIT_DIR', 'GIT_WORK_TREE', 'GIT_IMPLICIT_WORK_TREE', 'GIT_COMMON_DIR',
'GIT_INDEX_FILE', 'GIT_OBJECT_DIRECTORY', 'GIT_ALTERNATE_OBJECT_DIRECTORIES',
'GIT_PREFIX',
];
const isoDay = (value) => (ISO_DAY_RE.test(value) && parseDate(value) ? value : null);
/** Canonical state labels from templates/states.yml. */
export function loadStates(statesFile = STATES_FILE) {
return loadCanonicalStates(statesFile);
}
/** `Evaluated, applied` -> Set{'Evaluated','Applied'}; throws on an unknown state. */
export function resolveStatusFilter(value, states = loadStates()) {
const labels = new Set();
for (const part of String(value).split(',')) {
if (!part.trim()) continue;
const label = resolveCanonicalState(part, states);
if (!label) {
throw new Error(`Unknown status "${part.trim()}". Canonical states: ${states.map(s => s.label).join(', ')}.`);
}
labels.add(label);
}
if (labels.size === 0) throw new Error('--status needs at least one state.');
return labels;
}
function gitEnv() {
const env = { ...process.env };
for (const name of GIT_LOCATION_ENV) delete env[name];
return env;
}
// `--no-optional-locks`: `git status` would otherwise refresh the index, a
// write this read-only script has no business making.
function git(cwd, args) {
return execFileSync('git', ['--no-optional-locks', ...args], {
cwd,
encoding: 'utf-8',
env: gitEnv(),
timeout: 10_000,
stdio: ['ignore', 'pipe', 'ignore'],
}).trim();
}
/**
* When one source file last changed.
*
* @param {string} name - Display name (`cv.md`).
* @param {string} path - Where the file is expected.
* @returns {{source: string, path: string, exists: boolean, changed?: string,
* changedAt?: string, basis?: 'git'|'mtime', note?: string}}
*/
export function sourceChange(name, path) {
const base = { source: name, path };
let real;
let stat;
try {
real = realpathSync(path);
stat = statSync(real);
} catch {
return { ...base, exists: false };
}
if (!stat.isFile()) return { ...base, exists: false };
const fromMtime = (note) => ({
...base,
exists: true,
changed: localToday(stat.mtime),
changedAt: stat.mtime.toISOString(),
basis: 'mtime',
note,
});
const dir = dirname(real);
const file = basename(real);
let tracked = '';
try {
tracked = git(dir, ['ls-files', '--', file]);
} catch {
// Not inside a repository, or git is not installed.
return fromMtime('approximate: no usable git repository, file modification time');
}
// Untracked or gitignored (the default layout ignores user files). Checked
// before `git log`, which still names the commit that stopped tracking a
// file once it is removed from the index and kept on disk.
if (!tracked) return fromMtime('approximate: not tracked by git, file modification time');
let committed = '';
let dirty = '';
try {
committed = git(dir, ['log', '-1', '--format=%ct', '--', file]);
dirty = git(dir, ['status', '--porcelain', '--untracked-files=no', '--', file]);
} catch {
return fromMtime('approximate: git history unavailable, file modification time');
}
if (!/^\d+$/.test(committed)) return fromMtime('approximate: not committed yet, file modification time');
if (dirty) return fromMtime('approximate: uncommitted changes, file modification time');
const at = new Date(Number(committed) * 1000);
return {
...base,
exists: true,
changed: localToday(at),
changedAt: at.toISOString(),
basis: 'git',
note: 'last commit touching the file',
};
}
/** Every primary source under the data root, with its last-change day. */
export function primarySourceChanges(dataRoot, { profilePath = getProfilePath() } = {}) {
const paths = {
'cv.md': join(dataRoot, 'cv.md'),
'article-digest.md': join(dataRoot, 'article-digest.md'),
'config/profile.yml': profilePath,
'modes/_profile.md': join(dataRoot, 'modes', '_profile.md'),
};
return SOURCE_NAMES.map(name => sourceChange(name, paths[name]));
}
/**
* Report links a row names: the Report cell's local links, or, when it has
* none, `reports/` links in Notes (trackers without a Report column).
*/
export function reportLinkTargets(reportCell, notesCell) {
const local = (target) => {
const t = String(target ?? '').trim().replace(/^<|>$/g, '');
if (!t || /^(?:[a-z][a-z\d+.-]*:|\/\/)/i.test(t)) return null;
return t.split(/[?#]/, 1)[0];
};
const cell = String(reportCell ?? '').trim();
const links = parseMarkdownLinks(cell).map(l => local(l.target)).filter(Boolean);
if (links.length > 0) return [...new Set(links)];
if (/\.md$/i.test(cell) && !/\s/.test(cell)) {
const bare = local(cell);
if (bare) return [bare];
}
const fromNotes = parseMarkdownLinks(String(notesCell ?? ''))
.map(l => local(l.target))
.filter(t => t && /(?:^|[\\/])reports[\\/]/i.test(t));
return [...new Set(fromNotes)];
}
/** First existing file for a link, tried from each tracker dir, then the data root. */
function resolveReportPath(target, linkDirs, dataRoot) {
const candidates = isAbsolute(target)
? [target]
: [...new Set([...linkDirs.map(d => join(d, target)), join(dataRoot, target)])];
for (const path of candidates) {
try {
if (statSync(path).isFile()) return path;
} catch {
// Absent here; try the next base.
}
}
return null;
}
/** The evaluation date in a report's header block (before the first `---` or `## `). */
export function reportHeaderDate(markdown) {
const lines = String(markdown ?? '').replace(/\r\n/g, '\n').split('\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (i > 0 && (/^\s*-{3,}\s*$/.test(line) || /^##\s/.test(line))) break;
const m = HEADER_DATE_RE.exec(line);
if (m && isoDay(m[1])) return m[1];
}
return null;
}
function readReportHead(path) {
try {
return readFileSync(path).subarray(0, MAX_REPORT_BYTES).toString('utf-8');
} catch {
return null;
}
}
/** Every `Re-eval YYYY-MM-DD` marker in a Notes cell, as valid ISO days. */
export function reevalMarkerDates(notes) {
const out = [];
REEVAL_MARKER_RE.lastIndex = 0;
let m;
while ((m = REEVAL_MARKER_RE.exec(String(notes ?? ''))) !== null) {
if (isoDay(m[1])) out.push(m[1]);
}
return out;
}
/**
* The date a row was last evaluated, and where it came from.
*
* @returns {{evaluated: string|null, evaluatedFrom: string[], reports: object[]}}
* `evaluatedFrom` names every input that carries the winning date;
* `reports` lists each linked report with whether it was found.
*/
export function rowEvaluationDate(row, { linkDirs, dataRoot }) {
const dated = [];
const column = isoDay(String(row.date ?? '').trim());
if (column) dated.push({ date: column, from: 'date-column' });
for (const date of reevalMarkerDates(row.notes)) dated.push({ date, from: 'notes-reeval' });
const reports = [];
for (const link of reportLinkTargets(row.report, row.notes)) {
const path = resolveReportPath(link, linkDirs, dataRoot);
const entry = { link, found: Boolean(path), date: null };
if (path) {
const header = reportHeaderDate(readReportHead(path));
const fromName = isoDay(FILENAME_DATE_RE.exec(basename(path))?.[1] ?? '');
if (header) {
entry.date = header;
dated.push({ date: header, from: 'report-header' });
} else if (fromName) {
entry.date = fromName;
dated.push({ date: fromName, from: 'report-filename' });
}
}
reports.push(entry);
}
if (dated.length === 0) return { evaluated: null, evaluatedFrom: [], reports };
const evaluated = dated.reduce((max, d) => (d.date > max ? d.date : max), dated[0].date);
const evaluatedFrom = [...new Set(dated.filter(d => d.date === evaluated).map(d => d.from))];
return { evaluated, evaluatedFrom, reports };
}
/**
* Compute the re-evaluation candidates from tracker markdown.
*
* @param {string} trackerMd
* @param {object} opts
* @param {object[]} opts.sources - From primarySourceChanges().
* @param {Set<string>} opts.statuses - Canonical status labels to include.
* @param {string[]} opts.linkDirs - Directories report links resolve from.
* @param {string} opts.dataRoot
*/
export function computeReevalCandidates(trackerMd, { sources, statuses, linkDirs, dataRoot, states = loadStates() }) {
const lines = String(trackerMd || '').replace(/\r\n/g, '\n').split('\n');
const colmap = resolveColumns(lines);
const present = sources.filter(s => s.exists && s.changed);
const candidates = [];
const undated = [];
let rowsChecked = 0;
for (const line of lines) {
const row = parseTrackerRow(line, colmap);
if (!row) continue;
const rawStatus = String(row.status || '').replace(/\*\*/g, '').trim();
const status = resolveCanonicalState(rawStatus, states) ?? rawStatus;
if (!statuses.has(status)) continue;
rowsChecked++;
const { evaluated, evaluatedFrom, reports } = rowEvaluationDate(row, { linkDirs, dataRoot });
const primary = reports.find(r => r.date === evaluated) ?? reports[0] ?? null;
const base = {
num: row.num,
company: row.company,
role: row.role,
score: row.score,
status,
report: primary ? primary.link : null,
reportFound: primary ? primary.found : null,
};
if (reports.some(r => !r.found)) base.missingReports = reports.filter(r => !r.found).map(r => r.link);
if (!evaluated) {
undated.push({ ...base, reason: 'no ISO date in the Date column, Notes or a linked report' });
continue;
}
const changedSince = present
.filter(s => s.changed > evaluated)
.sort((a, b) => b.changed.localeCompare(a.changed) || SOURCE_NAMES.indexOf(a.source) - SOURCE_NAMES.indexOf(b.source))
.map(s => ({ source: s.source, changed: s.changed, basis: s.basis }));
if (changedSince.length === 0) continue;
candidates.push({ ...base, evaluated, evaluatedFrom, changedSince });
}
candidates.sort((a, b) => a.evaluated.localeCompare(b.evaluated) || a.num - b.num);
undated.sort((a, b) => a.num - b.num);
const latest = present.reduce((max, s) => (!max || s.changed > max.changed ? s : max), null);
return {
candidates,
undated,
rowsChecked,
latestSourceChange: latest ? { source: latest.source, changed: latest.changed, basis: latest.basis } : null,
};
}
// --- CLI ---
const KNOWN_FLAGS = ['--status', '--tracker', '--json', '--summary', '--help', '-h'];
const VALUE_FLAGS = ['--status', '--tracker'];
const USAGE = `Usage:
node reeval-candidates.mjs # JSON report (default)
node reeval-candidates.mjs --summary # human-readable table
node reeval-candidates.mjs --status <A[,B...]> # canonical states to include (default ${DEFAULT_STATUS})
node reeval-candidates.mjs --tracker <path> # a different applications.md
node reeval-candidates.mjs --help # show this message
Lists tracker rows whose last evaluation predates the latest change to
cv.md, article-digest.md, config/profile.yml or modes/_profile.md (git commit
date when the file is committed, file mtime otherwise). Read-only: it never
re-evaluates and never writes to the tracker, a report or any other file.`;
function fail(message) {
console.error(message);
process.exit(2);
}
function printSummary(report) {
const { metadata, sources, candidates, undated } = report;
const clip = (s, n) => (String(s).length > n ? `${String(s).slice(0, n - 1)}\u2026` : String(s));
console.log(`\n${'='.repeat(86)}`);
console.log(' Re-evaluation candidates \u2014 career-ops');
const latest = metadata.latestSourceChange;
console.log(` Status: ${metadata.statuses.join(', ')} \u00b7 latest source change: ${latest ? `${latest.changed} (${latest.source})` : 'none found'}`);
console.log(`${'='.repeat(86)}\n`);
console.log('Primary sources:');
for (const s of sources) {
const when = s.exists ? s.changed : '\u2014';
const basis = s.exists ? (s.basis === 'mtime' ? 'mtime (approximate)' : 'git commit') : 'missing';
console.log(` ${s.source.padEnd(20)}${when.padEnd(12)}${basis}`);
}
console.log(`\nScored before a later source change: ${candidates.length}`);
if (candidates.length) {
console.log(` ${'#'.padEnd(5)}${'Company'.padEnd(20)}${'Role'.padEnd(26)}${'Score'.padEnd(8)}${'Evaluated'.padEnd(12)}Changed since`);
for (const r of candidates) {
const changed = r.changedSince.map(c => `${c.source} ${c.changed}${c.basis === 'mtime' ? '~' : ''}`).join(', ');
console.log(` ${String(r.num).padEnd(5)}${clip(r.company, 18).padEnd(20)}${clip(r.role, 24).padEnd(26)}${clip(r.score || '\u2014', 7).padEnd(8)}${r.evaluated.padEnd(12)}${changed}`);
const report = r.report ? `${r.report}${r.reportFound ? '' : ' (missing)'}` : 'no report link';
console.log(` ${''.padEnd(5)}report: ${report}`);
}
console.log(' (~ = file mtime, approximate)');
}
console.log(`\nNo readable evaluation date: ${undated.length}`);
for (const r of undated) {
console.log(` #${r.num} ${clip(r.company, 22)} \u2014 ${clip(r.role, 28)} (${r.reason})`);
}
console.log(`\nRows checked: ${metadata.rowsChecked}`);
console.log('Read-only. To refresh a score, evaluate the posting again; the merge updates the row in place.\n');
}
if (isMainModule(import.meta.url)) {
const args = process.argv.slice(2);
validateFlags(args, KNOWN_FLAGS, USAGE, { valueFlags: VALUE_FLAGS, requireOperand: true });
if (hasFlag(args, '--json') && hasFlag(args, '--summary')) fail('Choose one of --json or --summary.');
const valueOf = (flag) => (hasFlag(args, flag) ? flagValue(args, flag) : null);
const states = loadStates();
let statuses;
try {
statuses = resolveStatusFilter(valueOf('--status') ?? DEFAULT_STATUS, states);
} catch (err) {
fail(err.message);
}
const dataRoot = getCareerOpsRoot();
const trackerArg = valueOf('--tracker');
const trackerPath = trackerArg ? canonicalizeTrackerPath(trackerArg) : resolveTrackerPath(dataRoot);
const linkDirs = trackerArg
? [...new Set([dirname(trackerPath), dirname(resolve(trackerArg))])]
: trackerLinkDirsFor(dataRoot);
const warnings = [];
let trackerMd = '';
if (existsSync(trackerPath)) {
trackerMd = readFileSync(trackerPath, 'utf-8');
} else {
warnings.push(`No tracker found at ${trackerPath}; nothing to check.`);
}
const sources = primarySourceChanges(dataRoot);
if (!sources.some(s => s.exists)) {
warnings.push(`None of ${SOURCE_NAMES.join(', ')} was found under ${dataRoot}; nothing to compare against.`);
}
const result = computeReevalCandidates(trackerMd, { sources, statuses, linkDirs, dataRoot, states });
const display = (path) => {
const rel = relative(dataRoot, path);
return rel && !rel.startsWith('..') && !isAbsolute(rel) ? rel.split('\\').join('/') : path;
};
const report = {
metadata: {
statuses: [...statuses],
rowsChecked: result.rowsChecked,
candidates: result.candidates.length,
undated: result.undated.length,
latestSourceChange: result.latestSourceChange,
},
sources: sources.map(s => ({ ...s, path: display(s.path) })),
candidates: result.candidates,
undated: result.undated,
warnings,
};
if (hasFlag(args, '--summary')) {
printSummary(report);
for (const w of warnings) console.log(w);
} else {
console.log(JSON.stringify(report, null, 2));
}
}