← Files Realize PluginARCHIVED FILE

skills/support/scripts/build-bundle.js

48.5 KB · Nov 13, 2026 · 18:10 UTC

↓ Download file

#!/usr/bin/env node
'use strict';

/**
 * Realize Plugin — support bundle builder.
 *
 * Reads the current Claude Code session transcript and renders a single
 * human-readable Markdown file for the Taboola Professional Services team.
 *
 * The transcript is the payload: PS needs the exact prompt, the exact call, and
 * the exact response. Everything above the transcript is mechanically derived
 * from it — never model-interpreted — so a plugin that misunderstood the user
 * cannot distort its own bug report.
 *
 * Usage:
 *   node build-bundle.js --preview
 *   node build-bundle.js --write --complaint-file ~/.realize-plugin/tmp/complaint-<stamp>-<rand>.txt
 *   node build-bundle.js --write --title-file ~/.realize-plugin/tmp/title-<stamp>-<rand>.txt
 *                        [--out <file.md>] [--force]
 *
 * Pass user-authored text by file, not as a quoted argument: the shell rewrites
 * it (`$12.40` becomes `2.40`) and `$(…)` executes. `--title` / `--complaint`
 * remain for trivial ASCII only. Both file options read only files named
 * complaint-*.txt / title-*.txt directly inside INPUT_DIR (~/.realize-plugin/tmp);
 * they are deleted after a successful --write, and leftovers from cancelled
 * runs are pruned after a day. `--force` needs an interactive terminal.
 *
 * Requires Node 18+ on PATH. Claude Code's native and desktop installs do not
 * provide one; the skill tells the user when `node` is missing. No dependencies.
 */

const fs = require('fs');
const path = require('path');
const os = require('os');

const SUPPORT_EMAIL = 'Support@taboola.com';

// The only folder --complaint-file / --title-file may read from. This script is
// pre-approved to run without a prompt, so a file option that accepted any path
// would let it read — and copy into an email attachment — any file the user
// can read. The skill's write permission is scoped to this same folder.
const INPUT_DIR = path.join(os.homedir(), '.realize-plugin', 'tmp');
// One file per run (`complaint-20261019-141502-k7q2.txt`), so two support runs
// never read each other's text. Anything else in the folder is refused.
const INPUT_NAME = /^(complaint|title)-[A-Za-z0-9_-]+\.txt$/;
// Leftovers from a run that was cancelled before --write.
const KEEP_INPUT_MS = 24 * 60 * 60 * 1000;

// Prepended to the summary so the case opens with its own provenance. Requested
// by PS: a Description that starts mid-diagnostics reads like a forwarded stack
// trace, and the agent picking up the case has no idea what produced it.
const EMAIL_PROLOG =
  'This case has been created by the Realize Plugin, please find the interaction summary below.';

// Case intake puts the email subject into the case Subject field, which is a
// single short line. The user's own words are the subject (per PS), so they have
// to survive being cut to something a triage queue can read.
const MAX_SUBJECT_CHARS = 120;

// Floor on how much of the user's own sentence must survive in the subject. If
// the account suffix would leave less than this, the suffix is dropped instead.
const MIN_SUBJECT_TEXT_CHARS = 40;

const MAX_RESULT_CHARS = 2000;           // per non-Realize tool result (bulk output)
const MAX_REALIZE_RESULT_CHARS = 20000;  // per Realize result — this is the evidence
const MAX_THINKING_CHARS = 1200; // per thinking block
const MAX_BUNDLE_BYTES = 8 * 1024 * 1024;

// ---------------------------------------------------------------------------
// args
// ---------------------------------------------------------------------------

function parseArgs(argv) {
  const out = {
    preview: false,
    write: false,
    title: '',
    complaint: '',
    out: '',
    force: false,
    allowGit: false,
    inputFiles: [],
  };
  for (let i = 0; i < argv.length; i++) {
    const a = argv[i];
    if (a === '--preview') out.preview = true;
    else if (a === '--write') out.write = true;
    else if (a === '--force') out.force = true;
    else if (a === '--allow-git') out.allowGit = true;
    else if (a === '--title') out.title = argv[++i] || '';
    else if (a === '--complaint') out.complaint = argv[++i] || '';
    else if (a === '--title-file') out.title = readTextFile(argv[++i], INPUT_DIR, out.inputFiles);
    else if (a === '--complaint-file') out.complaint = readTextFile(argv[++i], INPUT_DIR, out.inputFiles);
    else if (a === '--out') out.out = argv[++i] || '';
  }
  if (!out.preview && !out.write) out.preview = true;
  // A title spanning multiple lines splits the header and gives PS a broken
  // subject line. draftTitle() already collapses whitespace; do the same for
  // titles that arrive from a file or argument — and give them the same
  // redact-then-clamp treatment as the complaint, because the title becomes
  // the email subject when no complaint was supplied, so it leaves the machine.
  out.title = clampChars(redact(oneLine(out.title)), MAX_SUBJECT_CHARS);
  return out;
}

/**
 * Read user-authored text from a file rather than an argv string.
 *
 * The complaint is the user's own words and routinely contains characters the
 * shell rewrites — `$12.40` becomes `2.40` under double quotes, and `$(…)` or
 * backticks execute. Since this text is the evidence PS reads, it has to arrive
 * byte-for-byte. `--complaint` / `--title` remain for trivial ASCII, but the
 * file variants are the supported path.
 */
function readTextFile(p, inputDir = INPUT_DIR, seen = []) {
  if (!p) throw new Error('--complaint-file / --title-file was given without a path.');
  // realpath on both sides, so neither `..` nor a symlink inside the folder can
  // point the read somewhere else.
  let real;
  let root;
  try {
    real = fs.realpathSync(p);
    root = fs.realpathSync(inputDir);
  } catch (err) {
    throw new Error(`could not read ${p} — ${err.message}`);
  }
  if (path.dirname(real) !== root) {
    throw new Error(`${p} must be a file directly inside ${inputDir}. Write the text there and pass that path.`);
  }
  if (!INPUT_NAME.test(path.basename(real))) {
    throw new Error(`${p}: name the file complaint-<time>-<random>.txt or title-<time>-<random>.txt.`);
  }
  // realpath sees through symlinks but not hard links: a second name for a
  // file elsewhere looks like a regular file inside the folder. A file this
  // run wrote has exactly one name.
  const st = fs.lstatSync(real);
  if (!st.isFile() || st.nlink !== 1) {
    throw new Error(`${p} must be a regular file written for this run.`);
  }
  seen.push(real);
  try {
    return fs.readFileSync(real, 'utf8').trim();
  } catch (err) {
    // Fatal on purpose. Continuing would write a bundle whose "What the user
    // reported" section reads "The user did not add a description" — an
    // affirmative false statement to support about a user who did describe the
    // problem. Losing the complaint is worse than failing loudly.
    throw new Error(`could not read ${p} — ${err.message}`);
  }
}

