#!/usr/bin/env bun
/**
 * Generate SKILL.md files from .tmpl templates.
 *
 * Pipeline:
 *   read .tmpl → find {{PLACEHOLDERS}} → resolve from source → format → write .md
 *
 * Supports --dry-run: generate to memory, exit 1 if different from committed file.
 * Used by skill:check and CI freshness checks.
 */

import { discoverTemplates, discoverSectionTemplates } from './discover-skills';
import { writeLlmsTxt } from './gen-llms-txt';
import * as fs from 'fs';
import * as path from 'path';
import type { Host, TemplateContext } from './resolvers/types';
import { HOST_PATHS } from './resolvers/types';
import { RESOLVERS } from './resolvers/index';
import { ALL_HOST_CONFIGS, ALL_HOST_NAMES, resolveHostArg, getHostConfig } from '../hosts/index';
import type { HostConfig } from './host-config';

const ROOT = path.resolve(import.meta.dir, '..');
const DRY_RUN = process.argv.includes('--dry-run');

// ─── GBrain Detection Override ──────────────────────────────
// When --respect-detection is passed, read ~/.gstack/gbrain-detection.json
// and un-suppress GBRAIN_CONTEXT_LOAD + GBRAIN_SAVE_RESULTS for hosts that
// statically suppress them (claude, codex, slate, factory, opencode,
// openclaw, cursor, kiro). Detection state is produced by
// bin/gstack-gbrain-detect and persisted by `gstack-config gbrain-refresh`
// or by ./setup.
//
// Default (no flag): static suppressedResolvers honored as-is. Used by
// `bun run gen:skill-docs` (CI + canonical checked-in SKILL.md files) so
// the committed output is reproducible regardless of any developer's
// local gbrain installation state. Use `bun run gen:skill-docs:user`
// (which adds --respect-detection) for user-local installs.
const RESPECT_DETECTION = process.argv.includes('--respect-detection');

function loadGbrainOverride(): { detected: boolean } {
  if (!RESPECT_DETECTION) return { detected: false };
  const stateDir = process.env.GSTACK_HOME || path.join(process.env.HOME || '', '.gstack');
  const detectionPath = path.join(stateDir, 'gbrain-detection.json');
  try {
    const json = JSON.parse(fs.readFileSync(detectionPath, 'utf-8')) as { gbrain_local_status?: string };
    // "timeout" = slow-but-healthy engine (#1964); "thin-client" = remote-HTTP
    // MCP brain with no local engine by design (#2051); "engine-locked" = same
    // class (#2456): PGLite is single-writer, so a live `gbrain serve` (e.g.
    // an MCP server) owns the embedded DB — gbrain is installed and healthy,
    // a legitimate holder has the lock, so a transient lock must not silently
    // strip brain blocks from every SKILL.md. All usable — same treatment as
    // "ok", matching gstack-gbrain-detect --is-ok.
    const USABLE = ['ok', 'timeout', 'thin-client', 'engine-locked'];
    return { detected: USABLE.includes(json.gbrain_local_status ?? '') };
  } catch {
    return { detected: false };
  }
}

const GBRAIN_OVERRIDE = loadGbrainOverride();

/**
 * Compute effective suppressedResolvers for a host, applying the gbrain
 * detection override when enabled. When the override fires, GBRAIN_*
 * resolvers are removed from the suppression set so they render in the
 * generated SKILL.md.
 */
function effectiveSuppressedResolvers(hostConfig: HostConfig): Set<string> {
  let list = hostConfig.suppressedResolvers || [];
  if (GBRAIN_OVERRIDE.detected) {
    list = list.filter(r => r !== 'GBRAIN_CONTEXT_LOAD' && r !== 'GBRAIN_SAVE_RESULTS');
  }
  return new Set(list);
}

// ─── Host Detection (config-driven) ─────────────────────────

const HOST_ARG = process.argv.find(a => a.startsWith('--host'));
type HostArg = Host | 'all';
const HOST_ARG_VAL: HostArg = (() => {
  if (!HOST_ARG) return 'claude';
  const val = HOST_ARG.includes('=') ? HOST_ARG.split('=')[1] : process.argv[process.argv.indexOf(HOST_ARG) + 1];
  if (val === 'all') return 'all';
  try {
    return resolveHostArg(val) as Host;
  } catch {
    throw new Error(`Unknown host: ${val}. Use ${ALL_HOST_NAMES.join(', ')}, or all.`);
  }
})();

// For single-host mode, HOST is the host. For --host all, it's set per iteration below.
let HOST: Host = HOST_ARG_VAL === 'all' ? 'claude' : HOST_ARG_VAL;

// ─── Model Overlay Selection ────────────────────────────────
// --model is explicit. Without it, each host uses HostConfig.defaultModel.
// Host defaults are generation fallbacks, not claims that host === model.
// Missing overlay file → empty string (graceful).
import { ALL_MODEL_NAMES, resolveModel, type Model } from './models';
const MODEL_ARG = process.argv.find(a => a.startsWith('--model'));
const MODEL_ARG_VAL: Model | null = (() => {
  if (!MODEL_ARG) return null;
  const val = MODEL_ARG.includes('=') ? MODEL_ARG.split('=')[1] : process.argv[process.argv.indexOf(MODEL_ARG) + 1];
  const resolved = resolveModel(val);
  if (!resolved) {
    throw new Error(`Unknown model: ${val}. Use ${ALL_MODEL_NAMES.join(', ')}, or a family variant (e.g., claude-opus-4-7, gpt-5.4-mini, o3).`);
  }
  return resolved;
})();

function generationModelForHost(host: Host): Model {
  return MODEL_ARG_VAL ?? getHostConfig(host).defaultModel;
}

// ─── Catalog Mode (v1.45.0.0 T4) ────────────────────────────
// 'trim' (default): shorten frontmatter description to lead sentence and
// move routing/voice prose into a "## When to invoke" body section.
// 'full': legacy v1.44 behavior — full description stays in frontmatter.
const CATALOG_MODE_ARG = process.argv.find(a => a.startsWith('--catalog-mode'));
const CATALOG_MODE: 'trim' | 'full' = (() => {
  if (!CATALOG_MODE_ARG) return 'trim';
  const val = CATALOG_MODE_ARG.includes('=')
    ? CATALOG_MODE_ARG.split('=')[1]
    : process.argv[process.argv.indexOf(CATALOG_MODE_ARG) + 1];
  if (val !== 'trim' && val !== 'full') {
    throw new Error(`Unknown catalog mode: ${val}. Use 'trim' (default) or 'full'.`);
  }
  return val;
})();

