Ctrl K
ComunidadGuías…

add-entry.mjs

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

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env node
/**
 * add-entry.mjs — Deterministic dedup + insertion for `/career-ops add`.
 *
 * The `add` mode (agent) does the fetching, extraction, ATS-bullet writing,
 * preview, and confirm-before-write gate. This helper does ONE thing: take a
 * structured payload of already-written markdown and insert it into the user's
 * `cv.md` and/or `article-digest.md` idempotently — skipping anything that is
 * already there. It never fabricates or rewrites content; it only places the
 * blocks the agent produced.
 *
 * Usage:
 *   node add-entry.mjs <payload.json> [--dry-run]
 *   node add-entry.mjs --stdin [--dry-run]        (read payload JSON from stdin)
 *
 * Payload shape (every key optional, at least one required):
 *   {
 *     "cv": {
 *       "section": "Projects",                 // heading to insert under (## Projects)
 *       "dedupKey": "FraudShield",             // used to detect an existing entry
 *       "entry": "- **FraudShield** (Open Source) -- Real-time fraud detection..."
 *     },
 *     "articleDigest": {
 *       "dedupKey": "FraudShield",
 *       "entry": "## FraudShield -- Real-Time Fraud Detection\n\n**Hero metrics:** ..."
 *     },
 *     "articleDigestUpdate": {                 // one bullet under an EXISTING entry
 *       "dedupKey": "FraudShield",             // the entry, matched like articleDigest
 *       "bullet": "- Update 2026-10-22: 3 more merged PRs to acme/fraudshield since 2026-10-15"
 *     }
 *   }
 *
 * `articleDigestUpdate` appends one line at the end of that entry and touches
 * nothing else: no existing line is rewritten, and cv.md is never read for it.
 * The same bullet already in the entry is a "duplicate" no-op, so a rerun
 * never adds it twice.
 *
 * Output: a JSON result to stdout, e.g.
 *   { "dryRun": false,
 *     "cv": { "status": "added", "section": "Projects" },
 *     "articleDigest": { "status": "duplicate" },
 *     "articleDigestUpdate": { "status": "appended", "entry": "FraudShield" } }
 *
 * Exit codes: 0 on success (including a no-op "duplicate"); non-zero only on a
 * hard error (bad/empty payload, a requested target file missing, unwritable,
 * or an update whose entry is not in article-digest.md or is there twice).
 *
 * Test isolation: CAREER_OPS_CV / CAREER_OPS_ARTICLE_DIGEST override the target
 * files so tests never touch a real user CV.
 */

import { readFileSync, existsSync, realpathSync } from 'fs';
import { join, dirname, basename } from 'path';
import { fileURLToPath, pathToFileURL } from 'url';
import { getCareerOpsRoot } from './path-resolver.mjs';
import { normalizeTextKey } from './tracker-parse.mjs';
import { replaceUserFile, userFileWriteTarget } from './tracker-utils.mjs';
import { acquireDirLock, releaseOrWarn } from './lib/dir-lock.mjs';
import { validateFlags } from './lib/cli-flags.mjs';
import { isMainModule } from './lib/is-main-module.mjs';

const CAREER_OPS = getCareerOpsRoot();

const CV_FILE = process.env.CAREER_OPS_CV || join(CAREER_OPS, 'cv.md');
const ARTICLE_DIGEST_FILE = process.env.CAREER_OPS_ARTICLE_DIGEST || join(CAREER_OPS, 'article-digest.md');

const KNOWN_FLAGS = ['--dry-run', '--stdin', '--help', '-h'];

const USAGE = `Usage:
  node add-entry.mjs <payload.json> [--dry-run]
  node add-entry.mjs --stdin [--dry-run]
  node add-entry.mjs --help                    # print this usage block and exit (-h is an alias)`;

// Normalize a title/heading for duplicate detection: lowercase, collapse to
// letters and digits. "FraudShield", "Fraud-Shield", "fraud shield" all match.
//
// Delegates to the shared normalizeTextKey rather than keeping a private
// `[^a-z0-9]` strip. That strip deleted every non-Latin character, so a CV
// written in Japanese, Russian or Hindi keyed EVERY heading and dedup key to
// '' — which made `add` unusable rather than inaccurate: the non-empty-dedupKey
// guard below rejected a key the user had actually supplied ("payload.cv
// requires a non-empty dedupKey"), and two different section headings both
// keying to '' matched each other, so an entry could land under the wrong
// heading (#2849).
//
// Latin behaviour is unchanged except that an accented word now keys
// faithfully: "Café" was truncated to "caf" (it never matched "Cafe" either
// way), and now keys as "café".
export function normalizeKey(s) {
  return typeof s === 'string' ? normalizeTextKey(s) : '';
}