/** Remove complaint/title files a cancelled run left behind, once a day old. */
function pruneInputDir(inputDir = INPUT_DIR, now = Date.now()) {
  let names = [];
  try {
    names = fs.readdirSync(inputDir);
  } catch {
    return;
  }
  for (const name of names) {
    if (!INPUT_NAME.test(name)) continue;
    const p = path.join(inputDir, name);
    try {
      const st = fs.lstatSync(p);
      if (st.isFile() && now - st.mtimeMs > KEEP_INPUT_MS) fs.rmSync(p, { force: true });
    } catch {
      // Already gone, or not ours to remove; nothing to do.
    }
  }
}

// ---------------------------------------------------------------------------
// locating the transcript
// ---------------------------------------------------------------------------

/** Claude Code encodes the project path by replacing every non-alphanumeric char with '-'. */
function encodeProjectDir(cwd) {
  return cwd.replace(/[^a-zA-Z0-9]/g, '-');
}

/**
 * Resolve this session's transcript.
 *
 * CLAUDE_CODE_SESSION_ID is the authoritative signal. Falling back to
 * "newest .jsonl" is a real coin flip — a project folder routinely holds
 * several sessions — so that path is a last resort and is reported as such.
 */
function findTranscript() {
  const projectsRoot = path.join(os.homedir(), '.claude', 'projects');
  if (!fs.existsSync(projectsRoot)) {
    return { error: `No Claude Code project data found at ${projectsRoot}.` };
  }

  const sessionId = process.env.CLAUDE_CODE_SESSION_ID || '';
  const cwd = process.cwd();

  if (sessionId) {
    // Preferred: derive the folder from cwd.
    const direct = path.join(projectsRoot, encodeProjectDir(cwd), `${sessionId}.jsonl`);
    if (fs.existsSync(direct)) {
      return { file: direct, sessionId, confidence: 'exact' };
    }
    // The session may have started in a different cwd — scan every project folder.
    for (const dir of safeReaddir(projectsRoot)) {
      const candidate = path.join(projectsRoot, dir, `${sessionId}.jsonl`);
      if (fs.existsSync(candidate)) {
        return { file: candidate, sessionId, confidence: 'exact' };
      }
    }
  }

  // Last resort.
  const dirPath = path.join(projectsRoot, encodeProjectDir(cwd));
  const candidates = safeReaddir(dirPath)
    .filter((f) => f.endsWith('.jsonl'))
    .map((f) => {
      const full = path.join(dirPath, f);
      return { full, mtime: fs.statSync(full).mtimeMs };
    })
    .sort((a, b) => b.mtime - a.mtime);

  if (!candidates.length) {
    return { error: `No session transcript found for this project (looked in ${dirPath}).` };
  }
  return {
    file: candidates[0].full,
    sessionId: path.basename(candidates[0].full, '.jsonl'),
    confidence: 'guessed',
    siblings: candidates.length,
  };
}

function safeReaddir(p) {
  try {
    return fs.readdirSync(p);
  } catch {
    return [];
  }
}

// ---------------------------------------------------------------------------
// redaction
// ---------------------------------------------------------------------------

/**
 * Strip credentials only. Account, campaign, and item IDs are deliberately
 * preserved — PS cannot reproduce the issue without them.
 */
const SECRET_KEYS =
  'password|passwd|client[-_]?secret|clientSecret|access[-_]?token|refresh[-_]?token|id[-_]?token|auth[-_]?token|api[-_]?key|apiKey|x-api-key|authorization|secret';

const REDACTIONS = [
  [/(Bearer\s+)[A-Za-z0-9._\-]{12,}/gi, '$1<redacted>'],
  // JSON form: "password": "value"
  [new RegExp(`("(?:${SECRET_KEYS})"\\s*:\\s*")[^"]*(")`, 'gi'), '$1<redacted>$2'],
  // Flat `key=value` form, for text that is not JSON (error strings, logs).
  //
  // Deliberately `=` only, not `[:=]`. The colon form is already covered by the
  // JSON rule above, and matching bare `secret:` / `authorization:` in prose
  // shredded legitimate ad copy — "Secret: Summer Sale" became
  // "Secret: <redacted> Sale". Destroying creative text in a bundle whose whole
  // purpose is evidence fidelity is worse than the leak it was guarding.
  //
  // The value must also be whitespace-free and reasonably long, so ordinary
  // prose after an equals sign is left alone.
  [
    new RegExp(`((?:${SECRET_KEYS})\\s*=\\s*)(?!<redacted)[^\\s,;&|"']{6,}`, 'gi'),
    '$1<redacted>',
  ],
  [/(eyJ[A-Za-z0-9_\-]{10,}\.[A-Za-z0-9_\-]{10,})\.[A-Za-z0-9_\-]+/g, '<redacted-jwt>'],
];

/** Key names whose value is always a credential, matched structurally. */
const SECRET_KEY_TEST = new RegExp(`^(?:${SECRET_KEYS})$`, 'i');

function redact(s) {
  if (typeof s !== 'string') return s;
  let out = s;
  for (const [re, rep] of REDACTIONS) out = out.replace(re, rep);
  return out;
}

/**
 * Redact a single parameter where the key is known.
 *
 * Preferred over running `redact()` across a rendered string: the key is
 * structural data, so there is no need to guess at prose. Callers that have an
 * object should use this.
 */
function redactValue(key, value) {
  if (SECRET_KEY_TEST.test(String(key))) return '<redacted>';
  return redact(String(value));
}

/** Collapse whitespace so a value can sit safely inside a Markdown table cell. */
function oneLine(s) {
  return String(s).replace(/\s+/g, ' ').trim();
}