// ─── Explain-level Overlay ──────────────────────────────────
// --explain-level=terse compresses preamble prose (writing-style, completeness,
// confusion-protocol, context-health) to a single pointer line at gen time.
// Default keeps the runtime-conditional behavior (sections render unconditionally,
// the model skips them when EXPLAIN_LEVEL: terse appears in the preamble echo).
// Opt-in via the build flag so most users get the runtime-flexible default.
const EXPLAIN_LEVEL_ARG = process.argv.find(a => a.startsWith('--explain-level'));
const EXPLAIN_LEVEL: 'default' | 'terse' = (() => {
  if (!EXPLAIN_LEVEL_ARG) return 'default';
  const val = EXPLAIN_LEVEL_ARG.includes('=')
    ? EXPLAIN_LEVEL_ARG.split('=')[1]
    : process.argv[process.argv.indexOf(EXPLAIN_LEVEL_ARG) + 1];
  if (val !== 'default' && val !== 'terse') {
    throw new Error(`Unknown explain level: ${val}. Use 'default' or 'terse'.`);
  }
  return val;
})();

// ─── Out-dir (dev workspace render isolation) ───────────────
// --out-dir <abs-dir> redirects ALL generated output (Claude SKILL.md +
// sections, external-host trees like .agents/.factory, openclaw docs,
// gstack/llms.txt) into a separate (untracked) directory instead of writing
// in place. OUTPUTS ONLY: inputs (templates, sections/, host configs) are
// always read from ROOT. For the Claude host it ALSO rewrites the literal
// section-base path (`~/.claude/skills/gstack/<skill>/sections/`) inside
// generated content so section Reads resolve to the rendered copy — that
// rewrite stays Claude-only (external hosts have their own path grammar).
// Consumers: bin/dev-setup (renders the gbrain `:user` variant for a
// Conductor workspace — byte-compat pinned by gen-skill-docs-out-dir tests)
// and the former TREE_MUTATING tests, which render into a mkdtemp instead
// of mutating the live tree. Default (unset) = in-place, unchanged.
const OUT_DIR_ARG = process.argv.find(a => a.startsWith('--out-dir'));
const OUT_DIR: string | null = (() => {
  if (!OUT_DIR_ARG) return null;
  const val = OUT_DIR_ARG.includes('=')
    ? OUT_DIR_ARG.split('=')[1]
    : process.argv[process.argv.indexOf(OUT_DIR_ARG) + 1];
  if (!val) throw new Error('--out-dir requires a directory path');
  return path.resolve(val);
})();

/**
 * When rendering to an out-dir, repoint the literal section-base path at the
 * out-dir so section Reads resolve to the rendered copy, not the global install.
 * Surgical: ONLY paths containing `/sections/` are rewritten — bin/, browse/,
 * docs/ references keep pointing at `~/.claude/skills/gstack` (the global
 * install, which still works). No-op when --out-dir is unset.
 */
