Ctrl K
ComunidadGuías…

debrief-sync.mjs

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/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;
  });
}