function truncate(s, max) {
  if (typeof s !== 'string') s = String(s);
  if (s.length <= max) return s;
  return `${s.slice(0, max)}\n… [truncated ${s.length - max} more characters]`;
}

/**
 * Cap by bytes, not characters, and leave valid Markdown behind.
 *
 * A character-based slice under a byte-based limit can overshoot on multi-byte
 * content, and cutting inside a fenced block leaves every line below it
 * rendering as code.
 */
function truncateToBytes(s, maxBytes, note) {
  const buf = Buffer.from(s, 'utf8');
  if (buf.length <= maxBytes) return s;

  // Written as an escape, not the literal character: a raw replacement char in
  // source is invisible in most editors and won't survive an encoding change.
  let out = buf.subarray(0, maxBytes).toString('utf8').replace(new RegExp('\uFFFD+$'), '');

  // Close anything the cut left open, or every line below it renders wrong.
  if (((out.match(/^```/gm) || []).length) % 2 === 1) out += '\n```';
  const opened = (out.match(/<details>/g) || []).length;
  const closed = (out.match(/<\/details>/g) || []).length;
  for (let i = 0; i < opened - closed; i++) out += '\n</details>';

  return `${out}\n\n${note}\n`;
}

// ---------------------------------------------------------------------------
// parsing
// ---------------------------------------------------------------------------

const MAX_TRANSCRIPT_BYTES = 256 * 1024 * 1024;

function parseTranscript(file) {
  // The whole transcript is read and rendered before the output cap applies, so
  // guard the input too — otherwise a pathologically long session fails as an
  // out-of-memory crash rather than a message anyone can act on.
  const size = fs.statSync(file).size;
  if (size > MAX_TRANSCRIPT_BYTES) {
    throw new Error(
      `this session's transcript is ${Math.round(size / 1024 / 1024)} MB, ` +
        `above the ${MAX_TRANSCRIPT_BYTES / 1024 / 1024} MB limit. ` +
        `Start a fresh session and reproduce the issue there, then run /support again.`
    );
  }

  const raw = fs.readFileSync(file, 'utf8');
  const records = [];
  for (const line of raw.split('\n')) {
    if (!line.trim()) continue;
    try {
      records.push(JSON.parse(line));
    } catch {
      /* tolerate partial trailing writes */
    }
  }
  return records;
}

// Matches the plugin's own server (`mcp__realize-mcp__*`) and the claude.ai
// connector variant (`mcp__claude_ai_Realize_MCP__*`), which otherwise fell
// through to "other tools" and reported zero Realize activity.
const REALIZE_TOOL = /^mcp__(?:.*[-_])?realize[-_]?mcp__(.+)$/i;

function analyze(records) {
  const facts = {
    sessionId: '',
    claudeCodeVersion: '',
    gitBranch: '',
    cwd: '',
    models: new Set(),
    firstTs: '',
    lastTs: '',
    userTurns: 0,       // messages the human actually typed
    assistantTurns: 0,  // replies containing prose, not tool-call-only records
    toolResults: 0,     // tool results, which the transcript also stores as "user" records
    realizeCalls: [],   // {name, input, ok, error, ts}
    otherTools: new Map(),
    // What guidance the plugin actually consulted before answering. PS needs
    // this to separate two different bugs that look identical from outside:
    // the plugin read the right guidance and still got it wrong, versus the
    // plugin never read it. Extracted from tool calls, not self-reported.
    knowledgeFiles: new Set(),
    skillsUsed: new Set(),
    accountIds: new Set(),
    campaignIds: new Set(),
    itemIds: new Set(),
    firstUserText: '',
    lastUserText: '',
  };

  // tool_use id -> call, so results can be matched back to their call
  const callsById = new Map();

  for (const r of records) {
    if (r.sessionId && !facts.sessionId) facts.sessionId = r.sessionId;
    if (r.version) facts.claudeCodeVersion = r.version;
    if (r.gitBranch) facts.gitBranch = r.gitBranch;
    if (r.cwd) facts.cwd = r.cwd;
    if (r.timestamp) {
      if (!facts.firstTs) facts.firstTs = r.timestamp;
      facts.lastTs = r.timestamp;
    }
    if (r.message && r.message.model) facts.models.add(r.message.model);

    const content = r.message && r.message.content;

    // The transcript stores tool results as `type: "user"` records too, so a
    // raw count of user records overstates what the human typed by ~10x.
    // Count only records carrying actual text.
    if (r.type === 'user') {
      const text = extractText(content);
      if (text) {
        facts.userTurns++;
        if (!facts.firstUserText) facts.firstUserText = text;
        facts.lastUserText = text;
      }
      if (Array.isArray(content) && content.some((b) => b && b.type === 'tool_result')) {
        facts.toolResults++;
      }
    }
    // One reply spans many assistant records (one per tool call), so counting
    // records — even records carrying text — overstates replies ~7x.
    // `stop_reason: "end_turn"` marks a reply the user actually saw finish.
    if (r.type === 'assistant' && r.message && r.message.stop_reason === 'end_turn') {
      facts.assistantTurns++;
    }

    if (!Array.isArray(content)) continue;

    for (const block of content) {
      if (block.type === 'tool_use') {
        const m = REALIZE_TOOL.exec(block.name || '');
        const input = block.input || {};
        collectIds(input, facts);
        if (m) {
          const call = { name: m[1], input, ok: null, error: '', ts: r.timestamp || '', id: block.id };
          facts.realizeCalls.push(call);
          if (block.id) callsById.set(block.id, call);
        } else if (block.name) {
          // A recorded `Skill` call is already surfaced by name under "Skills
          // invoked"; counting it here as well double-reports the same call.
          if (!(block.name === 'Skill' && input.skill)) {
            facts.otherTools.set(block.name, (facts.otherTools.get(block.name) || 0) + 1);
          }
        }

        if (block.name === 'Skill' && input.skill) facts.skillsUsed.add(String(input.skill));
        const fp = input.file_path || input.path || input.notebook_path;
        const ref = typeof fp === 'string' ? knowledgeRef(fp) : '';
        if (ref) facts.knowledgeFiles.add(ref);
      }

      if (block.type === 'tool_result') {
        const call = block.tool_use_id ? callsById.get(block.tool_use_id) : null;
        if (call) {
          call.ok = !block.is_error;
          if (block.is_error) call.error = truncate(redact(extractText(block.content)), 600);
        }
      }
    }
  }

  return facts;
}

function collectIds(obj, facts) {
  const json = JSON.stringify(obj || {});
  for (const m of json.matchAll(/"account_id"\s*:\s*"?([^",}]+)"?/g)) facts.accountIds.add(m[1]);
  for (const m of json.matchAll(/"campaign_id"\s*:\s*"?([^",}]+)"?/g)) facts.campaignIds.add(m[1]);
  for (const m of json.matchAll(/"item_id"\s*:\s*"?([^",}]+)"?/g)) facts.itemIds.add(m[1]);
}

// The plugin's own guidance directories, matched against a path already made
// relative to the plugin root. Deliberately narrow: the question is "which
// guidance did it consult", not "every file it touched". A session that read the
// user's own spreadsheet has not consulted knowledge.
const KNOWLEDGE_REF =
  /^(?:(?:knowledge|os|agents)\/[^/]+\.md|skills\/[^/]+\/(?:SKILL\.md|references\/[^/]+\.md))$/i;

let cachedPluginRoot = null;

/** This plugin's checkout root — `skills/support/scripts` is three levels down. */
function pluginRoot() {
  if (cachedPluginRoot === null) cachedPluginRoot = path.resolve(__dirname, '..', '..', '..');
  return cachedPluginRoot;
}

/**
 * Label a read file as a knowledge resource, or '' if it isn't one.
 *
 * Anchored to the plugin root rather than matched anywhere in the path. Without
 * the anchor a user's own `~/Documents/os/notes.md` was reported to PS as plugin
 * guidance, which is worse than reporting nothing: the section exists so PS can
 * tell "read the right guidance and still got it wrong" from "never read it",
 * and a false entry answers that question incorrectly.
 *
 * `root` is injectable for tests; production always uses the real checkout.
 */
function knowledgeRef(p, root) {
  const baseFs = path.resolve(root === undefined ? pluginRoot() : String(root)).replace(/\\/g, '/');
  const fullFs = path.resolve(String(p)).replace(/\\/g, '/');

  // Compared case-insensitively because Windows paths routinely differ only in
  // case, but the label is sliced from the case-preserved path so `SKILL.md`
  // keeps its name.
  if (!fullFs.toLowerCase().startsWith(`${baseFs.toLowerCase()}/`)) return '';

  const rel = fullFs.slice(baseFs.length + 1);
  return KNOWLEDGE_REF.test(rel) ? rel : '';
}

function extractText(content) {
  if (typeof content === 'string') return content;
  if (!Array.isArray(content)) return '';
  return content
    .filter((b) => b && (b.type === 'text' || typeof b.text === 'string'))
    .map((b) => b.text || '')
    .join('\n')
    .trim();
}

// ---------------------------------------------------------------------------
// derived title
// ---------------------------------------------------------------------------

/**
 * A mechanical fallback title. The skill normally passes a better one via
 * --title; this exists so the bundle is never untitled.
 */
function draftTitle(facts) {
  const failed = facts.realizeCalls.filter((c) => c.ok === false);
  if (failed.length) {
    return `Realize Plugin — "${failed[0].name}" failed${
      facts.accountIds.size ? ` (account ${[...facts.accountIds][0]})` : ''
    }`;
  }
  // firstUserText is raw chat text, and this drafted title becomes the email
  // subject whenever the run had no complaint (emailSubject falls back here) —
  // so it gets the same redact-first treatment as the complaint path, and the
  // same backtick strip so the copy fence cannot close early.
  const seed = redact(String(facts.firstUserText || ''))
    .replace(/`/g, "'")
    .replace(/\s+/g, ' ')
    .trim();
  if (seed) {
    return `Realize Plugin — ${seed.slice(0, 70)}${seed.length > 70 ? '…' : ''}`;
  }
  return 'Realize Plugin — support request';
}

/**
 * The email subject, and therefore the case Subject field.
 *
 * PS asked for the user's own words here rather than a title written about them.
 * That also removes the last model-authored field in the bundle, which is the
 * direction the rest of this file already goes: the user reported the problem,
 * so the user's sentence is the most faithful summary of it available.
 *
 * Collapsed to one line — a newline in a subject header truncates it at the
 * break in most mail clients, silently dropping the rest.
 */
function emailSubject(complaint, facts) {
  // Redacted first, then shortened — the ordering the rest of this file follows,
  // because slicing can cut a credential below the length its pattern matches on.
  // Users paste error output they did not author, and this string now travels
  // into an email subject line, so a token in it would leave the machine.
  // `redact` is safe on prose by construction: the flat rule is `=`-only with a
  // length floor, so ordinary sentences are untouched.
  //
  // Backticks are stripped, not escaped: the subject is rendered inside a fenced
  // block for copying, and a stray backtick there closes the fence early and
  // swallows the rest of the file's formatting.
  const text = redact(String(complaint || ''))
    .replace(/`/g, "'")
    .replace(/\s+/g, ' ')
    .trim();
  if (!text) return draftTitle(facts);

  // Account ID earns its place in the subject: PS triages by account, and it is
  // the one thing the user's sentence reliably omits.
  const suffix = facts.accountIds.size ? ` (account ${[...facts.accountIds][0]})` : '';

  // The user's words are the point of this subject, so they get the space first.
  // account_id is an opaque API-supplied string with no length guarantee; when
  // the suffix cannot fit, drop it rather than let it push the complaint out of
  // the subject line describing that complaint. (A naive
  // `slice(0, MAX - suffix.length - 1)` goes negative here and slices from the
  // end, which silently replaced the entire complaint with an ellipsis.)
  // Section 2 still lists every account acted on, so nothing is lost.
  const room = MAX_SUBJECT_CHARS - suffix.length;
  if (room < MIN_SUBJECT_TEXT_CHARS) return clampChars(text, MAX_SUBJECT_CHARS);
  return `${clampChars(text, room)}${suffix}`;
}

function clampChars(s, max) {
  if (s.length <= max) return s;
  return `${s.slice(0, Math.max(1, max - 1)).trimEnd()}…`;
}

/**
 * The Summary section, and the text the user pastes as the email body.
 *
 * Every line is derived from the session log. There is deliberately no written
 * account of what went wrong: this bundle exists for the cases where the plugin
 * misread the request, and a narrative composed by that same plugin reproduces
 * the misreading in the one document meant to expose it. Counts, tool names and
 * file paths cannot be wrong in that way, so those are what the summary carries.
 */
function renderSummary(facts) {
  const failed = facts.realizeCalls.filter((c) => c.ok === false);
  const succeeded = facts.realizeCalls.filter((c) => c.ok === true);
  const actionNames = [...new Set(facts.realizeCalls.map((c) => c.name))];
  const L = [];

  L.push('## 1. Summary');
  L.push('');
  L.push(EMAIL_PROLOG);
  L.push('');
  L.push(`- **Session:** \`${facts.sessionId || 'unknown'}\` — ${facts.firstTs || '?'} → ${facts.lastTs || '?'}`);
  L.push(`- **Exchange:** ${facts.userTurns} message(s) from the user, ${facts.assistantTurns} repl(y/ies) from the plugin.`);
  L.push(`- **Accounts acted on:** ${setOr(facts.accountIds, 'none')}`);
  if (facts.campaignIds.size) L.push(`- **Campaigns acted on:** ${setOr(facts.campaignIds, 'none')}`);
  if (facts.itemIds.size) L.push(`- **Items acted on:** ${setOr(facts.itemIds, 'none')}`);
  L.push(
    `- **Realize actions:** ${facts.realizeCalls.length} attempted — ` +
      `${succeeded.length} succeeded, **${failed.length} failed**.`
  );
  L.push(
    `- **Realize tools used:** ${
      actionNames.length ? actionNames.map((n) => `\`${n}\``).join(', ') : 'none'
    }`
  );
  L.push(
    `- **Skills invoked:** ${
      facts.skillsUsed.size ? [...facts.skillsUsed].map((s) => `\`${s}\``).join(', ') : 'none recorded'
    }`
  );
  L.push(
    `- **Knowledge resources read:** ${
      facts.knowledgeFiles.size
        ? [...facts.knowledgeFiles].map((k) => `\`${k}\``).join(', ')
        : 'none recorded'
    }`
  );
  if (facts.otherTools.size) {
    const others = [...facts.otherTools.entries()]
      .sort((a, b) => b[1] - a[1])
      .slice(0, 8)
      .map(([n, c]) => `\`${n}\`×${c}`)
      .join(', ');
    L.push(`- **Other tools used:** ${others}`);
  }

  if (failed.length) {
    L.push('');
    L.push('**Actions that failed:**');
    L.push('');
    for (const f of failed) {
      const first = oneLine(f.error || '(no error text captured)');
      L.push(`- \`${f.name}\` — ${first.length > 160 ? `${first.slice(0, 160)}…` : first}`);
    }
  }

  L.push('');
  L.push(
    '_Every line above is extracted mechanically from the session log; nothing here is ' +
      'the plugin\'s own account of what happened. That is deliberate — if the plugin ' +
      'misread the request, a summary it wrote would repeat the misreading. The verbatim ' +
      'exchange is in the attached file under "Full transcript"._'
  );
  L.push('');
  return L.join('\n');
}