function rewriteSectionBase(content: string): string {
  if (!OUT_DIR) return content;
  return content.replace(
    /~\/\.claude\/skills\/gstack\/([^\s)`"'*]+\/sections\/)/g,
    `${OUT_DIR}/$1`,
  );
}

// HostPaths, HOST_PATHS, and TemplateContext imported from ./resolvers/types (line 7-8)
// Design constants (AI_SLOP_BLACKLIST, OPENAI_HARD_REJECTIONS, OPENAI_LITMUS_CHECKS)
// live in ./resolvers/constants and are consumed by resolvers directly.

// ─── External Host Helpers ───────────────────────────────────

// Canonical implementation (the codex-helpers.ts shadow copy was deleted —
// it was imported, immediately shadowed by this declaration, and stale)
// Accepts optional frontmatter name to support directory/invocation name divergence
function externalSkillName(skillDir: string, frontmatterName?: string): string {
  // Root skill (skillDir === '' or '.') always maps to 'gstack' regardless of frontmatter
  if (skillDir === '.' || skillDir === '') return 'gstack';
  // Use frontmatter name when it differs from directory name (e.g., run-tests/ with name: test)
  const baseName = frontmatterName && frontmatterName !== skillDir ? frontmatterName : skillDir;
  // Don't double-prefix: gstack-upgrade → gstack-upgrade (not gstack-gstack-upgrade)
  if (baseName.startsWith('gstack-')) return baseName;
  return `gstack-${baseName}`;
}

function extractNameAndDescription(content: string): { name: string; description: string } {
  const fmStart = content.indexOf('---\n');
  if (fmStart !== 0) return { name: '', description: '' };
  const fmEnd = content.indexOf('\n---', fmStart + 4);
  if (fmEnd === -1) return { name: '', description: '' };

  const frontmatter = content.slice(fmStart + 4, fmEnd);
  const nameMatch = frontmatter.match(/^name:\s*(.+)$/m);
  const name = nameMatch ? nameMatch[1].trim() : '';

  let description = '';
  const lines = frontmatter.split('\n');
  let inDescription = false;
  const descLines: string[] = [];
  for (const line of lines) {
    if (line.match(/^description:\s*\|?\s*$/)) {
      inDescription = true;
      continue;
    }
    if (line.match(/^description:\s*\S/)) {
      description = line.replace(/^description:\s*/, '').trim();
      break;
    }
    if (inDescription) {
      if (line === '' || line.match(/^\s/)) {
        descLines.push(line.replace(/^  /, ''));
      } else {
        break;
      }
    }
  }
  if (descLines.length > 0) {
    description = descLines.join('\n').trim();
  }

  return { name, description };
}

// ─── Voice Trigger Processing ────────────────────────────────

/**
 * Extract voice-triggers YAML list from frontmatter.
 * Returns an array of trigger strings, or [] if no voice-triggers field.
 */
function extractVoiceTriggers(content: string): string[] {
  const fmStart = content.indexOf('---\n');
  if (fmStart !== 0) return [];
  const fmEnd = content.indexOf('\n---', fmStart + 4);
  if (fmEnd === -1) return [];
  const frontmatter = content.slice(fmStart + 4, fmEnd);

  const triggers: string[] = [];
  let inVoice = false;
  for (const line of frontmatter.split('\n')) {
    if (/^voice-triggers:/.test(line)) { inVoice = true; continue; }
    if (inVoice) {
      const m = line.match(/^\s+-\s+"(.+)"$/);
      if (m) triggers.push(m[1]);
      else if (!/^\s/.test(line)) break;
    }
  }
  return triggers;
}

/**
 * Preprocess voice triggers: fold voice-triggers YAML field into description,
 * then strip the field from frontmatter. Must run BEFORE transformFrontmatter
 * and extractNameAndDescription so all hosts see the updated description.
 */
function processVoiceTriggers(content: string): string {
  const triggers = extractVoiceTriggers(content);
  if (triggers.length === 0) return content;

  // Strip voice-triggers block from frontmatter
  content = content.replace(/^voice-triggers:\n(?:\s+-\s+"[^"]*"\n?)*/m, '');

  // Get current description (after stripping voice-triggers, so it's clean)
  const { description } = extractNameAndDescription(content);
  if (!description) return content;

  // Build new description with voice triggers appended
  const voiceLine = `Voice triggers (speech-to-text aliases): ${triggers.map(t => `"${t}"`).join(', ')}.`;
  const newDescription = description + '\n' + voiceLine;

  // Replace old indented description with new in frontmatter
  const oldIndented = description.split('\n').map(l => `  ${l}`).join('\n');
  const newIndented = newDescription.split('\n').map(l => `  ${l}`).join('\n');
  content = content.replace(oldIndented, newIndented);

  return content;
}

// Export for testing
export { extractVoiceTriggers, processVoiceTriggers };

// ─── Catalog Trim (v1.45.0.0 T4) ─────────────────────────────
//
// Frontmatter `description:` blocks today pack: a one-line outcome, "Use when
// asked to..." voice triggers, "Proactively..." routing guidance, and a
// "(gstack)" tag. This pile is the always-loaded catalog surface — every
// session pays for the full text. The catalog trim splits the description
// into a one-line catalog entry (lead sentence + "(gstack)") that stays in
// the frontmatter, and a "## When to invoke" body section that holds the
// routing/voice triggers prose for in-skill discovery.
//
// Opt-out: `--catalog-mode=full` keeps v1.44 behavior (no trim, full
// description in frontmatter). Use when debugging routing regressions or
// when shipping skills to hosts that depend on the legacy fat catalog.

export interface CatalogParts {
  lead: string;            // First sentence — kept in catalog
  routingProse: string;    // "Use when asked to...", "Proactively..." paragraphs
  voiceLine: string | null; // "Voice triggers (speech-to-text aliases): ..." line if present
  hasGstackTag: boolean;
}

export function splitCatalogDescription(description: string): CatalogParts {
  // Voice triggers line (folded in by processVoiceTriggers earlier)
  const voiceMatch = description.match(/Voice triggers \(speech-to-text aliases\):[^\n]+/);
  const voiceLine = voiceMatch ? voiceMatch[0] : null;
  let working = voiceLine ? description.replace(voiceLine, '').trim() : description.trim();

  const hasGstackTag = /\(gstack\)/.test(working);
  if (hasGstackTag) working = working.replace(/\(gstack\)/, '').trim();

  // Lead = first sentence, ending at the first `.`/`!`/`?` that is followed by
  // whitespace or end-of-text. Terminator chars NOT followed by whitespace/end
  // (embedded periods in "TODOS.md", URLs, "v1.45.0.0") are consumed by the
  // second alternative `[.!?](?!\s|$)` and do NOT end the sentence. The two
  // alternatives are disjoint character classes, so there is no ambiguity and
  // no catastrophic-backtracking risk. If no terminator-followed-by-boundary
  // exists at all, we fall back to a 20-word cut below.
  // First normalize to single-line for sentence detection, then back out.
  const collapsed = working.replace(/\s+/g, ' ').trim();
  const sentenceMatch = collapsed.match(/^((?:[^.!?]|[.!?](?!\s|$))*[.!?])(?:\s|$)/);
  // sentenceLead is the FULL first sentence (no truncation). We compute routing
  // from this position, then optionally truncate the displayed lead afterwards.
  // Truncating first then computing routing was the v1.45.0.0 bug — when the
  // first sentence exceeded 200 chars, the routing extraction would lose the
  // entire tail of the description (design-consultation's "Use when..."
  // routing prose silently dropped).
  const sentenceLead = sentenceMatch ? sentenceMatch[1].trim() : collapsed.split(/\s/).slice(0, 20).join(' ');

  // Routing prose: everything AFTER the first sentence boundary in the collapsed view.
  const leadInCollapsed = collapsed.indexOf(sentenceLead);
  const routingCollapsed = leadInCollapsed >= 0
    ? collapsed.slice(leadInCollapsed + sentenceLead.length).trim()
    : '';

  // Now produce the displayed lead — truncated if too long. The original
  // sentenceLead is preserved for routing extraction below.
  let lead = sentenceLead;
  if (lead.length > 200) {
    const trunc = lead.slice(0, 197);
    const lastSpace = trunc.lastIndexOf(' ');
    lead = (lastSpace > 60 ? trunc.slice(0, lastSpace) : trunc) + '...';
  }
  // Restore line breaks for routing prose by mapping back to original layout.
  // Use original whitespace structure where possible; fall back to collapsed.
  // Anchor recovery on sentenceLead (the untruncated first sentence) — not
  // `lead` (which may have a "..." suffix and won't substring-match `working`).
  let routingProse = routingCollapsed;
  const collapsedLeadIdx = working.replace(/\s+/g, ' ').indexOf(sentenceLead);
  if (collapsedLeadIdx >= 0) {
    let consumed = 0;
    let cut = 0;
    for (let i = 0; i < working.length && consumed < collapsedLeadIdx + sentenceLead.length; i++) {
      if (/\s/.test(working[i])) {
        if (i === 0 || /\s/.test(working[i - 1])) continue;
        consumed += 1;
      } else {
        consumed += 1;
      }
      cut = i + 1;
    }
    const tail = working.slice(cut).trim();
    if (tail.length > 0) routingProse = tail;
  }

  return { lead, routingProse, voiceLine, hasGstackTag };
}

/** Build the catalog-trimmed `description:` block. */
export function buildTrimmedDescription(parts: CatalogParts): string {
  const lead = parts.lead.trim();
  const suffix = parts.hasGstackTag ? ' (gstack)' : '';
  return `${lead}${suffix}`;
}

/** Build the body section that holds the routing/voice prose. */
export function buildWhenToInvokeSection(parts: CatalogParts): string {
  const lines: string[] = ['## When to invoke this skill', ''];
  if (parts.routingProse) {
    lines.push(parts.routingProse);
    lines.push('');
  }
  if (parts.voiceLine) {
    lines.push(parts.voiceLine);
    lines.push('');
  }
  return lines.join('\n');
}

/**
 * Render a string as a YAML inline scalar value (the text after `key: `),
 * quoting only when a plain scalar would be invalid or ambiguous.
 *
 * The bug this guards (#1778): a description like "Ship workflow: detect..."
 * emitted as a plain scalar has an interior ": " that a strict YAML parser
 * (Codex/OpenAI skill loading) reads as a nested mapping and rejects with
 * "mapping values are not allowed in this context". When quoting is needed we
 * fall back to JSON.stringify, which produces a double-quoted scalar that YAML
 * accepts verbatim (YAML is a superset of JSON for flow scalars). Strings that
 * are already valid plain scalars pass through unchanged to keep regen diffs small.
 */
export function toYamlInlineScalar(s: string): string {
  const needsQuote =
    s.length === 0 ||
    s !== s.trim() ||                       // leading/trailing whitespace
    /:(\s|$)/.test(s) ||                    // "foo: bar" / trailing colon → mapping ambiguity
    /\s#/.test(s) ||                        // " #" → inline comment
    /\.\.\./.test(s) ||                     // "..." → document-end marker; strict parsers reject mid-scalar (catalog-trim truncation appends it)
    /^[\s>|&*!%@`"'#,\[\]{}?-]/.test(s);    // leading YAML indicator char
  return needsQuote ? JSON.stringify(s) : s;
}

/**
 * Apply catalog trim to a SKILL.md body:
 *  - shorten frontmatter `description:` to lead + (gstack)
 *  - insert "## When to invoke" body section AFTER the generated header
 *    (so it lands near the top of body content, where routing guidance
 *    belongs)
 *
 * Returns the rewritten content plus the extracted parts.
 */
export function applyCatalogTrim(content: string, skillName: string): { content: string; parts: CatalogParts } | null {
  // Locate description block in frontmatter
  if (!content.startsWith('---\n')) return null;
  const fmEnd = content.indexOf('\n---', 4);
  if (fmEnd === -1) return null;
  const frontmatter = content.slice(4, fmEnd);

  // Match `description: |` block + indented body lines
  const descMatch = frontmatter.match(/^description:\s*\|?\s*\n((?:\s{2,}.*(?:\n|$))+)/m)
                    || frontmatter.match(/^description:\s+(.+)$/m);
  if (!descMatch) return null;

  // Extract full description text
  let descText: string;
  if (descMatch[0].startsWith('description: |') || /^description:\s*\|/.test(descMatch[0])) {
    descText = descMatch[1].split('\n').map(l => l.replace(/^\s{2}/, '')).join('\n').trim();
  } else {
    descText = descMatch[1].trim();
  }

  // Skip skills with very short descriptions (already trimmed or no routing prose).
  // Below ~120 chars, splitting adds no value.
  if (descText.length < 120) return null;

  const parts = splitCatalogDescription(descText);
  // If lead + (gstack) is already most of the text, no trim needed.
  const trimmedLen = buildTrimmedDescription(parts).length;
  if (trimmedLen >= descText.length - 20) return null;

  // Replace description in frontmatter — keep trailing newline so the next
  // YAML field doesn't collide on the same line as the description value.
  // Quote the value when it would be an invalid YAML plain scalar (the common
  // case: an interior ": " like "Ship workflow: detect..." which a strict YAML
  // parser reads as a nested mapping and rejects — #1778). toYamlInlineScalar
  // only quotes when needed, so descriptions without special chars stay plain.
  const newDesc = buildTrimmedDescription(parts);
  // Function replacer (not a string) so a `$` in the description — e.g. a future
  // skill referencing `$B`/`$D` — can't be interpreted as a `$&`/`$1` replacement
  // pattern and silently corrupt the frontmatter.
  const newDescLine = `description: ${toYamlInlineScalar(newDesc)}\n`;
  const newFrontmatter = frontmatter.replace(descMatch[0], () => newDescLine);
  let newContent = '---\n' + newFrontmatter + content.slice(fmEnd);

  // Insert body section after frontmatter (after the closing ---\n and any
  // existing GENERATED header). We insert before the first non-comment line.
  const bodyStart = newContent.indexOf('\n---\n') + 5;
  const whenToInvoke = '\n' + buildWhenToInvokeSection(parts).trim() + '\n';
  // Skip past the generated header if present (it lives after frontmatter close)
  const headerMatch = newContent.slice(bodyStart).match(/^(<!--[^>]*-->\s*\n)+/);
  const insertAt = bodyStart + (headerMatch ? headerMatch[0].length : 0);
  newContent = newContent.slice(0, insertAt) + whenToInvoke + '\n' + newContent.slice(insertAt);

  return { content: newContent, parts };
}

const OPENAI_SHORT_DESCRIPTION_LIMIT = 120;

function condenseOpenAIShortDescription(description: string): string {
  const firstParagraph = description.split(/\n\s*\n/)[0] || description;
  const collapsed = firstParagraph.replace(/\s+/g, ' ').trim();
  if (collapsed.length <= OPENAI_SHORT_DESCRIPTION_LIMIT) return collapsed;

  const truncated = collapsed.slice(0, OPENAI_SHORT_DESCRIPTION_LIMIT - 3);
  const lastSpace = truncated.lastIndexOf(' ');
  const safe = lastSpace > 40 ? truncated.slice(0, lastSpace) : truncated;
  return `${safe}...`;
}

function generateOpenAIYaml(displayName: string, shortDescription: string): string {
  return `interface:
  display_name: ${JSON.stringify(displayName)}
  short_description: ${JSON.stringify(shortDescription)}
  default_prompt: ${JSON.stringify(`Use ${displayName} for this task.`)}
policy:
  allow_implicit_invocation: true
`;
}

/**
 * Transform frontmatter for external hosts.
 * Claude: strips `sensitive:` field (only Factory uses it).
 * Codex: keeps name + description only, enforces 1024-char limit.
 * Factory: keeps name + description + user-invocable, conditionally adds disable-model-invocation.
 */
function transformFrontmatter(content: string, host: Host): string {
  const hostConfig = getHostConfig(host);
  const fm = hostConfig.frontmatter;

  if (fm.mode === 'denylist') {
    // Denylist mode: strip listed fields, keep everything else
    for (const field of fm.stripFields || []) {
      if (field === 'voice-triggers') {
        content = content.replace(/^voice-triggers:\n(?:\s+-\s+"[^"]*"\n?)*/m, '');
      } else {
        content = content.replace(new RegExp(`^${field}:\\s*.*\\n`, 'm'), '');
      }
    }
    return content;
  }

  // Allowlist mode: reconstruct frontmatter with only allowed fields
  const fmStart = content.indexOf('---\n');
  if (fmStart !== 0) return content;
  const fmEnd = content.indexOf('\n---', fmStart + 4);
  if (fmEnd === -1) return content;
  const frontmatter = content.slice(fmStart + 4, fmEnd);
  const body = content.slice(fmEnd + 4);
  const { name, description } = extractNameAndDescription(content);

  // Description limit enforcement
  if (fm.descriptionLimit) {
    const behavior = fm.descriptionLimitBehavior || 'error';
    if (description.length > fm.descriptionLimit) {
      if (behavior === 'error') {
        throw new Error(
          `${hostConfig.displayName} description for "${name}" is ${description.length} chars (max ${fm.descriptionLimit}). ` +
          `Compress the description in the .tmpl file.`
        );
      } else if (behavior === 'warn') {
        console.warn(`WARNING: ${hostConfig.displayName} description for "${name}" exceeds ${fm.descriptionLimit} chars`);
      }
      // 'truncate' — silently proceed
    }
  }

  // Build frontmatter with allowed fields
  const indentedDesc = description.split('\n').map(l => `  ${l}`).join('\n');
  let newFm = `---\nname: ${name}\ndescription: |\n${indentedDesc}\n`;

  // Add extra fields (host-wide)
  if (fm.extraFields) {
    for (const [key, value] of Object.entries(fm.extraFields)) {
      if (key !== 'name' && key !== 'description') {
        newFm += `${key}: ${value}\n`;
      }
    }
  }

  // Add conditional fields
  if (fm.conditionalFields) {
    for (const rule of fm.conditionalFields) {
      const match = Object.entries(rule.if).every(([k, v]) =>
        new RegExp(`^${k}:\\s*${v}`, 'm').test(frontmatter)
      );
      if (match) {
        for (const [key, value] of Object.entries(rule.add)) {
          newFm += `${key}: ${value}\n`;
        }
      }
    }
  }

  // Preserve additional keepFields beyond name and description
  if (fm.keepFields) {
    for (const field of fm.keepFields) {
      if (field === 'name' || field === 'description') continue;
      // Match YAML field with possible multi-line/array value (indented lines after colon)
      const fieldMatch = frontmatter.match(new RegExp(`^${field}:(.*(?:\\n(?:[ \\t]+.+))*)`, 'm'));
      if (fieldMatch) {
        newFm += `${field}:${fieldMatch[1]}\n`;
      }
    }
  }

  // Rename fields (copy values from template frontmatter with new keys)
  if (fm.renameFields) {
    for (const [oldName, newName] of Object.entries(fm.renameFields)) {
      const fieldMatch = frontmatter.match(new RegExp(`^${oldName}:(.+(?:\\n(?:\\s+.+)*)?)`, 'm'));
      if (fieldMatch) {
        newFm += `${newName}:${fieldMatch[1]}\n`;
      }
    }
  }

  newFm += '---';
  return newFm + body;
}

/**
 * Extract hook descriptions from frontmatter for inline safety prose.
 * Returns a description of what the hooks do, or null if no hooks.
 */
function extractHookSafetyProse(tmplContent: string): string | null {
  if (!tmplContent.match(/^hooks:/m)) return null;

  // Parse the hook matchers to build a human-readable safety description
  const matchers: string[] = [];
  const matcherRegex = /matcher:\s*"(\w+)"/g;
  let m;
  while ((m = matcherRegex.exec(tmplContent)) !== null) {
    if (!matchers.includes(m[1])) matchers.push(m[1]);
  }

  if (matchers.length === 0) return null;

  // Build safety prose based on what tools are hooked
  const toolDescriptions: Record<string, string> = {
    Bash: 'check bash commands for destructive operations (rm -rf, DROP TABLE, force-push, git reset --hard, etc.) before execution',
    Edit: 'verify file edits are within the allowed scope boundary before applying',
    Write: 'verify file writes are within the allowed scope boundary before applying',
  };

  const safetyChecks = matchers
    .map(t => toolDescriptions[t] || `check ${t} operations for safety`)
    .join(', and ');

  return `> **Safety Advisory:** This skill includes safety checks that ${safetyChecks}. When using this skill, always pause and verify before executing potentially destructive operations. If uncertain about a command's safety, ask the user for confirmation before proceeding.`;
}

// ─── External Host Config (now derived from hosts/*.ts) ──────
// EXTERNAL_HOST_CONFIG replaced by getHostConfig() from hosts/index.ts

// ─── Template Processing ────────────────────────────────────

const GENERATED_HEADER = `<!-- AUTO-GENERATED from {{SOURCE}} — do not edit directly -->\n<!-- Regenerate: bun run gen:skill-docs -->\n`;

/**
 * Apply a host's configured path + tool rewrites. Extracted so both SKILL.md
 * (via processExternalHost) and section files (via processSectionTemplate) get
 * identical per-host treatment — a section's cross-references must rewrite the
 * same way the parent skill's do, or external hosts get wrong paths.
 */
function applyHostRewrites(content: string, hostConfig: HostConfig): string {
  let result = content;
  for (const rewrite of hostConfig.pathRewrites) {
    result = result.replaceAll(rewrite.from, rewrite.to);
  }
  if (hostConfig.toolRewrites) {
    for (const [from, to] of Object.entries(hostConfig.toolRewrites)) {
      result = result.replaceAll(from, to);
    }
  }
  return result;
}

/**
 * Resolve {{PLACEHOLDER}} / {{NAME:arg}} tokens against the RESOLVERS registry,
 * honoring host suppression and appliesTo gating, then assert nothing is left
 * unresolved. Extracted so SKILL.md and section templates resolve through the
 * exact same path — a security/sanitization fix to one can't miss the other.
 */
/**
 * A second {{PREAMBLE}} in one template re-expands the entire ~12K-token
 * preamble mid-document (#2508/#2362 — a PROSE mention of the macro in
 * spec/SKILL.md.tmpl expanded it a second time, +43KB per /spec load).
 * Resolution is context-blind, so any second occurrence — code fence, prose,
 * anywhere — is a generation error, never intentional. Throw at render time
 * so the mistake cannot reach a generated SKILL.md again.
 */
export function assertSinglePreamble(tmplContent: string, relTmplPath: string): void {
  const count = (tmplContent.match(/\{\{PREAMBLE\}\}/g) || []).length;
  if (count > 1) {
    throw new Error(
      `${relTmplPath} contains {{PREAMBLE}} ${count} times — a template may reference it `
      + `at most once (each occurrence expands the full preamble; see #2508/#2362). `
      + `Refer to "the preamble" in prose instead of the macro.`,
    );
  }
}

function resolvePlaceholders(
  tmplContent: string,
  ctx: TemplateContext,
  hostConfig: HostConfig,
  relTmplPath: string,
): string {
  assertSinglePreamble(tmplContent, relTmplPath);
  // effectiveSuppressedResolvers() honors --respect-detection: when gbrain is
  // detected locally, GBRAIN_* resolvers un-suppress. Shared by SKILL.md and
  // section generation so both paths get the same gbrain-aware behavior.
  const suppressed = effectiveSuppressedResolvers(hostConfig);
  const onePass = (input: string): string =>
    input.replace(/\{\{(\w+(?::[^}]+)?)\}\}/g, (_match, fullKey) => {
      const parts = fullKey.split(':');
      const resolverName = parts[0];
      const args = parts.slice(1);
      if (suppressed.has(resolverName)) return '';
      const resolve = RESOLVERS[resolverName];
      if (!resolve) throw new Error(`Unknown placeholder {{${resolverName}}} in ${relTmplPath}`);
      return args.length > 0 ? resolve(ctx, args) : resolve(ctx);
    });

  // Multi-pass: a resolver may emit content that itself contains {{TOKENS}} — the
  // {{SECTION:id}} resolver inlines a section template (with its own resolvers)
  // for non-Claude hosts. .replace() doesn't re-scan inserted text, so loop until
  // the output stabilizes. Bounded to avoid an infinite loop if a resolver ever
  // emits its own placeholder; 6 passes is far more nesting than any skill needs.
  let content = tmplContent;
  for (let pass = 0; pass < 6; pass++) {
    const next = onePass(content);
    if (next === content) break;
    content = next;
  }

  const remaining = content.match(/\{\{(\w+(?::[^}]+)?)\}\}/g);
  if (remaining) {
    throw new Error(`Unresolved placeholders in ${relTmplPath}: ${remaining.join(', ')}`);
  }
  return content;
}

/**
 * Build the TemplateContext from a template's frontmatter. Shared by SKILL.md
 * and section generation so sections inherit the SAME context the parent skill
 * resolves with (skillName, tier, benefitsFrom, interactive) — enforced by
 * test/template-context-parity.test.ts. skillNameOverride lets section
 * generation pin the parent skill's name instead of deriving "sections".
 */
function buildContext(
  tmplContent: string,
  tmplPath: string,
  host: Host,
  skillNameOverride?: string,
): TemplateContext {
  const { name: extractedName } = extractNameAndDescription(tmplContent);
  const skillName = skillNameOverride || extractedName || path.basename(path.dirname(tmplPath));
  const benefitsMatch = tmplContent.match(/^benefits-from:\s*\[([^\]]*)\]/m);
  const benefitsFrom = benefitsMatch
    ? benefitsMatch[1].split(',').map(s => s.trim()).filter(Boolean)
    : undefined;
  const tierMatch = tmplContent.match(/^preamble-tier:\s*(\d+)$/m);
  const preambleTier = tierMatch ? parseInt(tierMatch[1], 10) : undefined;
  const interactiveMatch = tmplContent.match(/^interactive:\s*(true|false)\s*$/m);
  const interactive = interactiveMatch ? interactiveMatch[1] === 'true' : undefined;
  return {
    skillName, tmplPath, benefitsFrom, host, paths: HOST_PATHS[host],
    preambleTier, model: generationModelForHost(host), interactive, explainLevel: EXPLAIN_LEVEL,
  };
}

