#!/usr/bin/env node import { execFileSync } from 'node:child_process'; import { existsSync, readFileSync } from 'node:fs'; import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; import { appendContact, VALID_TYPES } from './contact-extract.mjs'; import { replaceNextPin } from './followup-seed.mjs'; import { isRealCalendarDate } from './followup-cadence.mjs'; import { isMainModule } from './lib/is-main-module.mjs'; import { validateFlags } from './lib/cli-flags.mjs'; import { loadYaml } from './lib/yaml-document.mjs'; import { machineSummaryStart } from './lib/machine-summary-section.mjs'; import { getCareerOpsRoot, resolveTrackerPath } from './path-resolver.mjs'; import { parseMarkdownLinks, parseTrackerRow, resolveColumns } from './tracker-parse.mjs'; import { acquireTrackerLock, resolveWorkspaceRoot, trackerLockDirFor, writeFileAtomic } from './tracker-utils.mjs'; const CODE_ROOT = dirname(fileURLToPath(import.meta.url)); const FENCE = /^(##[ \t]*Machine Summary[ \t]*\n+```(?:yaml|yml)?[ \t]*\n)([\s\S]*?)(\n```)/i; const KNOWN_FLAGS = ['--app', '--dealbreaker', '--contact-name', '--contact-email', '--contact-type', '--next', '--why', '--help', '-h']; const VALUE_FLAGS = ['--app', '--dealbreaker', '--contact-name', '--contact-email', '--contact-type', '--next', '--why']; const USAGE = `Usage: node debrief-sync.mjs --app N [--dealbreaker TEXT] [--contact-name NAME [--contact-email EMAIL] [--contact-type TYPE]]... [--next YYYY-MM-DD] [--why TEXT] Records a confirmed interview debrief on one tracker row. Pass at least one of --dealbreaker, --contact-name, or --next. A second run of the same command changes nothing.`; export class DebriefSyncError extends Error { constructor(code, message) { super(message); this.name = 'DebriefSyncError'; this.code = code; } } function planError(code, message) { throw new DebriefSyncError(code, `${code}: ${message}`); } function insideWorkspace(root, candidate) { const rel = relative(resolve(root), resolve(candidate)); return rel === '' || (!rel.startsWith(`..${sep}`) && rel !== '..' && !isAbsolute(rel)); } function normalizeDealbreaker(value) { if (value == null) return null; const text = String(value); // Notes are joined with "; ". A semicolon, tab, or newline would split the // cell, so the same sentence would not match on the next run. if (/[\t\n\r;]/.test(text)) { planError('BAD_PLAN', 'dealbreaker text cannot contain a tab, newline, or semicolon'); } const trimmed = text.trim(); return trimmed || null; } function normalizeContacts(contacts) { if (contacts == null) return []; if (!Array.isArray(contacts)) planError('BAD_PLAN', 'contacts must be a list'); return contacts.map((contact) => { const name = String(contact?.name ?? '').trim(); if (!name || /[\t\r\n]/.test(String(contact?.name ?? ''))) { planError('BAD_PLAN', 'each contact needs a name without a tab or newline'); } const next = { name }; const email = contact?.email == null ? '' : String(contact.email).trim(); if (email) next.email = email; const type = contact?.type == null ? '' : String(contact.type).trim(); if (type) { if (!VALID_TYPES.has(type)) { planError('BAD_PLAN', `contact type must be one of ${[...VALID_TYPES].join(', ')}`); } next.type = type; } return next; }); } function normalizeNext(value) { if (value == null || value === '') return null; if (!isRealCalendarDate(String(value))) { planError('AMBIGUOUS_DATE', `next date must be one real YYYY-MM-DD day, got ${value}`); } return String(value); } function normalizeWhy(value) { if (value == null) return null; const text = String(value); if (/[\t\r\n]/.test(text)) planError('BAD_PLAN', 'pin note cannot contain a tab or newline'); const trimmed = text.trim(); return trimmed || null; } function normalizePlan(plan) { if (!plan || typeof plan !== 'object') planError('BAD_PLAN', 'plan must be an object'); if (!Number.isSafeInteger(plan.appNum) || plan.appNum <= 0) { planError('BAD_PLAN', 'appNum must be a positive integer'); } const dealbreaker = normalizeDealbreaker(plan.dealbreaker); const contacts = normalizeContacts(plan.contacts); const nextDate = normalizeNext(plan.nextDate); const nextWhy = nextDate ? normalizeWhy(plan.nextWhy) : null; if (dealbreaker == null && contacts.length === 0 && nextDate == null) { planError('BAD_PLAN', 'pass a dealbreaker, a contact, or a next date'); } return { appNum: plan.appNum, dealbreaker, contacts, nextDate, nextWhy }; } function replaceEntry(yamlBody, key, entry) { const lines = yamlBody.split('\n'); const idx = lines.findIndex((line) => line.startsWith(`${key}:`)); if (idx === -1) return `${yamlBody}${yamlBody.endsWith('\n') ? '' : '\n'}${entry.join('\n')}`; let end = idx + 1; while (end < lines.length && (lines[end].trim() === '' || /^(\s|-(\s|$))/.test(lines[end]))) end += 1; while (end > idx + 1 && lines[end - 1].trim() === '') end -= 1; lines.splice(idx, end - idx, ...entry); return lines.join('\n'); } function hardStops(summary) { return Array.isArray(summary?.hard_stops) ? summary.hard_stops.map((item) => String(item)) : []; } function rewriteSummary(yamlBody, summary, sentence) { let next = yamlBody; if (String(summary.final_decision ?? '').trim() !== 'Skip') { next = replaceEntry(next, 'final_decision', ['final_decision: "Skip"']); } const stops = hardStops(summary); if (!stops.includes(sentence)) { const items = [...stops, sentence].map((item) => ` - ${JSON.stringify(item)}`); next = replaceEntry(next, 'hard_stops', ['hard_stops:', ...items]); } return next; } function parseSummary(yamlBody) { try { return loadYaml(yamlBody); } catch (err) { planError('BAD_PLAN', `Machine Summary YAML did not parse: ${err.message}`); } } function applyReport(report, sentence) { const start = machineSummaryStart(report); const fence = start >= 0 ? report.slice(start).match(FENCE) : null; if (!fence) planError('BAD_PLAN', 'the report has no Machine Summary YAML'); const summary = parseSummary(fence[2]); if (summary == null || typeof summary !== 'object' || Array.isArray(summary)) { planError('BAD_PLAN', 'the Machine Summary YAML is not a mapping'); } const nextYaml = rewriteSummary(fence[2], summary, sentence); const written = parseSummary(nextYaml); if (written?.final_decision !== 'Skip' || !hardStops(written).includes(sentence)) { planError('BAD_PLAN', 'the Machine Summary could not be rewritten safely; edit final_decision and hard_stops by hand'); } return report.slice(0, start) + `${fence[1]}${nextYaml}${fence[3]}` + report.slice(start + fence[0].length); } function reportPathFor(row, workspaceRoot) { const links = parseMarkdownLinks(String(row.report ?? '')); if (links.length !== 1) { planError('BAD_PLAN', `application #${row.num} must link exactly one report`); } const normalized = links[0].target.replace(/\\/g, '/').replace(/^(\.\.\/)+/, ''); const candidate = resolve(normalized.includes('/') ? workspaceRoot : join(workspaceRoot, 'reports'), normalized); if (!insideWorkspace(workspaceRoot, candidate) || !existsSync(candidate)) { planError('BAD_PLAN', `report for application #${row.num} was not found`); } return candidate; } function loadRow(appsFile, appNum) { if (!existsSync(appsFile)) planError('BAD_PLAN', `tracker not found at ${appsFile}`); const lines = readFileSync(appsFile, 'utf8').split('\n'); const colmap = resolveColumns(lines); const row = lines.map((line) => parseTrackerRow(line, colmap)).find((item) => item?.num === appNum); if (!row) planError('BAD_PLAN', `application #${appNum} is not in the tracker`); return row; } function appendDealbreakerNote(appNum, status, sentence, root, { dryRun = false } = {}) { const stdout = execFileSync(process.execPath, [ join(CODE_ROOT, 'set-status.mjs'), '--row', String(appNum), status, '--note', `Dealbreaker: ${sentence}`, '--json', ...(dryRun ? ['--dry-run'] : []), ], { encoding: 'utf8', env: { ...process.env, CAREER_OPS_ROOT: root }, ...(dryRun ? { stdio: ['ignore', 'pipe', 'pipe'] } : {}), }); return JSON.parse(stdout).changed === true; } function checkDealbreakerNote(appNum, status, sentence, root) { try { appendDealbreakerNote(appNum, status, sentence, root, { dryRun: true }); } catch (err) { const detail = String(err.stderr || err.message).replace(/^❌\s*/, '').trim(); planError('BAD_PLAN', `set-status.mjs would not add the tracker note: ${detail}`); } } export async function applyDebriefSync(plan) { const normalized = normalizePlan(plan); const root = getCareerOpsRoot(); const appsFile = resolveTrackerPath(root); const lock = await acquireTrackerLock(trackerLockDirFor(appsFile), { tracker: appsFile }); let prepared; let wrote = false; try { const row = loadRow(appsFile, normalized.appNum); const status = row.status.trim(); const company = row.company.trim(); if (normalized.contacts.length > 0 && !company) { planError('BAD_PLAN', 'the tracker row has no company to store with the contact'); } if (normalized.dealbreaker && !status) { planError('BAD_PLAN', 'the tracker row has no status to keep while recording the dealbreaker'); } if (normalized.dealbreaker) { const reportPath = reportPathFor(row, resolveWorkspaceRoot(appsFile)); const original = readFileSync(reportPath, 'utf8'); const next = applyReport(original, normalized.dealbreaker); checkDealbreakerNote(normalized.appNum, status, normalized.dealbreaker, root); if (next !== original) { writeFileAtomic(reportPath, next); wrote = true; } } prepared = { status, company }; } finally { lock.release(); } if (normalized.dealbreaker) { if (appendDealbreakerNote(normalized.appNum, prepared.status, normalized.dealbreaker, root)) wrote = true; } const contactsPath = join(root, 'data', 'contacts.tsv'); for (const contact of normalized.contacts) { const before = existsSync(contactsPath) ? readFileSync(contactsPath, 'utf8') : null; const payload = { name: contact.name, company: prepared.company, tracker: String(normalized.appNum), }; if (contact.email) payload.email = contact.email; if (contact.type) payload.type = contact.type; await appendContact(payload, contactsPath); const after = readFileSync(contactsPath, 'utf8'); if (after !== before) wrote = true; } if (normalized.nextDate) { const pin = await replaceNextPin(normalized.appNum, { date: normalized.nextDate, why: normalized.nextWhy, }); if (pin.changed) wrote = true; } return { wrote, appNum: normalized.appNum }; } function flagToken(token) { const eq = token.indexOf('='); return eq === -1 ? token : token.slice(0, eq); } function parseArgs(args) { const plan = { appNum: null, dealbreaker: null, contacts: [], nextDate: null, nextWhy: null }; const seen = new Set(); let current = null; for (let i = 0; i < args.length; i += 1) { const token = args[i]; if (token === '--help' || token === '-h') continue; if (!token.startsWith('--')) planError('BAD_PLAN', `unexpected argument ${token}`); const flag = flagToken(token); const eq = token.indexOf('='); const value = eq === -1 ? args[i + 1] : token.slice(eq + 1); if (eq === -1) i += 1; if (flag === '--contact-name') { current = { name: value }; plan.contacts.push(current); continue; } if (flag === '--contact-email' || flag === '--contact-type') { if (!current) planError('BAD_PLAN', `${flag} must follow a contact name`); current[flag === '--contact-email' ? 'email' : 'type'] = value; continue; } if (seen.has(flag)) planError('BAD_PLAN', `${flag} was given twice`); seen.add(flag); if (flag === '--app') { if (!/^[1-9]\d*$/.test(value) || !Number.isSafeInteger(Number(value))) { planError('BAD_PLAN', `--app must be a positive integer, got ${value}`); } plan.appNum = Number(value); } else if (flag === '--dealbreaker') plan.dealbreaker = value; else if (flag === '--next') plan.nextDate = value; else if (flag === '--why') plan.nextWhy = value; } return plan; } async function main() { const args = process.argv.slice(2); validateFlags(args, KNOWN_FLAGS, USAGE, { valueFlags: VALUE_FLAGS, requireOperand: true }); const result = await applyDebriefSync(parseArgs(args)); console.log(JSON.stringify(result)); } if (isMainModule(import.meta.url)) { main().catch((err) => { console.error(err instanceof Error ? err.message : String(err)); process.exitCode = 1; }); }