// ---------------------------------------------------------------------------
// rendering
// ---------------------------------------------------------------------------

function pluginVersion() {
  try {
    const p = path.join(__dirname, '..', '..', '..', '.claude-plugin', 'plugin.json');
    return JSON.parse(fs.readFileSync(p, 'utf8')).version || 'unknown';
  } catch {
    return 'unknown';
  }
}

function renderHeader(facts, subject, complaint, loc) {
  const failed = facts.realizeCalls.filter((c) => c.ok === false);
  const L = [];

  L.push(`# Realize Plugin — Support Bundle`);
  L.push('');
  L.push('## How to send this to Support');
  L.push('');
  L.push(`1. Start a new email to **${SUPPORT_EMAIL}**.`);
  L.push('2. **Subject** — copy this line (it becomes the case Subject):');
  L.push('');
  L.push('```');
  L.push(subject);
  L.push('```');
  L.push('');
  L.push('3. **Body** — copy all of **Section 1. Summary** below (it becomes the case Description).');
  L.push('4. **Attach this file**, so Support also has the failed actions and the full transcript.');
  L.push('');
  L.push('_Nothing has been sent anywhere. This file is on your machine and emailing it is your decision._');
  L.push('');
  L.push('---');
  L.push('');
  L.push(renderSummary(facts));
  L.push('## 2. At a glance');
  L.push('');
  L.push('| Field | Value |');
  L.push('|---|---|');
  L.push(`| Session ID | \`${facts.sessionId || 'unknown'}\` |`);
  L.push(`| Conversation start | ${facts.firstTs || 'unknown'} |`);
  L.push(`| Conversation end | ${facts.lastTs || 'unknown'} |`);
  L.push(`| Messages from the user | ${facts.userTurns} |`);
  L.push(`| Replies from the plugin | ${facts.assistantTurns} |`);
  L.push(`| Tool results exchanged | ${facts.toolResults} |`);
  // "acted on", not "involved": these come from the parameters of requests the
  // plugin sent. A session that only browsed search results without acting on
  // one correctly shows none here.
  L.push(`| Accounts acted on | ${setOr(facts.accountIds, 'none')} |`);
  L.push(`| Campaigns acted on | ${setOr(facts.campaignIds, 'none')} |`);
  L.push(`| Items acted on | ${setOr(facts.itemIds, 'none')} |`);
  L.push(`| Realize actions attempted | ${facts.realizeCalls.length} |`);
  L.push(`| **Failed actions** | **${failed.length}** |`);
  L.push(`| Plugin version | ${pluginVersion()} |`);
  L.push(`| Claude Code version | ${facts.claudeCodeVersion || 'unknown'} |`);
  L.push(`| Model(s) | ${setOr(facts.models, 'unknown')} |`);
  L.push(`| Platform | ${os.platform()} ${os.release()} |`);
  if (loc.confidence === 'guessed') {
    L.push(`| ⚠ Transcript match | best-guess (session ID unavailable; ${loc.siblings} sessions in folder) |`);
  }
  L.push('');

  L.push('## 3. What the user reported');
  L.push('');
  // Redacted, not paraphrased. The wording stays the user's own — `redact` only
  // removes credential-shaped values, and users paste error output containing
  // tokens they never looked at.
  L.push(complaint ? redact(complaint) : '_The user did not add a description when generating this bundle._');
  L.push('');

  L.push('## 4. Failed actions');
  L.push('');
  if (!failed.length) {
    L.push('_No Realize action returned an error in this session. The reported problem is likely about the **content** of an answer rather than a failed call — see the transcript below._');
  } else {
    for (const f of failed) {
      L.push(`### \`${f.name}\` — failed${f.ts ? ` at ${f.ts}` : ''}`);
      L.push('');
      L.push('Parameters sent:');
      L.push('```json');
      L.push(truncate(redact(JSON.stringify(f.input, null, 2)), 800));
      L.push('```');
      L.push('Error returned:');
      L.push('```');
      L.push(f.error || '(no error text captured)');
      L.push('```');
      L.push('');
    }
  }
  L.push('');

  L.push('## 5. Realize actions attempted, in order');
  L.push('');
  if (!facts.realizeCalls.length) {
    L.push('_No Realize actions were called in this session._');
  } else {
    L.push('| # | Action | Result | Key parameters |');
    L.push('|---|---|---|---|');
    facts.realizeCalls.forEach((c, i) => {
      const status = c.ok === false ? '❌ failed' : c.ok === true ? '✅ ok' : '– no result';
      // The key is known here, so redact structurally rather than pattern-match
      // the rendered string. Collapse whitespace too: a newline inside a value
      // (creative titles and descriptions routinely have them) would end the
      // table row early and corrupt every row below it.
      const keys = Object.entries(c.input || {})
        .slice(0, 4)
        .map(([k, v]) => {
          // Serialize objects rather than letting String() flatten them to
          // "[object Object]". Realize targeting parameters are objects, and
          // those are exactly the writes whose payload PS most needs to see —
          // a truncated {"type":"EXCLUDE","value":[...]} still carries meaning.
          const raw = v !== null && typeof v === 'object' ? JSON.stringify(v) : String(v);
          const safe = oneLine(`${k}=${redactValue(k, raw)}`);
          return safe.length > 48 ? `${safe.slice(0, 48)}…` : safe;
        })
        .join(', ');
      L.push(`| ${i + 1} | \`${c.name}\` | ${status} | ${escapePipes(keys) || '–'} |`);
    });
  }
  L.push('');
  L.push('---');
  L.push('');
  return L.join('\n');
}