/**
 * Process external host output: routing, frontmatter, path rewrites, metadata.
 * Shared between Codex and Factory (and future external hosts).
 */
function processExternalHost(
  content: string,
  tmplContent: string,
  host: Host,
  skillDir: string,
  extractedDescription: string,
  ctx: TemplateContext,
  frontmatterName?: string,
): { content: string; outputPath: string; outputDir: string; symlinkLoop: boolean } {
  const hostConfig = getHostConfig(host);

  const name = externalSkillName(skillDir === '.' ? '' : skillDir, frontmatterName);
  // --out-dir mirrors the host tree (outputs only; inputs read from ROOT).
  const outputDir = path.join(OUT_DIR ?? ROOT, hostConfig.hostSubdir, 'skills', name);
  fs.mkdirSync(outputDir, { recursive: true });
  const outputPath = path.join(outputDir, 'SKILL.md');

  // Guard against symlink loops
  let symlinkLoop = false;
  const claudePath = ctx.tmplPath.replace(/\.tmpl$/, '');
  try {
    const resolvedClaude = fs.realpathSync(claudePath);
    const resolvedExternal = fs.realpathSync(path.dirname(outputPath)) + '/' + path.basename(outputPath);
    if (resolvedClaude === resolvedExternal) {
      symlinkLoop = true;
    }
  } catch {
    // realpathSync fails if file doesn't exist yet — no symlink loop
  }

  // Extract hook safety prose BEFORE transforming frontmatter (which strips hooks)
  const safetyProse = extractHookSafetyProse(tmplContent);

  // Transform frontmatter (host-aware)
  let result = transformFrontmatter(content, host);

  // Insert safety advisory at the top of the body (after frontmatter)
  if (safetyProse) {
    const bodyStart = result.indexOf('\n---') + 4;
    result = result.slice(0, bodyStart) + '\n' + safetyProse + '\n' + result.slice(bodyStart);
  }

  // Config-driven path + tool rewrites (shared with processSectionTemplate so
  // section cross-references get the same per-host treatment as SKILL.md).
  result = applyHostRewrites(result, hostConfig);

  // Config-driven: generate metadata (e.g., openai.yaml for Codex)
  if (hostConfig.generation.generateMetadata && !symlinkLoop) {
    const agentsDir = path.join(outputDir, 'agents');
    fs.mkdirSync(agentsDir, { recursive: true });
    const shortDescription = condenseOpenAIShortDescription(extractedDescription);
    fs.writeFileSync(path.join(agentsDir, 'openai.yaml'), generateOpenAIYaml(name, shortDescription));
  }

  return { content: result, outputPath, outputDir, symlinkLoop };
}

