hired-share.mjs
Rama main · réplica de lectura del repositorio original.
#!/usr/bin/env node
/**
* hired-share.mjs — draft a Hired Wall story from data the tracker already
* holds, show the EXACT payload, and open a prefilled GitHub issue for the
* user to review and submit themselves.
*
* Human-in-the-loop is the contract: this script never posts anything. The
* only thing that leaves the machine is what the user submits from their own
* GitHub account, after seeing every field. career-ops has no backend and no
* telemetry; the public ledger is the repo itself (HIRED.md + the issues).
*
* node hired-share.mjs --report 12 # draft from tracker row 12
* node hired-share.mjs --report 12 --anonymity role # role-and-location card, no handle
* node hired-share.mjs --report 12 --story "..." --open # custom quote + open the browser
* node hired-share.mjs --status # hires recorded but never asked
* node hired-share.mjs --report 12 --mark never # user said no: never ask again
*
* Anonymity levels (asked, never defaulted to the most exposed):
* handle → card shows @login and avatar (full credit)
* role → card shows role + location only (default)
* count → counts on the badge, no card
*
* The ask cadence lives in data/.hired-share-state.json (user layer, never
* committed): one ask per hire at outcome time, at most one gentle re-mention
* after an update if the answer was "later" (>30 days), and "never" is
* permanent. A flywheel that nags stops being a celebration.
*/
import { readFileSync, existsSync, mkdirSync } from 'fs';
import { dirname, join } from 'path';
import { execFileSync } from 'child_process';
import { flagValue, hasFlag, validateFlags } from './lib/cli-flags.mjs';
import { isMainModule } from './lib/is-main-module.mjs';
import { parseTrackerRow, resolveColumns, isSeparatorRow, isHeaderRow, parseMarkdownLinks, extractTrackerReportNumbers } from './tracker-parse.mjs';
import { resolveTrackerPath, resolveWorkspaceRoot, writeFileAtomic, acquireTrackerLock, loadCanonicalStates, resolveCanonicalStateId } from './tracker-utils.mjs';
import { getCareerOpsRoot } from './path-resolver.mjs';
import { fileURLToPath } from 'url';
// System-layer states.yml resolves from the codebase, not the data root (#3500).
const CODEBASE_ROOT = dirname(fileURLToPath(import.meta.url));
let statesCache = null;
/** Canonical states from templates/states.yml, read once per process. */
function canonicalStates() {
if (statesCache) return statesCache;
// Throws on a broken install rather than degrading to "no rows match":
// drafting a wrong Hired Wall story is worse than stopping.
statesCache = loadCanonicalStates(join(CODEBASE_ROOT, 'templates', 'states.yml'));
return statesCache;
}
const REPO_URL = 'https://github.com/career-ops-hq/career-ops';
const TEMPLATE = 'i-got-hired.yml';
const LEVELS = ['handle', 'role', 'count'];
const KNOWN_FLAGS = ['--report', '--anonymity', '--story', '--weeks', '--feature', '--open', '--dry-run', '--status', '--mark', '--root', '--help', '-h'];
const VALUE_FLAGS = ['--report', '--anonymity', '--story', '--weeks', '--feature', '--mark', '--root'];
const USAGE = `Usage:
node hired-share.mjs --report N [--anonymity handle|role|count] [--story "..."]
[--weeks N] [--feature "..."] [--open] [--dry-run]
node hired-share.mjs --status
node hired-share.mjs --report N --mark shared|later|never
Drafts a Hired Wall story from tracker row N, prints the exact payload, and
builds a prefilled GitHub issue URL. Nothing is posted by this script: the
user reviews on GitHub and submits from their own account. --status lists
hires recorded in the tracker that were never offered a share. --mark records
the user's answer so the question is never repeated against their wishes.`;
/**
* Raised when the anti-nag memory exists and cannot be trusted.
*
* Deliberately not a silent fallback: the file records which hires the user
* declined to share, and an empty memory is indistinguishable from "nobody has
* ever been asked". Failing loudly asks the user to fix one file; failing
* quietly asks them about a hire they already said no to.
*/
class StateError extends Error {
constructor(message) { super(message); this.name = 'StateError'; }
}
/** State file: the entire anti-nag memory. User layer, gitignored with data/. */
function statePath(root) { return join(root, 'data', '.hired-share-state.json'); }
function loadState(root) {
const p = statePath(root);
// Absent is a first run — an empty memory is the correct answer.
if (!existsSync(p)) return { byReport: {} };
let raw;
try {
raw = readFileSync(p, 'utf8');
} catch (err) {
throw new StateError(`cannot read ${p} (${err.message})`);
}
// Unreadable is NOT a first run, and returning an empty memory for it is how
// a "never" answer disappears. AGENTS.md's cadence rule is absolute — "If
// they say no: --mark never, and honor it — that hire is never brought up
// again" — and this file is the only thing that remembers. An empty object
// here means every declined hire becomes askable, and the next saveState
// persists that, so one unreadable byte erases the record permanently.
try {
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new StateError(`${p} does not contain a JSON object`);
}
// ABSENT is fine — a file written before this key existed, or one that has
// only ever recorded nothing, is an empty memory and `{}` is the right
// answer. PRESENT BUT NOT AN OBJECT is the same reset one level down: a
// string or an array here was silently replaced with `{}` and then written
// back by saveState, dropping every declined hire exactly as a corrupt file
// used to. Refuse it for the same reason the top level is refused.
if (parsed.byReport === undefined || parsed.byReport === null) parsed.byReport = {};
else if (typeof parsed.byReport !== 'object' || Array.isArray(parsed.byReport)) {
throw new StateError(`${p} has a "byReport" that is not an object — fix or delete it.`);
}
return parsed;
} catch (err) {
if (err instanceof StateError) throw err;
throw new StateError(`${p} is not valid JSON (${String(err.message).split('\n')[0]})`);
}
}
// Atomic, via the same helper every tracker writer uses. A plain writeFileSync
// truncates before it writes, so an interrupt — Ctrl-C, a full disk, a crash —
// leaves a half-written file. That file then fails to parse, which is exactly
// the condition the guard above now refuses to paper over: the non-atomic write
// is what MANUFACTURES the corruption the silent reset used to hide.
function saveState(root, s) {
// A root on the legacy layout (applications.md at the top, no data/) still
// resolves its hires, so data/ may not exist yet when the answer is recorded.
const p = statePath(root);
mkdirSync(dirname(p), { recursive: true });
writeFileAtomic(p, JSON.stringify(s, null, 2) + '\n');
}
/**
* A report id in its canonical, unpadded form.
*
* Report numbers are unpadded everywhere in the tracker (`12`, not `012`), so
* a flag that keeps its zeros has to reach the same row: `--report 012` is
* the same request as `--report 12`.
*
* A value that is not a number is NOT a report id: stripping the letters out
* of `--report 1x2` would record a decision for report 12, so an invalid id
* is rejected instead of guessed.
*
* @param {string|number} value - A report id as typed.
* @returns {string|null} The digits without leading zeros, or null when the
* value is not a number.
*/
function normalizeReportId(value) {
const digits = String(value ?? '').trim();
if (!/^\d+$/.test(digits)) return null;
return digits.replace(/^0+(?=\d)/, '');
}
/**
* The key a report's answer lives under in the state file.
*
* Report ids became unpadded with this change, so a state file written
* earlier still holds "012" where the row's id is now "12". A declined hire
* is meant to stay declined forever, so the lookup finds the old padded key
* by its unpadded form — and a new answer is written under the key that is
* already there, instead of adding a second one for the same hire.
*
* When a file holds BOTH forms of one id, a recorded decline wins: the
* answer is the strongest one on record, so "never" under "012" is not
* shadowed by "later" under "12" and the hire cannot become mentionable
* again.
*
* @param {{byReport: Record<string, any>}} state - The loaded state.
* @param {string} report - The report id.
* @returns {string} The key to read and write.
*/
function stateKeyFor(state, report) {
const byReport = state?.byReport ?? {};
const id = String(report);
const target = normalizeReportId(id);
if (target == null) return id;
const matches = Object.keys(byReport).filter((key) => normalizeReportId(key) === target);
if (matches.length === 0) return id;
const declined = matches.find((key) => byReport[key]?.status === 'never');
if (declined) return declined;
return id in byReport ? id : matches[0];
}
async function recordDecision(root, report, status) {
mkdirSync(join(root, 'data'), { recursive: true });
const lock = await acquireTrackerLock(`${statePath(root)}.lock`, { tracker: statePath(root) });
try {
const state = loadState(root);
const key = stateKeyFor(state, report);
if (status === 'drafted' && state.byReport[key]?.status === 'never') return;
state.byReport[key] = { status, askedAt: new Date().toISOString() };
saveState(root, state);
} finally {
lock.release();
}
}
/**
* True for a target that points at this install rather than at the web.
*
* A protocol-relative or absolute-URL target is somebody else's page, so its
* digits are never a report number of ours.
*
* @param {string} target
* @returns {boolean}
*/
function isLocalTarget(target) {
if (!target) return false;
if (target.startsWith('//')) return false;
return !/^[a-z][a-z0-9+.-]*:/i.test(target);
}
/**
* Report number from a Report cell the canonical parser could not resolve.
*
* The canonical parser only resolves report links written in the
* `{num}-{slug}.md` convention, so a tracker that links `reports/12.md` — a
* shape the tracker family has always accepted — resolves to nothing and
* `--report 12` would not find the row. The Report cell holds a report link
* by contract, so for a LOCAL link its numeric label (or the leading digits
* of its filename) is the report id. An external link never is: a tracker
* cell can hold a third-party posting URL whose digits mean nothing here.
*
* @param {string} cell - Raw Report cell.
* @returns {number|null} The report number, or null when there is none.
*/
function reportNumberFromReportCell(cell) {
const value = String(cell ?? '').trim();
if (!value) return null;
const links = parseMarkdownLinks(value);
for (const link of links) {
const target = String(link.target ?? '').trim().replace(/^<|>$/g, '');
if (!isLocalTarget(target)) continue;
const label = String(link.label ?? '').trim();
if (/^\d+$/.test(label) && Number(label) > 0) return Number(label);
const base = target.split(/[\\/]/).pop() ?? '';
const match = base.match(/^(\d+)(?:-|\.|$)/);
if (match) return Number(match[1]);
}
if (links.length > 0) return null;
// A bare path, no link syntax: the filename carries the number.
if (!isLocalTarget(value)) return null;
const base = value.split(/[\\/]/).pop() ?? '';
const match = base.match(/^(\d+)(?:-|\.|$)/);
return match ? Number(match[1]) : null;
}
/** All tracker rows whose canonical state is Hired, as {report, role, company, location, date}. */
export function hiredRows(trackerText) {
const lines = trackerText.split('\n');
const colmap = resolveColumns(lines);
const out = [];
for (const line of lines) {
if (!line.startsWith('|') || isSeparatorRow(line) || isHeaderRow(line)) continue;
const row = parseTrackerRow(line, colmap);
// Exact canonical state, never a substring: `/hired/i` used to count any
// future state containing "hired" (NotHired, PreHired) as a landed job.
// resolveCanonicalStateId returns the canonical ID ("hired"), case-folded.
if (!row || resolveCanonicalStateId(row.status ?? '', canonicalStates()) !== 'hired') continue;
// The canonical report parser, never a digit grab off the whole cell: the
// FIRST-run-of-digits rule answered `reports/012-acme.md` as "012", so
// `--report 12` found nothing. Numbers come back unpadded, label first.
const reportNumbers = extractTrackerReportNumbers(row.report ?? '', row.notes ?? '');
const loose = reportNumbers.length > 0 ? null : reportNumberFromReportCell(row.report ?? '');
out.push({
report: reportNumbers.length > 0 ? String(reportNumbers[0]) : (loose != null ? String(loose) : ''),
role: row.role ?? '',
company: row.company ?? '',
location: row.location ?? '',
date: row.date ?? '',
});
}
return out;
}
/**
* Weeks from the earliest scan-history date to `endIso`. The search started
* when the first posting was seen, not when this row appeared; that is the
* number a reader deciding whether to try the tool actually wants. Returns
* null when it cannot be derived — the field is optional and the user can
* always pass --weeks.
*/
export function weeksFromScanHistory(scanHistoryText, endIso) {
if (!scanHistoryText || !endIso) return null;
let earliest = null;
for (const line of scanHistoryText.split('\n')) {
const m = line.match(/^(\d{4}-\d{2}-\d{2})\t/);
if (m && (!earliest || m[1] < earliest)) earliest = m[1];
}
if (!earliest) return null;
const ms = new Date(`${endIso}T12:00:00Z`) - new Date(`${earliest}T12:00:00Z`);
if (!(ms > 0)) return null;
return Math.max(1, Math.round(ms / (7 * 86400e3)));
}
/**
* The prefilled issue URL. GitHub issue forms accept query params keyed by
* field id; dropdowns need the exact option text. Everything the user will
* see on GitHub is exactly what this URL carries — printed in full first.
*/
export function buildIssueUrl({ role, companyType, story, feature, timeToHire, anonymity }) {
const q = new URLSearchParams();
q.set('template', TEMPLATE);
q.set('title', `Hired: ${role}`);
if (role) q.set('role', role);
if (companyType) q.set('company_type', companyType);
if (story) q.set('story', story);
if (feature) q.set('feature', feature);
if (timeToHire) q.set('time_to_hire', timeToHire);
q.set('anonymity', {
handle: 'With my GitHub handle (full credit)',
role: 'Role and location only, no handle',
count: 'Just count me, no card',
}[anonymity]);
return `${REPO_URL}/issues/new?${q.toString()}`;
}
function openInBrowser(url) {
const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'cmd' : 'xdg-open';
const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url];
try { execFileSync(cmd, args, { stdio: 'ignore' }); return true; } catch { return false; }
}
async function main() {
const args = process.argv.slice(2);
if (args.includes('--help') || args.includes('-h')) { console.log(USAGE); return; }
const bad = validateFlags(args, KNOWN_FLAGS, USAGE, { valueFlags: VALUE_FLAGS, requireOperand: true });
if (bad) { process.exitCode = 1; return; }
// Default to the configured data root (CAREER_OPS_ROOT / CAREER_OPS_DATA_DIR /
// .career-ops-data marker), not process.cwd(): with the user layer outside the
// checkout, the cwd-derived root found no tracker and reported no hires.
const root = flagValue(args, '--root') || resolveWorkspaceRoot(resolveTrackerPath(getCareerOpsRoot()));
const trackerPath = resolveTrackerPath(root);
const tracker = existsSync(trackerPath) ? readFileSync(trackerPath, 'utf8') : '';
const hires = hiredRows(tracker);
const state = loadState(root);
if (hasFlag(args, '--status')) {
// For the agent's post-update moment: which hires were never asked, and
// which said "later" long enough ago that ONE gentle re-mention is fair.
const now = Date.now();
const rows = hires.map((h) => {
const s = state.byReport[stateKeyFor(state, h.report)];
const staleLater = s?.status === 'later' && s.askedAt && (now - new Date(s.askedAt)) > 30 * 86400e3;
return { ...h, asked: Boolean(s), status: s?.status ?? 'never-asked', mentionable: !s || staleLater };
});
console.log(JSON.stringify({ hires: rows, askable: rows.filter((r) => r.mentionable).map((r) => r.report) }, null, 2));
return;
}
const report = flagValue(args, '--report');
if (!report) { console.error(`--report is required (or use --status).\n\n${USAGE}`); process.exitCode = 1; return; }
// A report id is a number: `--report 1x2` is a typo, not report 12.
const reportId = normalizeReportId(report);
if (reportId == null) {
console.error(`--report must be a report number, got ${JSON.stringify(report)}.\n\n${USAGE}`);
process.exitCode = 1; return;
}
const hire = hires.find((h) => h.report === reportId);
if (!hire) {
console.error(`No tracker row with state Hired and report #${reportId}. Record the outcome first: node outcome.mjs ${reportId} hired`);
process.exitCode = 1; return;
}
const mark = flagValue(args, '--mark');
if (mark) {
if (!['shared', 'later', 'never'].includes(mark)) { console.error(`--mark must be shared|later|never.\n\n${USAGE}`); process.exitCode = 1; return; }
await recordDecision(root, hire.report, mark);
console.log(`report #${hire.report} marked "${mark}"${mark === 'never' ? ' — this hire will never be brought up again' : ''}.`);
return;
}
const anonymity = flagValue(args, '--anonymity') || 'role';
if (!LEVELS.includes(anonymity)) { console.error(`--anonymity must be one of: ${LEVELS.join('|')}.\n\n${USAGE}`); process.exitCode = 1; return; }
let weeks = flagValue(args, '--weeks');
if (!weeks) {
const shPath = join(root, 'data', 'scan-history.tsv');
weeks = weeksFromScanHistory(existsSync(shPath) ? readFileSync(shPath, 'utf8') : '', hire.date) ?? '';
}
const story = flagValue(args, '--story') || '';
const feature = flagValue(args, '--feature') || '';
const payload = {
role: hire.role,
companyType: '',
story,
feature,
timeToHire: weeks ? `${weeks} weeks` : '',
anonymity,
};
const url = buildIssueUrl(payload);
console.log('This is EXACTLY what the prefilled issue will contain — nothing more:');
console.log(JSON.stringify(payload, null, 2));
console.log('\nReview and submit it yourself on GitHub (nothing has been sent):');
console.log(url);
if (hasFlag(args, '--dry-run')) return;
await recordDecision(root, hire.report, 'drafted');
if (hasFlag(args, '--open')) {
if (!openInBrowser(url)) console.log('(could not open a browser here — copy the URL above)');
}
}
if (isMainModule(import.meta.url)) {
main().catch((e) => { console.error(e.message); process.exitCode = 1; });
}