function escapePipes(s) {
  return String(s).replace(/\|/g, '\\|');
}

function setOr(set, fallback) {
  return set && set.size ? [...set].join(', ') : `_${fallback}_`;
}

function renderTranscript(records) {
  const L = [];
  L.push('## 6. Full transcript');
  L.push('');
  L.push('_Credentials are redacted. Long tool outputs are truncated — Realize responses far less aggressively than other output, since they are the evidence. Account, campaign, and item IDs are preserved so the issue can be reproduced._');
  L.push('');

  // tool_use id -> whether it was a Realize call, so the result can be capped
  // by how diagnostic it is rather than uniformly.
  const realizeCallIds = new Set();
  for (const r of records) {
    const c = r.message && r.message.content;
    if (!Array.isArray(c)) continue;
    for (const block of c) {
      if (block.type === 'tool_use' && block.id && REALIZE_TOOL.test(block.name || '')) {
        realizeCallIds.add(block.id);
      }
    }
  }

  let n = 0;
  for (const r of records) {
    if (r.type !== 'user' && r.type !== 'assistant') continue;
    const content = r.message && r.message.content;
    const side = r.isSidechain ? ' _(background task)_' : '';
    const ts = r.timestamp ? ` · ${r.timestamp}` : '';

    if (r.type === 'user') {
      const text = extractText(content);
      const results = Array.isArray(content) ? content.filter((b) => b.type === 'tool_result') : [];
      if (text) {
        n++;
        L.push(`### 👤 User${side}${ts}`);
        L.push('');
        L.push(quote(truncate(redact(text), 4000)));
        L.push('');
      }
      for (const res of results) {
        // A report CSV is usually the whole case — "the CPA here disagrees with
        // the UI" is answered by the rows that produced it. At the old uniform
        // 2,000 chars only ~13 of 250 rows survived, so the disputed row was
        // typically gone. Bulk output from other tools stays tightly capped.
        const isRealize = res.tool_use_id && realizeCallIds.has(res.tool_use_id);
        const cap = isRealize ? MAX_REALIZE_RESULT_CHARS : MAX_RESULT_CHARS;
        const body = truncate(redact(extractText(res.content) || JSON.stringify(res.content || '')), cap);
        L.push(`<details><summary>${res.is_error ? '❌ ' : ''}Tool result${res.is_error ? ' (error)' : ''}</summary>`);
        L.push('');
        L.push('```');
        L.push(body);
        L.push('```');
        L.push('');
        L.push('</details>');
        L.push('');
      }
      continue;
    }

    if (!Array.isArray(content)) continue;
    const header = `### 🤖 Assistant${side}${ts}`;
    let wrote = false;

    for (const block of content) {
      if (block.type === 'text' && block.text && block.text.trim()) {
        if (!wrote) { L.push(header); L.push(''); wrote = true; }
        L.push(truncate(redact(block.text), 6000));
        L.push('');
      } else if (block.type === 'thinking' && block.thinking) {
        if (!wrote) { L.push(header); L.push(''); wrote = true; }
        L.push('<details><summary>Internal reasoning (why the plugin did this)</summary>');
        L.push('');
        L.push('```');
        L.push(truncate(redact(block.thinking), MAX_THINKING_CHARS));
        L.push('```');
        L.push('');
        L.push('</details>');
        L.push('');
      } else if (block.type === 'tool_use') {
        if (!wrote) { L.push(header); L.push(''); wrote = true; }
        const m = REALIZE_TOOL.exec(block.name || '');
        const label = m ? `Realize action: \`${m[1]}\`` : `Tool: \`${block.name}\``;
        L.push(`**${label}**`);
        L.push('');
        L.push('```json');
        L.push(truncate(redact(JSON.stringify(block.input || {}, null, 2)), 1200));
        L.push('```');
        L.push('');
      }
    }
  }

  if (!n) L.push('_No user messages were found in this transcript._');
  return L.join('\n');
}