function processTemplate(tmplPath: string, host: Host = 'claude'): { outputPath: string; content: string; symlinkLoop?: boolean } {
  // Normalize to LF at the entry point. Templates may have CRLF on disk when
  // checked out on Windows with core.autocrlf=true. Downstream regexes
  // (processVoiceTriggers, transformFrontmatter) hardcode \n, so without
  // normalization they silently no-op on CRLF — producing different output
  // than CI (Linux, LF) and breaking the Skill Docs Freshness check.
  // (catalogParts left the return type with the proactive-suggestions
  // retirement — merge of the two v1.64 waves.)
  const tmplContent = fs.readFileSync(tmplPath, 'utf-8').replace(/\r\n/g, '\n');
  const relTmplPath = path.relative(ROOT, tmplPath);
  let outputPath = tmplPath.replace(/\.tmpl$/, '');

  // Determine skill directory relative to ROOT
  const skillDir = path.relative(ROOT, path.dirname(tmplPath));

  // --out-dir: mirror the skill tree into the out-dir instead of writing in
  // place (external hosts compute their own OUT_DIR-aware paths below).
  if (OUT_DIR && host === 'claude') {
    outputPath = path.join(OUT_DIR, skillDir, path.basename(tmplPath).replace(/\.tmpl$/, ''));
  }

  // Extract name/description: name drives external skill naming + setup symlinks
  // (and TemplateContext.skillName via buildContext); description feeds external
  // host metadata. When frontmatter name: differs from directory name (e.g.
  // run-tests/ with name: test), the frontmatter name wins.
  const { name: extractedName, description: extractedDescription } = extractNameAndDescription(tmplContent);

  const currentHostConfig = getHostConfig(host);
  const ctx = buildContext(tmplContent, tmplPath, host);
  const skillName = ctx.skillName;

  // Replace placeholders + assert none remain (shared path with section generation).
  let content = resolvePlaceholders(tmplContent, ctx, currentHostConfig, relTmplPath);

  // Preprocess voice triggers: fold into description, strip field from frontmatter.
  // Must run BEFORE transformFrontmatter so all hosts see the updated description,
  // and BEFORE extractedDescription is used by external host metadata.
  content = processVoiceTriggers(content);

  // Re-extract description AFTER voice trigger preprocessing so Codex openai.yaml
  // metadata gets the updated description with voice triggers included.
  const postProcessDescription = extractNameAndDescription(content).description;

  // For Claude: strip sensitive: field (only Factory uses it)
  // For external hosts: route output, transform frontmatter, rewrite paths
  let symlinkLoop = false;
  if (host === 'claude') {
    content = transformFrontmatter(content, host);
  } else {
    const result = processExternalHost(content, tmplContent, host, skillDir, postProcessDescription, ctx, extractedName || undefined);
    content = result.content;
    outputPath = result.outputPath;
    symlinkLoop = result.symlinkLoop;
  }

  // Prepend generated header (after frontmatter)
  const header = GENERATED_HEADER.replace('{{SOURCE}}', path.basename(tmplPath));
  const fmEnd = content.indexOf('---', content.indexOf('---') + 3);
  if (fmEnd !== -1) {
    const insertAt = content.indexOf('\n', fmEnd) + 1;
    content = content.slice(0, insertAt) + header + content.slice(insertAt);
  } else {
    content = header + content;
  }

  // Catalog trim (Claude only — external hosts have their own frontmatter shapes)
  if (host === 'claude' && CATALOG_MODE === 'trim') {
    const trimmed = applyCatalogTrim(content, skillName);
    if (trimmed) content = trimmed.content;
  }

  // --out-dir: repoint section-base paths to the out-dir (no-op otherwise).
  if (host === 'claude') content = rewriteSectionBase(content);

  return { outputPath, content, symlinkLoop };
}

