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