function quote(s) {
  return s.split('\n').map((l) => `> ${l}`).join('\n');
}

// ---------------------------------------------------------------------------
// output location
// ---------------------------------------------------------------------------

/**
 * Compare paths on a single canonical form.
 *
 * Windows mixes separators freely — `path.join` yields backslashes while a
 * hand-written `--out` usually has forward slashes — and a raw string compare
 * silently fails across the two, so the cloud-sync warning would not fire on
 * exactly the folder it exists to catch.
 */
function normalizePath(p) {
  return path.resolve(String(p)).replace(/\\/g, '/').toLowerCase();
}

/** Folders synced to a cloud provider, which would upload the bundle on write. */
function cloudSyncedRoots() {
  return [process.env.OneDrive, process.env.OneDriveCommercial, process.env.OneDriveConsumer]
    .filter(Boolean)
    .map(normalizePath);
}

function isCloudSynced(p) {
  const target = normalizePath(p);
  return cloudSyncedRoots().some((root) => target === root || target.startsWith(`${root}/`));
}

/**
 * Nearest enclosing git working tree, or null.
 *
 * The bundle holds customer campaign data. Landing it inside a repo — above all
 * this one, which is public — puts it one `git add -A` away from being
 * published. SKILL.md tells the model not to do that, but instructions are not
 * a control; this is.
 */