/**
 * Generate one on-demand section file (`<skill>/sections/<name>.md.tmpl` →
 * `<name>.md`). Sections are BODY FRAGMENTS — no frontmatter, no catalog trim,
 * no voice triggers. They resolve placeholders through the SAME path as
 * SKILL.md (resolvePlaceholders) using the PARENT skill's TemplateContext
 * (so appliesTo gating + tier behave identically — a section's {{PREAMBLE}}-
 * style resolver renders the same content it would in the parent, not empty).
 *
 * Output routing mirrors SKILL.md: Claude writes in-tree at
 * `<skill>/sections/<name>.md`; external hosts write to
 * `<hostSubdir>/skills/<externalName>/sections/<name>.md`. External hosts get
 * applyHostRewrites so cross-references resolve per host.
 */
function processSectionTemplate(
  sectionTmplPath: string,
  skillDir: string,
  host: Host = 'claude',
): { outputPath: string; content: string } {
  const tmplContent = fs.readFileSync(sectionTmplPath, 'utf-8');
  const relTmplPath = path.relative(ROOT, sectionTmplPath);
  const hostConfig = getHostConfig(host);

  // Read the owning SKILL.md.tmpl so the section inherits the parent's name +
  // tier + benefits-from (TemplateContext parity). Fall back to the dir name.
  const parentTmplPath = path.join(ROOT, skillDir, 'SKILL.md.tmpl');
  const parentContent = fs.existsSync(parentTmplPath) ? fs.readFileSync(parentTmplPath, 'utf-8') : '';
  const parentName = (parentContent && extractNameAndDescription(parentContent).name) || skillDir;
  const ctx = buildContext(parentContent || tmplContent, parentTmplPath, host, parentName);

  // Resolve placeholders against the section body (shared guard catches stragglers).
  let content = resolvePlaceholders(tmplContent, ctx, hostConfig, relTmplPath);

  // External hosts: rewrite cross-reference paths/tools (no frontmatter to transform).
  if (host !== 'claude') {
    content = applyHostRewrites(content, hostConfig);
  } else {
    // --out-dir: a section may cross-reference another section by absolute path;
    // repoint those to the out-dir too (no-op when --out-dir is unset).
    content = rewriteSectionBase(content);
  }

  // Plain generated header (no frontmatter to insert after).
  content = GENERATED_HEADER.replace('{{SOURCE}}', path.basename(sectionTmplPath)) + content;

  const fileName = path.basename(sectionTmplPath).replace(/\.tmpl$/, '');
  let outputPath: string;
  if (host === 'claude') {
    outputPath = path.join(OUT_DIR || ROOT, skillDir, 'sections', fileName);
  } else {
    const externalName = externalSkillName(skillDir, parentName);
    outputPath = path.join(OUT_DIR ?? ROOT, hostConfig.hostSubdir, 'skills', externalName, 'sections', fileName);
  }
  if (!DRY_RUN) fs.mkdirSync(path.dirname(outputPath), { recursive: true });
  return { outputPath, content };
}