// Split a markdown doc into the block belonging to a `## <section>` heading:
// { before, heading, body, after }. `body` runs up to the next `## ` heading
// (or EOF). Returns null when the section is absent.
export function locateSection(md, section) {
  const target = normalizeKey(section);
  const lines = md.split('\n');
  for (let i = 0; i < lines.length; i++) {
    const m = lines[i].match(/^##\s+(.*\S)\s*$/);
    if (m && normalizeKey(m[1]) === target) {
      let end = i + 1;
      while (end < lines.length && !/^##\s+/.test(lines[end])) end++;
      return {
        before: lines.slice(0, i).join('\n'),
        heading: lines[i],
        body: lines.slice(i + 1, end).join('\n'),
        after: lines.slice(end).join('\n'),
      };
    }
  }
  return null;
}

// The identifiers an entry can be recognized by within a section: bold spans
// (**Name**) and any sub-headings (### / ####). Matching dedup against these
// discrete names — not the raw section text — stops a short key like "AI" from
// colliding with an unrelated substring (e.g. the "ai" inside "email").
export function extractIdentifiers(body) {
  const ids = [];
  for (const m of body.matchAll(/\*\*([^*]+)\*\*/g)) ids.push(m[1]);
  for (const m of body.matchAll(/^#{3,}\s+(.*\S)\s*$/gm)) ids.push(m[1]);
  return ids;
}

// Does the named CV section already contain an entry matching dedupKey?
// Compares the key against each entry's identifier (normalized equality), not a
// substring of the whole section body.
export function cvHasEntry(md, section, dedupKey) {
  const key = normalizeKey(dedupKey);
  if (!key) return false;
  const loc = locateSection(md, section);
  if (!loc) return false;
  return extractIdentifiers(loc.body).some(id => normalizeKey(id) === key);
}

// Insert a pre-formatted entry under `## <section>`, creating the section at
// end-of-file when absent. Trailing whitespace inside the section is trimmed so
// entries stack cleanly with a single blank line between the heading and body.
export function insertIntoCvSection(md, section, entry) {
  const block = entry.replace(/\s+$/, '');
  const loc = locateSection(md, section);
  if (!loc) {
    const base = md.replace(/\s+$/, '');
    return `${base}\n\n## ${section}\n\n${block}\n`;
  }
  const body = loc.body.replace(/\s+$/, '');
  const newBody = body ? `${body}\n${block}` : block;
  const parts = [loc.before, loc.heading, '', newBody, ''];
  const after = loc.after.replace(/^\n+/, '');
  const rebuilt = parts.join('\n') + (after ? `\n${after}` : '');
  return rebuilt.replace(/\n{3,}/g, '\n\n');
}

// article-digest.md is a sequence of `## <name> -- <tagline>` blocks separated
// by `---`. Dedup on the name (the heading text before the dash), matched by
// normalized equality so "## FraudShield -- Detection" matches the key
// "FraudShield" without a short key colliding on other project names.
export function articleDigestHasEntry(md, dedupKey) {
  const key = normalizeKey(dedupKey);
  if (!key) return false;
  for (const m of md.matchAll(/^##\s+(.*\S)\s*$/gm)) {
    const n = normalizeKey(digestEntryName(m[1]));
    if (n === key) return true;
  }
  return false;
}

/** An article-digest heading's entry name: the text before the dash. */
export function digestEntryName(heading) {
  return heading.split(/\s+[—–-]{1,2}\s+/)[0];
}

/**
 * The entries of article-digest.md: each `## ` heading with its name and the
 * lines it owns, up to the next `#` or `##` heading (or EOF). `start` is the
 * heading's line index and `end` is exclusive, both into `md.split('\n')`.
 *
 * @returns {{name: string, start: number, end: number}[]}
 */
export function digestEntries(md) {
  const lines = md.split('\n');
  const entries = [];
  for (let i = 0; i < lines.length; i++) {
    const m = lines[i].match(/^##\s+(.*\S)\s*$/);
    if (!m) continue;
    let end = i + 1;
    while (end < lines.length && !/^#{1,2}\s/.test(lines[end])) end++;
    entries.push({ name: digestEntryName(m[1]), start: i, end });
  }
  return entries;
}

const LIST_ITEM = /^\s*[-*+]\s/;
const THEMATIC_BREAK = /^ {0,3}([-*_])(?:[ \t]*\1){2,}[ \t]*$/;
/** A bullet's text for comparison: marker dropped, whitespace collapsed. */
const bulletText = (line) => String(line).trim().replace(/^[-*+](?:\s+|$)/, '').replace(/\s+/g, ' ');

/**
 * Append one bullet to the end of an existing article-digest entry. Every other
 * line is kept byte for byte: the bullet goes after the entry's last content
 * line, before the blank lines and `---` that separate it from the next entry.
 * After a list item it continues that list; after a paragraph it starts one
 * with a blank line. A bullet whose text the entry already has is a duplicate.
 * Two entries with the same name are ambiguous: nothing is appended, since
 * the key cannot say which one was meant.
 *
 * @returns {{text: string, status: 'appended'|'duplicate'|'missing'|'ambiguous', entry?: string, matches?: number}}
 */
export function appendDigestBullet(md, dedupKey, bullet) {
  const key = normalizeKey(dedupKey);
  const matches = key ? digestEntries(md).filter((e) => normalizeKey(e.name) === key) : [];
  if (!matches.length) return { text: md, status: 'missing' };
  if (matches.length > 1) return { text: md, status: 'ambiguous', matches: matches.length };
  const [entry] = matches;
  const lines = md.split('\n');
  const want = bulletText(bullet);
  for (let k = entry.start + 1; k < entry.end; k++) {
    if (LIST_ITEM.test(lines[k]) && bulletText(lines[k]) === want) return { text: md, status: 'duplicate', entry: entry.name };
  }
  let last = entry.end - 1;
  while (last > entry.start && (!lines[last].trim() || THEMATIC_BREAK.test(lines[last].replace(/\r$/, '')))) last--;
  const eol = lines[last].endsWith('\r') ? '\r' : '';
  const insert = LIST_ITEM.test(lines[last]) ? [`- ${want}${eol}`] : [eol, `- ${want}${eol}`];
  lines.splice(last + 1, 0, ...insert);
  return { text: lines.join('\n'), status: 'appended', entry: entry.name };
}

export function appendArticleDigest(md, entry) {
  const block = entry.replace(/\s+$/, '');
  const base = md.replace(/\s+$/, '');
  // Keep the existing `---`-separated block rhythm.
  return `${base}\n\n---\n\n${block}\n`;
}

/**
 * Pure core: given the current file contents and a payload, compute the new
 * contents and a per-target status. No I/O — this is what the tests exercise.
 * @returns {{ cv: string, articleDigest: string, result: object }}
 */
export function applyAdd(payload, { cvText = null, articleText = null } = {}) {
  if (!payload || typeof payload !== 'object' || (!payload.cv && !payload.articleDigest && !payload.articleDigestUpdate)) {
    throw new Error('payload must include at least one of: cv, articleDigest, articleDigestUpdate');
  }

  const result = {};
  let cv = cvText;
  let articleDigest = articleText;

  if (payload.cv) {
    const { section, dedupKey, entry } = payload.cv;
    if (!section || !entry) throw new Error('payload.cv requires { section, entry }');
    // dedupKey is what makes the insert idempotent — refuse to add without one
    // rather than silently allowing duplicate re-runs.
    if (!normalizeKey(dedupKey)) throw new Error('payload.cv requires a non-empty dedupKey (used for dedup/idempotency)');
    if (cvText === null) throw new Error(`cv.md not found — cannot add to a CV that does not exist`);
    if (cvHasEntry(cvText, section, dedupKey)) {
      result.cv = { status: 'duplicate', section };
    } else {
      cv = insertIntoCvSection(cvText, section, entry);
      result.cv = { status: 'added', section };
    }
  }

  if (payload.articleDigest) {
    const { dedupKey, entry } = payload.articleDigest;
    if (!entry) throw new Error('payload.articleDigest requires { entry }');
    if (!normalizeKey(dedupKey)) throw new Error('payload.articleDigest requires a non-empty dedupKey (used for dedup/idempotency)');
    // article-digest.md is optional; create it from a header when missing.
    const current = articleText === null
      ? '# Article Digest -- Proof Points\n\nCompact proof points from portfolio projects. Read by career-ops at evaluation time.\n'
      : articleText;
    if (articleDigestHasEntry(current, dedupKey)) {
      result.articleDigest = { status: 'duplicate' };
      articleDigest = articleText;
    } else {
      articleDigest = appendArticleDigest(current, entry);
      result.articleDigest = { status: articleText === null ? 'created' : 'added' };
    }
  }

  // After articleDigest, so one payload can add an entry and then update it.
  if (payload.articleDigestUpdate) {
    const { dedupKey, bullet } = payload.articleDigestUpdate;
    if (!normalizeKey(dedupKey)) throw new Error('payload.articleDigestUpdate requires a non-empty dedupKey (the existing entry to update)');
    if (typeof bullet !== 'string' || /[\r\n]/.test(bullet.trim()) || !bulletText(bullet)) {
      throw new Error('payload.articleDigestUpdate requires { bullet }: one line of text');
    }
    if (articleDigest === null) throw new Error('article-digest.md not found — an update goes under an existing entry');
    const out = appendDigestBullet(articleDigest, dedupKey, bullet);
    if (out.status === 'missing') {
      throw new Error(`article-digest.md has no entry named "${dedupKey}" — an update goes under an existing entry`);
    }
    if (out.status === 'ambiguous') {
      throw new Error(`article-digest.md has ${out.matches} entries named "${dedupKey}" — rename one so the update has a single entry to go under`);
    }
    articleDigest = out.text;
    result.articleDigestUpdate = { status: out.status, entry: out.entry };
  }

  return { cv, articleDigest, result };
}

async function readStdin() {
  const chunks = [];
  for await (const chunk of process.stdin) chunks.push(chunk);
  return Buffer.concat(chunks).toString('utf-8');
}

async function main() {
  const args = process.argv.slice(2);

  // Migrated to shared validateFlags to reject mistyped flags (#3112).
  // Inside the main-module guard so importers are unaffected (#3088).
  validateFlags(args, KNOWN_FLAGS, USAGE);

  const dryRun = args.includes('--dry-run');
  const useStdin = args.includes('--stdin');
  const fileArg = args.find(a => !a.startsWith('-'));

  if (!useStdin && !fileArg) {
    console.error(USAGE);
    process.exit(1);
  }

  let payload;
  try {
    const raw = useStdin ? await readStdin() : readFileSync(fileArg, 'utf-8');
    payload = JSON.parse(raw);
  } catch (e) {
    console.error(`add-entry: could not read/parse payload: ${e.message}`);
    process.exit(1);
  }

  const locks = [];
  try {
    // Lock the actual files before reading: an atomic rename alone still loses
    // another process's confirmed addition. Sort and dedupe to avoid deadlocks.
    const targets = dryRun ? [] : [
      ...(payload?.cv ? [CV_FILE] : []),
      ...((payload?.articleDigest || payload?.articleDigestUpdate) ? [ARTICLE_DIGEST_FILE] : []),
    ].map(file => {
      const target = userFileWriteTarget(file);
      return join(realpathSync(dirname(target)), basename(target));
    });
    for (const target of [...new Set(targets)].sort()) {
      locks.push(await acquireDirLock(`${target}.add-entry.lock`, {
        timeoutMs: 60_000, retryMs: 50, staleMs: 600_000,
        owner: { tool: 'add-entry' },
      }));
    }
    const cvText = existsSync(CV_FILE) ? readFileSync(CV_FILE, 'utf-8') : null;
    const articleText = existsSync(ARTICLE_DIGEST_FILE) ? readFileSync(ARTICLE_DIGEST_FILE, 'utf-8') : null;

    let out;
    try {
      out = applyAdd(payload, { cvText, articleText });
    } catch (e) {
      console.error(`add-entry: ${e.message}`);
      process.exitCode = 1;
      return;
    }

    if (!dryRun) {
      // Track what actually landed so a failure on the second write reports which
      // file was already mutated (the two writes aren't transactional).
      const written = [];
      try {
        if (payload.cv && out.result.cv?.status === 'added') {
          replaceUserFile(CV_FILE, out.cv);
          written.push('cv.md');
        }
        if ((payload.articleDigest && (out.result.articleDigest?.status === 'added' || out.result.articleDigest?.status === 'created'))
          || out.result.articleDigestUpdate?.status === 'appended') {
          replaceUserFile(ARTICLE_DIGEST_FILE, out.articleDigest);
          written.push('article-digest.md');
        }
      } catch (e) {
        console.error(`add-entry: write failed after writing [${written.join(', ') || 'nothing'}]: ${e.message}`);
        process.exitCode = 1;
        return;
      }
    }

    console.log(JSON.stringify({ dryRun, ...out.result }, null, 2));
  } catch (e) {
    console.error(`add-entry: ${e.message}`);
    process.exitCode = 1;
  } finally {
    for (const lock of locks.reverse()) releaseOrWarn(lock);
  }
}

// Only run main() when invoked directly, not when imported by tests.
if (isMainModule(import.meta.url)) {
  main();
}