function findGitRoot(startDir) {
  let dir = path.resolve(startDir);
  for (;;) {
    if (fs.existsSync(path.join(dir, '.git'))) return dir;
    const parent = path.dirname(dir);
    if (parent === dir) return null;
    dir = parent;
  }
}

/**
 * Pick a destination. Desktop is the most findable place for a user who has to
 * attach the file to an email, but it is often OneDrive-redirected on
 * enterprise Windows (silently syncing customer data to the cloud), and a home
 * directory is sometimes itself a git repo under a dotfiles setup. Prefer a
 * folder that is neither; fall back to the OS temp directory, which is always
 * safe on both counts, rather than knowingly choosing a bad one.
 */
function defaultOutPath(sessionId) {
  const home = os.homedir();
  const stamp = new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-');
  const name = `realize-support-${stamp}-${String(sessionId).slice(0, 8)}.md`;

  const candidates = ['Desktop', 'Downloads']
    .map((d) => path.join(home, d))
    .concat([home])
    .filter((d) => fs.existsSync(d));

  const safe = candidates.find((d) => !isCloudSynced(d) && !findGitRoot(d));
  if (safe) return path.join(safe, name);

  const unsynced = candidates.find((d) => !findGitRoot(d));
  if (unsynced) return path.join(unsynced, name);

  return path.join(os.tmpdir(), name);
}

/** The folders a bundle may be written to: the ones defaultOutPath() picks from. */
function allowedOutDirs() {
  const home = os.homedir();
  return [path.join(home, 'Desktop'), path.join(home, 'Downloads'), home, os.tmpdir()];
}

/** A person at a terminal — never the case for a command the model runs. */
function isInteractive() {
  return Boolean(process.stdin.isTTY && process.stdout.isTTY);
}

/**
 * Limits on where a bundle may be written, checked before anything else runs.
 *
 * The skill pre-approves this script, and a permission rule can't pin every
 * argument, so the script itself refuses what would turn "write a support
 * bundle" into "write any file": `--out` must be a new `.md` file directly
 * inside one of the default bundle folders and not a symlink, and `--force` /
 * `--allow-git` need an interactive terminal. Without the `.md` rule,
 * `--out ~/.zshrc` would plant the transcript in a file the shell executes.
 */
function vetWriteArgs(args, { interactive = isInteractive(), outDirs = allowedOutDirs() } = {}) {
  if ((args.force || args.allowGit) && !interactive) {
    throw new Error('--force and --allow-git only work when a person runs this script in a terminal.');
  }
  if (!args.out) return;
  const out = path.resolve(args.out);
  const name = path.basename(out);
  if (path.extname(name).toLowerCase() !== '.md' || name.startsWith('.')) {
    throw new Error(`${args.out}: the bundle must be a .md file.`);
  }
  let dir;
  try {
    dir = fs.realpathSync(path.dirname(out));
  } catch {
    throw new Error(`${path.dirname(out)} does not exist.`);
  }
  const allowed = outDirs.some((d) => {
    try {
      return fs.realpathSync(d) === dir;
    } catch {
      return false;
    }
  });
  if (!allowed) {
    throw new Error(`${args.out}: write the bundle directly into your Desktop, Downloads or home folder.`);
  }
  let st = null;
  try {
    st = fs.lstatSync(out);
  } catch {
    // Doesn't exist yet — the normal case.
  }
  if (st && st.isSymbolicLink()) {
    throw new Error(`${args.out} is a symbolic link; choose a different file name.`);
  }
}

/** Add a numeric suffix until the name is free. */
function nextAvailablePath(p) {
  const dir = path.dirname(p);
  const ext = path.extname(p);
  const base = path.basename(p, ext);
  for (let i = 1; i < 1000; i++) {
    const candidate = path.join(dir, `${base}-${i}${ext}`);
    if (!fs.existsSync(candidate)) return candidate;
  }
  throw new Error(`could not find a free filename next to ${p}.`);
}

// ---------------------------------------------------------------------------
// main
// ---------------------------------------------------------------------------