// ─── Main ───────────────────────────────────────────────────

function findTemplates(): string[] {
  return discoverTemplates(ROOT).map(t => path.join(ROOT, t.tmpl));
}

const ALL_HOSTS: Host[] = ALL_HOST_NAMES as Host[];

/**
 * The generator's whole executable body. Import-purity contract: importing
 * this module must NEVER touch the tree — test/gen-skill-docs.test.ts pulls
 * assertSinglePreamble via require(), test/catalog-trim.test.ts imports
 * helpers, and before this guard existed every such import regenerated all
 * 71 SKILL.md in place at module-load time (the root cause of half the
 * TREE_MUTATING serial shard; hazard class #2532). Pinned by
 * test/gen-skill-docs-import-purity.test.ts.
 *
 * Returns the process exit code. Kept synchronous so the module stays
 * require()-able (see the llms.txt IIFE note below).
 */
export function main(): number {
const hostsToRun: Host[] = HOST_ARG_VAL === 'all' ? ALL_HOSTS : [HOST];
const failures: { host: string; error: Error }[] = [];

for (const currentHost of hostsToRun) {
  HOST = currentHost;

  try {
    let hasChanges = false;
    const tokenBudget: Array<{ skill: string; lines: number; tokens: number }> = [];

    const currentHostConfig = getHostConfig(currentHost);
    for (const tmplPath of findTemplates()) {
      const dir = path.basename(path.dirname(tmplPath));

      // includeSkills allowlist (union logic: include minus skip)
      if (currentHostConfig.generation.includeSkills?.length) {
        if (!currentHostConfig.generation.includeSkills.includes(dir)) continue;
      }
      // skipSkills denylist (subtracts from includeSkills or full set)
      if (currentHostConfig.generation.skipSkills?.length) {
        if (currentHostConfig.generation.skipSkills.includes(dir)) continue;
      }

      const { outputPath, content, symlinkLoop } = processTemplate(tmplPath, currentHost);
      const relOutput = path.relative(OUT_DIR || ROOT, outputPath);

      if (symlinkLoop) {
        console.log(`SKIPPED (symlink loop): ${relOutput}`);
      } else if (DRY_RUN) {
        const existing = fs.existsSync(outputPath) ? fs.readFileSync(outputPath, 'utf-8') : '';
        if (existing !== content) {
          console.log(`STALE: ${relOutput}`);
          hasChanges = true;
        } else {
          console.log(`FRESH: ${relOutput}`);
        }
      } else {
        // In-place writes land in existing dirs; --out-dir needs the mirrored
        // skill dir created first.
        if (OUT_DIR) fs.mkdirSync(path.dirname(outputPath), { recursive: true });
        fs.writeFileSync(outputPath, content);
        console.log(`GENERATED: ${relOutput}`);
      }

      // Track token budget
      const lines = content.split('\n').length;
      const tokens = Math.round(content.length / 4); // ~4 chars per token
      tokenBudget.push({ skill: relOutput, lines, tokens });

      // Token ceiling check: warn if any generated SKILL.md exceeds ~40K tokens (160KB).
      // The ceiling is a "watch for feature bloat" guardrail, not a hard gate. Modern
      // flagship models have 200K-1M context windows, so 40K (4-20% of window) is fine.
      // Prompt caching further reduces the marginal cost of larger skills. This ceiling
      // exists to catch a runaway preamble or resolver that's grown by 10K+ tokens in
      // a release, not to force compression on carefully-tuned big skills (ship,
      // plan-ceo-review, office-hours all legitimately pack 25-35K tokens of behavior).
      const TOKEN_CEILING_BYTES = 160_000;
      if (content.length > TOKEN_CEILING_BYTES) {
        console.warn(`⚠️  TOKEN CEILING: ${relOutput} is ${content.length} bytes (~${tokens} tokens), exceeds ${TOKEN_CEILING_BYTES} byte ceiling (~40K tokens)`);
      }
    }

    // ─── Section generation (v2 plan T9, Claude-first carve) ───
    // On-demand sections/*.md for carved skills. Generated for CLAUDE ONLY:
    // every other host inlines section content via the {{SECTION:id}} resolver
    // (keeping the full monolith skill), so they need no section files and we
    // sidestep host-portable section paths until that plumbing lands. No-op for
    // any skill without a sections/ dir. Mirrors the SKILL.md DRY_RUN handling so
    // sections participate in the freshness gate.
    for (const sec of currentHost === 'claude' ? discoverSectionTemplates(ROOT) : []) {
      if (currentHostConfig.generation.includeSkills?.length &&
          !currentHostConfig.generation.includeSkills.includes(sec.skillDir)) continue;
      if (currentHostConfig.generation.skipSkills?.length &&
          currentHostConfig.generation.skipSkills.includes(sec.skillDir)) continue;

      const { outputPath, content } = processSectionTemplate(path.join(ROOT, sec.tmpl), sec.skillDir, currentHost);
      const relOutput = path.relative(OUT_DIR || ROOT, outputPath);

      if (DRY_RUN) {
        const existing = fs.existsSync(outputPath) ? fs.readFileSync(outputPath, 'utf-8') : '';
        if (existing !== content) {
          console.log(`STALE: ${relOutput}`);
          hasChanges = true;
        } else {
          console.log(`FRESH: ${relOutput}`);
        }
      } else {
        if (OUT_DIR) fs.mkdirSync(path.dirname(outputPath), { recursive: true });
        fs.writeFileSync(outputPath, content);
        console.log(`GENERATED: ${relOutput}`);
      }

      tokenBudget.push({
        skill: relOutput,
        lines: content.split('\n').length,
        tokens: Math.round(content.length / 4),
      });
    }

    // Generate the OpenClaw orchestrator-injection docs (gstack-lite / gstack-full /
    // gstack-plan CLAUDE.md snippets). Sources live in openclaw/templates/ —
    // plain markdown, no placeholder resolution — and are copied byte-for-byte
    // to openclaw/ at gen time.
    if (currentHost === 'openclaw' && !DRY_RUN) {
      // Inputs from ROOT, outputs into OUT_DIR when set (outputs-only rule).
      const openclawTemplatesDir = path.join(ROOT, 'openclaw', 'templates');
      const openclawOutDir = path.join(OUT_DIR ?? ROOT, 'openclaw');
      if (OUT_DIR) fs.mkdirSync(openclawOutDir, { recursive: true });
      for (const variant of ['lite', 'full', 'plan'] as const) {
        const fileName = `gstack-${variant}-CLAUDE.md`;
        const content = fs.readFileSync(path.join(openclawTemplatesDir, fileName), 'utf-8');
        fs.writeFileSync(path.join(openclawOutDir, fileName), content);
        console.log(`GENERATED: openclaw/${fileName}`);
      }
    }

    if (DRY_RUN && hasChanges) {
      console.error(`\nGenerated SKILL.md files are stale (${currentHost} host). Run: bun run gen:skill-docs --host ${currentHost}`);
      if (HOST_ARG_VAL !== 'all') return 1;
      failures.push({ host: currentHost, error: new Error('Stale files detected') });
    }

    // Print token budget summary
    if (!DRY_RUN && tokenBudget.length > 0) {
      tokenBudget.sort((a, b) => b.lines - a.lines);
      const totalLines = tokenBudget.reduce((s, t) => s + t.lines, 0);
      const totalTokens = tokenBudget.reduce((s, t) => s + t.tokens, 0);

      console.log('');
      console.log(`Token Budget (${currentHost} host)`);
      console.log('═'.repeat(60));
      for (const t of tokenBudget) {
        const hostSubdirs = ALL_HOST_CONFIGS.map(c => c.hostSubdir.replace('.', '\\.')).join('|');
        const name = t.skill.replace(/\/SKILL\.md$/, '').replace(new RegExp(`^\\.(${hostSubdirs})\\/skills\\/`), '');
        console.log(`  ${name.padEnd(30)} ${String(t.lines).padStart(5)} lines  ~${String(t.tokens).padStart(6)} tokens`);
      }
      console.log('─'.repeat(60));
      console.log(`  ${'TOTAL'.padEnd(30)} ${String(totalLines).padStart(5)} lines  ~${String(totalTokens).padStart(6)} tokens`);
      console.log('');
    }
  } catch (e) {
    failures.push({ host: currentHost, error: e as Error });
    console.error(`WARNING: ${currentHost} generation failed: ${(e as Error).message}`);
  }
}

// --host all: any host failure fails the build. Previously only claude failures
// exited nonzero, which let a stale or broken external-host output (e.g. a
// section that failed to generate for Factory) slip through the freshness gate
// silently. With sections fanned out across every host, "all hosts regenerated
// in the same commit" is only a real gate if every host failure is fatal here.
if (failures.length > 0 && HOST_ARG_VAL === 'all') {
  console.error(`\n${failures.length} host(s) failed: ${failures.map(f => f.host).join(', ')}`);
  return 1;
}
// Single host dry-run failure already handled above

// After all hosts processed, warn if prefix patches may need re-applying
if (!DRY_RUN) {
  try {
    const configPath = path.join(process.env.HOME || '', '.gstack', 'config.yaml');
    if (fs.existsSync(configPath)) {
      const config = fs.readFileSync(configPath, 'utf-8');
      if (/^skill_prefix:\s*true/m.test(config)) {
        console.log('\nNote: skill_prefix is true. Run gstack-relink to re-apply name: patches.');
      }
    }
  } catch { /* non-fatal */ }
}

// Regenerate gstack/llms.txt — single-file capability index for AI agents.
// Runs after SKILL.md generation so it sees current skill descriptions and
// browse command list. Wrapped in an IIFE so the await-import doesn't make
// this module async (test/gen-skill-docs.test.ts uses require() to pull
// extractVoiceTriggers/processVoiceTriggers, which fails on async modules).
// Freshness is asserted in test/llms-txt-shape.test.ts.
if (!DRY_RUN) {
  void (async () => {
    try {
      const result = await writeLlmsTxt(
        // Outputs-only rule: under --out-dir even this index lands there
        // (a catalog-mode render must never rewrite the tracked llms.txt).
        OUT_DIR ? { outputPath: path.join(OUT_DIR, 'gstack', 'llms.txt') } : {},
      );
      if (result.warnings.length > 0) {
        for (const w of result.warnings) console.error(`[gen-llms-txt] WARN: ${w}`);
      } else {
        console.log(`[gen-llms-txt] gstack/llms.txt: ${result.skills.length} skills, ${result.browseCommands.length} browse commands`);
      }
    } catch (err) {
      const msg = err instanceof Error ? err.message : String(err);
      console.error(`[gen-llms-txt] FAILED: ${msg}`);
    }
  })();
}

return 0;
}

if (import.meta.main) {
  // Failure exits are immediate (matching the old top-level process.exit
  // behavior); success leaves the event loop to drain so the llms.txt
  // fire-and-forget IIFE inside main() finishes its write.
  const code = main();
  if (code !== 0) process.exit(code);
}