function main() {
  pruneInputDir();
  const args = parseArgs(process.argv.slice(2));
  vetWriteArgs(args);
  const loc = findTranscript();

  if (loc.error) {
    console.error(`ERROR: ${loc.error}`);
    process.exit(1);
  }

  const records = parseTranscript(loc.file);
  const facts = analyze(records);
  const failed = facts.realizeCalls.filter((c) => c.ok === false);

  // PS asked for the user's own words as the subject, since case intake copies
  // the subject into the case Subject field. When the user described the problem
  // that description *is* the most faithful title available, and preferring it
  // removes the last model-authored field in the bundle. `--title` stays as the
  // fallback for a run with no description.
  const subject = args.complaint
    ? emailSubject(args.complaint, facts)
    : args.title || draftTitle(facts);

  // Rendered once and reused, so --preview does not pay for a full render and
  // then throw it away.
  const body = truncateToBytes(
    `${renderHeader(facts, subject, args.complaint, loc)}\n${renderTranscript(records)}\n`,
    MAX_BUNDLE_BYTES,
    `… [bundle truncated at ${Math.round(MAX_BUNDLE_BYTES / 1024 / 1024)} MB to stay email-attachable]`
  );
  let outPath = args.out || defaultOutPath(facts.sessionId);

  // A bundle inside a repo is one `git add -A` from being published, and this
  // plugin's own repo is public. Refuse an explicit --out that lands in a work
  // tree; the auto-chosen default already avoids them.
  const gitRoot = findGitRoot(path.dirname(path.resolve(outPath)));
  if (gitRoot && !args.allowGit) {
    throw new Error(
      // Deliberately does not name the override flag. The model that hits this
      // error reads the message as its next instruction, and what it should do
      // is pick a different path — not reach for a bypass on the one control
      // standing between customer data and a public repository.
      `${outPath} is inside the git repository at ${gitRoot}. This bundle contains ` +
        `customer campaign data and must never be committed. Write it somewhere ` +
        `outside the repository — your Desktop is the default and is already safe.`
    );
  }

  if (args.preview) {
    console.log('PREVIEW — nothing has been written yet.');
    console.log('');
    console.log(`Transcript      : ${loc.file}`);
    console.log(`Match confidence: ${loc.confidence}${loc.confidence === 'guessed' ? ` (⚠ ${loc.siblings} sessions in folder)` : ''}`);
    console.log(`Session ID      : ${facts.sessionId}`);
    console.log(`Window          : ${facts.firstTs || '?'} → ${facts.lastTs || '?'}`);
    console.log(`Messages        : ${facts.userTurns} from you / ${facts.assistantTurns} replies`);
    console.log(`Tool results    : ${facts.toolResults}`);
    console.log(`Realize actions : ${facts.realizeCalls.length} (${failed.length} failed)`);
    console.log(`Accounts        : ${facts.accountIds.size ? [...facts.accountIds].join(', ') : '(none seen)'}`);
    console.log(`Campaigns       : ${facts.campaignIds.size ? [...facts.campaignIds].join(', ') : '(none seen)'}`);
    console.log(`Local paths     : ${facts.cwd || '(none)'}`);
    console.log(`Skills invoked  : ${facts.skillsUsed.size ? [...facts.skillsUsed].join(', ') : '(none recorded)'}`);
    console.log(`Knowledge read  : ${facts.knowledgeFiles.size ? [...facts.knowledgeFiles].join(', ') : '(none recorded)'}`);
    console.log(`Email subject   : ${subject}`);
    console.log(`Would write to  : ${outPath}`);
    console.log(`Size            : ${Math.round(Buffer.byteLength(body, 'utf8') / 1024)} KB`);
    if (isCloudSynced(outPath)) {
      console.log('');
      console.log('WARNING: that folder is synced to OneDrive, so the file would upload to the');
      console.log('         cloud on write. Pass --out <path> to choose a local-only folder.');
    }
    return;
  }

  // Never clobber an existing file without being told to. The default path is
  // timestamped so collisions are unlikely, but --out is user-supplied and a
  // silent overwrite destroys whatever was there.
  if (fs.existsSync(outPath) && !args.force) {
    if (args.out) {
      // Explicit path: the caller named this file, so silently replacing it
      // would destroy something they chose.
      throw new Error(
        `${outPath} already exists. Choose a different --out file name.`
      );
    }
    // Auto-generated name (second-resolution timestamp): two runs in the same
    // second are a collision, not a conflict. Step aside rather than fail.
    outPath = nextAvailablePath(outPath);
  }

  fs.mkdirSync(path.dirname(outPath), { recursive: true });
  // 'wx' fails if anything — including a symlink planted after the checks
  // above — already sits at that path. Only an interactive --force replaces.
  fs.writeFileSync(outPath, body, { encoding: 'utf8', flag: args.force ? 'w' : 'wx' });

  // The complaint now lives in the bundle; the scratch copy has no purpose and
  // shouldn't outlive it. Done here rather than by the model, because a cleanup
  // command the user declines leaves their description on disk.
  // A failed delete is a warning, not a failed bundle: the file the user needs
  // exists, and reporting failure would send them to retry for nothing.
  for (const f of args.inputFiles) {
    try {
      fs.rmSync(f, { force: true });
    } catch (err) {
      console.error(`WARNING: bundle written; could not delete ${f} (${err.message}). Delete it by hand.`);
    }
  }

  console.log('WROTE');
  console.log(`Path        : ${outPath}`);
  console.log(`Size        : ${Math.round(Buffer.byteLength(body, 'utf8') / 1024)} KB`);
  console.log(`Subject     : ${subject}`);
  console.log(`Send to     : ${SUPPORT_EMAIL}`);
  console.log('Email body  : copy Section 1. Summary from the file');
  if (isCloudSynced(outPath)) {
    console.log('');
    console.log('NOTE: this folder is synced to OneDrive, so a copy has gone to the cloud.');
  }
}

// Exported so the redaction and parsing rules can be exercised directly by
// tests rather than re-typed into a test harness, where an escaping difference
// would prove nothing about this file.
module.exports = {
  redact,
  readTextFile,
  pruneInputDir,
  INPUT_DIR,
  vetWriteArgs,
  allowedOutDirs,
  redactValue,
  oneLine,
  parseArgs,
  analyze,
  truncateToBytes,
  REALIZE_TOOL,
  draftTitle,
  emailSubject,
  renderSummary,
  knowledgeRef,
  EMAIL_PROLOG,
  MAX_SUBJECT_CHARS,
  isCloudSynced,
  normalizePath,
  findGitRoot,
  defaultOutPath,
  nextAvailablePath,
  renderTranscript,
  MAX_RESULT_CHARS,
  MAX_REALIZE_RESULT_CHARS,
};

// Guarded with a conditional rather than a top-level `return`, which is valid
// in CommonJS only and would become a syntax error if this file is ever ESM.
if (require.main === module) {
  runCli();
}

function runCli() {
  try {
    main();
  } catch (err) {
    // The audience is a non-technical user already having a bad day; a raw
    // stack trace exposing internal paths is not an acceptable failure mode.
    console.error('ERROR: could not build the support bundle.');
    console.error(`Reason: ${err && err.message ? err.message : String(err)}`);
    console.error('');
    console.error(`If this keeps happening, email ${SUPPORT_EMAIL} and mention this message.`);
    process.exit(1);
  }
}

SHA-256: 828a33d4a3634baf1e70d094e0c4c06129146acd3d0a51334173ae65aeb120f2