#!/usr/bin/env node

"use strict";

/**
 * evolv-statusline.js — StatusLine hook for Claude Code
 * Renders a configurable multi-row session dashboard in the terminal bottom bar.
 *
 * Layout (default 5-column grid):
 *   R1: brand_version (full width)
 *   R2: model_labeled    | protection   | project_type | status       | lifecycle
 *   R3: context_combined | tokens_io    | dev_rigor    | usage_time   | phase_stage
 *   R4: cost             | cache_hit    | style        | learning     | feature
 *   R5+: directory rows (project, current, added dirs) | next_cmd
 *
 * Palette: "Warm Neutral" — 5 hues + neutral axis
 * NOTE: This file is generated by scripts/build-hooks.js from src/statusline/.
 *       Do not edit directly — edit the source modules and run npm run build:hooks.
 */

const fs = require("fs");
const path = require("path");
const os = require("os");
const crypto = require("crypto");
const { execSync } = require("child_process");

// ─── [color-system] ─────────────────────────────────────────────────
// ─── Color System ──────────────────────────────────────────────────────────

const USE_COLOR = !process.env.NO_COLOR && process.env.TERM !== "dumb";
const IS_TRUECOLOR =
  process.env.COLORTERM === "truecolor" || process.env.COLORTERM === "24bit";

/**
 * Create a color function that picks truecolor, 256-color, or 16-color ANSI.
 * Returns a function that wraps text in the chosen escape sequence.
 */
function color(tc, c256) {
  if (!USE_COLOR) return (s) => s;
  const code = IS_TRUECOLOR ? tc : c256;
  return (s) => `\x1b[${code}m${s}\x1b[39m`;
}

// Warm Neutral palette
const BRAND = color("38;2;255;140;0", "38;5;208");
const AMBER = color("38;2;204;153;0", "38;5;178");
const SLATE = color("38;2;108;153;204", "38;5;110");
const OK = color("38;2;76;191;115", "38;5;78");
const WARN = color("38;2;230;190;50", "38;5;220");
const ERR = color("38;2;230;80;80", "38;5;167");
const MUTED = color("38;2;140;140;140", "38;5;245");
const SEP_C = color("38;2;70;70;70", "38;5;238");

// Typography helpers — use SGR-specific resets for nesting
const BOLD = USE_COLOR ? "\x1b[1m" : "";
const DIM = USE_COLOR ? "\x1b[2m" : "";
const ITALIC = USE_COLOR ? "\x1b[3m" : "";
const RESET = USE_COLOR ? "\x1b[0m" : "";

// Shorthand: italic dim for labels
function lbl(s) {
  if (!USE_COLOR) return s;
  return `${ITALIC}${DIM}${s}${RESET}`;
}

// Nerd Font detection
const USE_NERDFONT = (() => {
  if (process.env.NERD_FONTS || process.env.NERDFONT) return true;
  const fontName = process.env.TERM_FONT || process.env.LC_TERMINAL_FONT || "";
  return /nerd/i.test(fontName);
})();

const ICONS = USE_NERDFONT
  ? {
      brand: " ",
      output: " ",
      status: " ",
      model: "󰚩 ",
      cache: "󰑮 ",
      tokens: "󰦨 ",
      project: " ",
      folder: " ",
      branch: " ",
      mode: "󰔡 ",
      usage: " ",
      clock: " ",
      files: " ",
      prot: " ",
      dirty: " ",
    }
  : {
      brand: "",
      output: "",
      status: "",
      model: "",
      cache: "",
      tokens: "",
      project: "",
      folder: "",
      branch: "",
      mode: "",
      usage: "",
      clock: "",
      files: "",
      prot: "",
      dirty: "",
    };

// ─── [helpers] ──────────────────────────────────────────────────────
// ─── Helpers ───────────────────────────────────────────────────────────────

/** Strip ANSI escape sequences from a string. */
function stripAnsi(str) {
  return str.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "");
}

/** Get the visible width of a string (excluding ANSI codes). */
function visWidth(str) {
  return stripAnsi(str).length;
}

/** Pad a string with trailing spaces to reach target visible width. */
function padToWidth(str, target) {
  const pad = target - visWidth(str);
  return pad > 0 ? str + " ".repeat(pad) : str;
}

/** Format a token count as human-readable (e.g., 1.2k, 3.5M). */
function formatTokens(n) {
  if (n == null || n === "null") return "0";
  n = Number(n);
  if (isNaN(n) || n === 0) return "0";
  if (n >= 1000000) {
    const k = Math.floor(n / 1000);
    return `${Math.floor(k / 1000)}.${Math.floor((k % 1000) / 100)}M`;
  }
  if (n >= 1000) {
    const whole = Math.floor(n / 1000);
    const frac = Math.floor((n % 1000) / 100);
    return frac > 0 ? `${whole}.${frac}k` : `${whole}k`;
  }
  return String(n);
}

/** Shorten a path for display (replace $HOME with ~, truncate if needed). */
function shortenPath(p, max = 40) {
  const home = os.homedir();
  const isHome = p.startsWith(home);
  if (isHome) {
    p = "~" + p.slice(home.length);
  }
  if (p.length > max) {
    const last = path.basename(p);
    const parent = path.basename(path.dirname(p));
    p = isHome ? `~/${parent}/${last}` : `.../${parent}/${last}`;
  }
  return p;
}

// ─── [components] ───────────────────────────────────────────────────
// ─── Components ────────────────────────────────────────────────────────────

/**
 * Each component function receives a data bag and returns
 * { vis: string, out: string } or null (to hide).
 * vis = plain text for width calculation, out = ANSI-formatted output.
 */

function compBrandVersion(d) {
  const ver = d.eckVersion || "\u2014";
  return {
    vis: `evolv Coder Kit v${ver}`,
    out: `${BRAND(`${BOLD}${ICONS.brand}evolv Coder Kit`)}${RESET} ${MUTED(`v${ver}`)}`,
  };
}

function compBrandVersionModel(d) {
  const ver = d.eckVersion || "\u2014";
  const m = d.model || "unknown";
  return {
    vis: `evolv Coder Kit v${ver} - ${m}`,
    out: `${BRAND(`${BOLD}${ICONS.brand}evolv Coder Kit`)}${RESET} ${MUTED(`v${ver}`)} ${MUTED("-")} ${SLATE(`${BOLD}${ICONS.model}${m}`)}${RESET}`,
  };
}

function compModel(d) {
  const m = d.model || "unknown";
  return {
    vis: m,
    out: SLATE(`${BOLD}${ICONS.model}${m}${RESET}`),
  };
}

function compModelLabeled(d) {
  const m = d.model || "unknown";
  return {
    vis: `Model: ${m}`,
    out: `${lbl(`${ICONS.model}Model:`)} ${SLATE(`${BOLD}${m}${RESET}`)}`,
  };
}

function compStatus(d) {
  // `statusText` was a stub that always rendered "Ready". The actual live
  // channel skills write to is `stageDescription` (via update-stage.sh →
  // bridge file). Merging them here keeps the visible label simple and frees
  // the old `Stage:` cell for other content.
  const raw = d.stageDescription || d.statusText || "Ready";
  const text = raw.length > 40 ? raw.slice(0, 37) + "..." : raw;
  const isReady = text === "Ready";

  return {
    vis: `Status:    ${text}`,
    out: isReady
      ? `${lbl(`${ICONS.status}Status:`)}    ${OK(text)}`
      : `${lbl(`${ICONS.status}Status:`)}    ${BRAND(`${BOLD}${text}${RESET}`)}`,
  };
}

function compContextBar(d) {
  const pct = d.contextPercent;
  const termW = d.termWidth || 80;
  let barCells = Math.floor(termW / 5);
  if (barCells < 9) barCells = 9;
  if (barCells > 20) barCells = 20;

  if (pct != null && !isNaN(pct)) {
    const filled = Math.floor((pct * barCells) / 100);
    const empty = barCells - filled;
    const fillChar = USE_COLOR ? "\u2588" : "#";
    const emptyChar = USE_COLOR ? "\u2591" : ".";
    const barColor = pct >= 85 ? ERR : pct >= 70 ? WARN : OK;
    const pctStr = String(pct).padStart(3) + "%";

    const fillStr = fillChar.repeat(filled);
    const emptyStr = emptyChar.repeat(empty);
    const vis = `[${fillChar.repeat(barCells)}] ${pctStr}`;
    const out = `${barColor(`[${fillStr}`)}${RESET}${MUTED(emptyStr)}${RESET}${barColor(`]`)} ${barColor(`${BOLD}${pctStr}`)}${RESET}`;
    return { vis, out };
  }

  // Stale / no data
  const emptyChar = USE_COLOR ? "\u2591" : ".";
  const staleBar = emptyChar.repeat(barCells);
  const vis = `[${staleBar}]  --%`;
  const out = USE_COLOR ? MUTED(`[${staleBar}]  --%`) : vis;
  return { vis, out };
}

function compTokensCtx(d) {
  if (d.contextWindowSize == null || d.contextPercent == null) return null;
  const used = Math.floor((d.contextPercent * d.contextWindowSize) / 100);
  const usedFmt = formatTokens(used);
  const totalFmt = formatTokens(d.contextWindowSize);
  return {
    vis: `Context: ${usedFmt} / ${totalFmt}`,
    out: `${lbl("Context:")} ${SLATE(usedFmt)} ${MUTED("/")} ${SLATE(totalFmt)}`,
  };
}

function compContextCombined(d) {
  const pct = d.contextPercent;
  const termW = d.termWidth || 80;
  let barCells = Math.floor(termW / 5);
  if (barCells < 9) barCells = 9;
  if (barCells > 20) barCells = 20;

  // Build bar + percentage
  let barPart;
  let pctStr;
  let barColor;
  if (pct != null && !isNaN(pct)) {
    const filled = Math.floor((pct * barCells) / 100);
    const empty = barCells - filled;
    const fillChar = USE_COLOR ? "\u2588" : "#";
    const emptyChar = USE_COLOR ? "\u2591" : ".";
    barColor = pct >= 85 ? ERR : pct >= 70 ? WARN : OK;
    pctStr = String(pct).padStart(3) + "%";
    const fillStr = fillChar.repeat(filled);
    const emptyStr = emptyChar.repeat(empty);
    barPart = {
      vis: `[${fillChar.repeat(barCells)}] ${pctStr}`,
      out: `${barColor(`[${fillStr}`)}${RESET}${MUTED(emptyStr)}${RESET}${barColor(`]`)} ${barColor(`${BOLD}${pctStr}`)}${RESET}`,
    };
  } else {
    const emptyChar = USE_COLOR ? "\u2591" : ".";
    const staleBar = emptyChar.repeat(barCells);
    barColor = MUTED;
    pctStr = " --%";
    barPart = {
      vis: `[${staleBar}]  --%`,
      out: MUTED(`[${staleBar}]  --%`),
    };
  }

  // Build token stats
  let statsPart = "";
  let statsVis = "";
  if (d.contextWindowSize != null && pct != null) {
    const used = Math.floor((pct * d.contextWindowSize) / 100);
    const usedFmt = formatTokens(used);
    const totalFmt = formatTokens(d.contextWindowSize);
    statsVis = ` ${usedFmt}/${totalFmt}`;
    statsPart = ` ${SLATE(usedFmt)}${MUTED("/")}${SLATE(totalFmt)}`;
  }

  return {
    vis: `Context ${barPart.vis}${statsVis}`,
    out: `${lbl("Context")} ${barPart.out}${statsPart}`,
  };
}

function compTokensIo(d) {
  const ti = d.totalInputTokens || 0;
  const to = d.totalOutputTokens || 0;
  if (ti === 0 && to === 0) return null;
  const tiFmt = formatTokens(ti);
  const toFmt = formatTokens(to);
  return {
    vis: `In: ${tiFmt} Out: ${toFmt}`,
    out: `${lbl(`${ICONS.tokens}In:`)} ${SLATE(tiFmt)} ${lbl("Out:")} ${SLATE(toFmt)}`,
  };
}

function compUsageTime(d) {
  const ms = d.durationMs || 0;
  const min = Math.floor(ms / 60000);
  if (min < 1) return null;
  let fmt;
  if (min >= 60) {
    fmt = `${Math.floor(min / 60)}h${min % 60}m`;
  } else {
    fmt = `${min}m`;
  }
  return {
    vis: `Time:      ${fmt}`,
    out: `${lbl(`${ICONS.clock}Time:`)}      ${fmt}`,
  };
}

function compDevRigor(d) {
  const rigor = d.devRigor || "standard";
  const rigorColor = rigor === "lite" ? WARN : rigor === "strict" ? OK : SLATE;
  return {
    vis: `Rigor: ${rigor}`,
    out: `${lbl(`${ICONS.mode}Rigor:`)} ${rigorColor(`${BOLD}${rigor}`)}${RESET}`,
  };
}

function compStyle(d) {
  const style = d.outputStyle || "standard";
  return {
    vis: `Style: ${style}`,
    out: `${lbl(`${ICONS.output}Style:`)} ${style}`,
  };
}

function compProtection(d) {
  const state = d.protectionState || "off";

  let protVis, protOut;
  switch (state) {
    case "on":
      protVis = "Protect: On";
      protOut = `${lbl(`${ICONS.prot}Protect:`)} ${OK(`${BOLD}On`)}${RESET}`;
      break;
    case "sudo":
      protVis = "Protect: Sudo";
      protOut = `${lbl(`${ICONS.prot}Protect:`)} ${SLATE(`${BOLD}Sudo`)}${RESET}`;
      break;
    default:
      protVis = "Protect: Off";
      protOut = `${lbl(`${ICONS.prot}Protect:`)} ${ERR(`${BOLD}Off`)}${RESET}`;
      break;
  }

  return {
    vis: protVis,
    out: protOut,
  };
}

function compProjectType(d) {
  const type = d.projectType;
  if (!type) {
    return {
      vis: "Type:  \u2014",
      out: `${lbl("Type:")}  ${MUTED("\u2014")}`,
    };
  }
  return {
    vis: `Type:  ${type}`,
    out: `${lbl("Type:")}  ${SLATE(`${BOLD}${type}`)}${RESET}`,
  };
}

function compLearning(d) {
  const level = d.learningAutonomy || "advisor";
  return {
    vis: `Learning: ${level}`,
    out: `${lbl("Learning:")} ${level}`,
  };
}

function compCost(d) {
  const cost = d.costUsd;
  if (!cost || cost <= 0) return null;
  const fmt = `$${cost.toFixed(2)}`;
  return {
    vis: `Cost: ${fmt}`,
    out: `${lbl(`${ICONS.usage}Cost:`)} ${AMBER(`${BOLD}${fmt}`)}${RESET}`,
  };
}

function compCacheHit(d) {
  const { cacheCreate, cacheRead, inputTokens } = d;
  if (!cacheCreate && !cacheRead && !inputTokens) return null;
  const total = (inputTokens || 0) + (cacheCreate || 0) + (cacheRead || 0);
  if (total <= 0) return null;
  const pct = Math.floor(((cacheRead || 0) * 100) / total);
  const cacheColor = pct >= 80 ? OK : pct >= 50 ? WARN : MUTED;
  return {
    vis: `Cache: ${pct}%`,
    out: `${lbl(`${ICONS.cache}Cache:`)} ${cacheColor(`${BOLD}${pct}%`)}${RESET}`,
  };
}

function compRateLimits(d) {
  const rl = d.rateLimits;
  if (!rl) return null;
  const fiveHour = rl.five_hour;
  const sevenDay = rl.seven_day;
  if (!fiveHour && !sevenDay) return null;

  const partsOut = [];
  const partsVis = [];

  if (fiveHour && fiveHour.used_percentage != null) {
    const pct = Math.round(fiveHour.used_percentage);
    const reset = fiveHour.resets_at
      ? new Date(fiveHour.resets_at * 1000).toLocaleTimeString([], {
          hour: "2-digit",
          minute: "2-digit",
        })
      : null;
    const color = pct < 60 ? OK : pct < 80 ? WARN : ERR;
    const body = `5h:${pct}%`;
    partsVis.push(reset ? `${body} ${reset}` : body);
    partsOut.push(
      reset ? `${color(body)} ${MUTED(reset)}` : `${color(body)}`,
    );
  }
  if (sevenDay && sevenDay.used_percentage != null) {
    const pct = Math.round(sevenDay.used_percentage);
    const reset = sevenDay.resets_at
      ? new Date(sevenDay.resets_at * 1000).toLocaleDateString([], {
          month: "short",
          day: "numeric",
        })
      : null;
    const color = pct < 60 ? OK : pct < 80 ? WARN : ERR;
    const body = `7d:${pct}%`;
    partsVis.push(reset ? `${body} ${reset}` : body);
    partsOut.push(
      reset ? `${color(body)} ${MUTED(reset)}` : `${color(body)}`,
    );
  }

  if (partsOut.length === 0) return null;
  return {
    vis: `Limits: ${partsVis.join("  ")}`,
    out: `${lbl("Limits:")} ${partsOut.join("  ")}`,
  };
}

function compLinesChanged(d) {
  const la = d.linesAdded || 0;
  const lr = d.linesRemoved || 0;
  if (la === 0 && lr === 0) return null;
  return {
    vis: `+${la}/-${lr}`,
    out: `${OK(`${BOLD}+${la}`)}${RESET}${MUTED("/")}${ERR(`${BOLD}-${lr}`)}${RESET}`,
  };
}

function compLifecycle(d) {
  if (!d.lifecycleSteps) {
    // Always show breadcrumb — all muted when no active feature
    const letters = ["S", "D", "D", "V", "D"].map((l) => MUTED(l));
    return {
      vis: "Lifecycle: S D D V D",
      out: `${lbl("Lifecycle:")} ${letters.join(" ")}`,
    };
  }

  const steps = d.lifecycleSteps;
  const STEP_KEYS = ["spec", "design", "develop", "validate", "deploy"];
  const STEP_LETTERS = {
    spec: "S",
    design: "D",
    develop: "D",
    validate: "V",
    deploy: "D",
  };
  const STRIKE = USE_COLOR ? "\x1b[9m" : "";
  const STRIKE_OFF = USE_COLOR ? "\x1b[29m" : "";
  const letters = STEP_KEYS.map((step) => {
    const status = steps[step] || "pending";
    const letter = STEP_LETTERS[step];
    switch (status) {
      case "done":
        return SLATE(`${BOLD}${letter}${RESET}`);
      case "active":
        return OK(`${BOLD}${letter}${RESET}`);
      case "gated":
        return WARN(`${BOLD}${letter}${RESET}`);
      case "failed":
        return ERR(`${BOLD}${letter}${RESET}`);
      case "skipped":
        return MUTED(`${STRIKE}${letter}${STRIKE_OFF}`);
      default:
        return MUTED(letter);
    }
  });

  // Determine active/gated/pending phase for suffix
  const PHASE_LABELS = {
    spec: "Spec",
    design: "Design",
    develop: "Develop",
    validate: "Validate",
    deploy: "Deploy",
  };
  let phaseName = null;
  for (const step of STEP_KEYS) {
    const st = steps[step] || "pending";
    if (st === "active" || st === "gated") {
      phaseName = PHASE_LABELS[step];
      break;
    }
  }
  if (!phaseName) {
    for (const step of STEP_KEYS) {
      if ((steps[step] || "pending") === "pending") {
        phaseName = PHASE_LABELS[step];
        break;
      }
    }
  }

  const suffix = phaseName ? ` (${phaseName})` : "";
  return {
    vis: `Lifecycle: S D D V D${suffix}`,
    out: `${lbl("Lifecycle:")} ${letters.join(" ")}${phaseName ? ` ${MUTED("(" + phaseName + ")")}` : ""}`,
  };
}

function compPlaceholder() {
  return { vis: "-", out: MUTED("-") };
}

function compFeature(d) {
  const conflict = d.sessionConflict && d.sessionConflict > 1 ? " (!)" : "";
  if (d.lifecycleFeatureId) {
    // Show feature ID ({KEY}-XXX) only, no slug or description
    const featureColor = conflict ? WARN : BRAND;
    return {
      vis: `Feature:   ${d.lifecycleFeatureId}${conflict}`,
      out: `${lbl("Feature:")}   ${featureColor(d.lifecycleFeatureId)}${conflict ? WARN(conflict) : ""}`,
    };
  }
  if (d.isEckProject) {
    return {
      vis: `Feature:   none`,
      out: `${lbl("Feature:")}   ${MUTED("none")}`,
    };
  }
  return null;
}

function compPhase(d) {
  if (!d.lifecycleSteps) return null;
  const STEP_KEYS = ["spec", "design", "develop", "validate", "deploy"];
  const LABELS = {
    spec: "Spec",
    design: "Design",
    develop: "Develop",
    validate: "Validate",
    deploy: "Deploy",
  };
  for (const step of STEP_KEYS) {
    if (d.lifecycleSteps[step] === "active") {
      return {
        vis: `Phase: ${LABELS[step]}`,
        out: `${MUTED("Phase:")} ${OK(LABELS[step])}`,
      };
    }
  }
  // No active step — show next pending
  for (const step of STEP_KEYS) {
    const st = d.lifecycleSteps[step] || "pending";
    if (st === "pending" || st === "gated") {
      return {
        vis: `Phase: ${LABELS[step]}`,
        out: `${MUTED("Phase:")} ${BRAND(LABELS[step])}`,
      };
    }
  }
  return null;
}

function compStage(d) {
  const desc = d.stageDescription;
  if (!desc || desc === "Ready") {
    return {
      vis: "Stage:      Ready",
      out: `${lbl("Stage:")}      ${OK("Ready")}`,
    };
  }
  const truncated = desc.length > 40 ? desc.slice(0, 37) + "..." : desc;
  return {
    vis: `Stage:      ${truncated}`,
    out: `${lbl("Stage:")}      ${OK(truncated)}`,
  };
}

function compPhaseStage(d) {
  if (!d.lifecycleSteps) return null;
  const STEP_KEYS = ["spec", "design", "develop", "validate", "deploy"];
  const LABELS = {
    spec: "Spec",
    design: "Design",
    develop: "Develop",
    validate: "Validate",
    deploy: "Deploy",
  };

  let activePhase = null;
  for (const step of STEP_KEYS) {
    if (d.lifecycleSteps[step] === "active") {
      activePhase = step;
      break;
    }
  }
  if (!activePhase) {
    for (const step of STEP_KEYS) {
      const st = d.lifecycleSteps[step] || "pending";
      if (st === "pending" || st === "gated") {
        activePhase = step;
        break;
      }
    }
  }
  if (!activePhase) return null;

  const label = LABELS[activePhase];
  const isActive = d.lifecycleSteps[activePhase] === "active";
  const stageDesc = d.stageDescription;

  // Extract fraction like "(5/9)" from stageDescription
  const fractionMatch = stageDesc && stageDesc.match(/\((\d+\/\d+)\)/);
  if (fractionMatch) {
    const combined = `${label} ${fractionMatch[1]}`;
    return {
      vis: combined,
      out: isActive ? OK(combined) : BRAND(combined),
    };
  }
  return {
    vis: label,
    out: isActive ? OK(label) : BRAND(label),
  };
}

function compNextCmd(d) {
  const STEP_KEYS = ["spec", "design", "develop", "validate", "deploy"];

  if (!d.lifecycleSteps) {
    if (!d.isEckProject) {
      return {
        vis: "Next: /eck:new-project",
        out: `${lbl("Next:")} ${AMBER("/eck:new-project")}`,
      };
    }
    if (!d.isProjectConfigured) {
      return {
        vis: "Next: /eck:start-project",
        out: `${lbl("Next:")} ${AMBER("/eck:start-project")}`,
      };
    }
    // Backlog features exist: pick next one
    if (d.backlogCount > 0) {
      return {
        vis: "Next: /eck:next-feature",
        out: `${lbl("Next:")} ${AMBER("/eck:next-feature")}`,
      };
    }
    // Nothing in queue: create a feature (green — suggested next action)
    return {
      vis: "Next: /eck:create-feature",
      out: `${lbl("Next:")} ${OK("/eck:create-feature")}`,
    };
  }

  const steps = d.lifecycleSteps;

  for (const step of STEP_KEYS) {
    if (steps[step] === "active") {
      return {
        vis: `Next: /eck:${step}`,
        out: `${lbl("Next:")} ${OK(`/eck:${step}`)}`,
      };
    }
  }

  for (const step of STEP_KEYS) {
    if (steps[step] === "failed") {
      return {
        vis: `Next: /eck:${step}`,
        out: `${lbl("Next:")} ${ERR(`/eck:${step}`)}`,
      };
    }
  }

  for (const step of STEP_KEYS) {
    const status = steps[step] || "pending";
    if (status === "pending" || status === "gated") {
      return {
        vis: `Next: /eck:${step}`,
        out: `${lbl("Next:")} ${BRAND(`/eck:${step}`)}`,
      };
    }
  }

  // All steps done — check backlog before suggesting create
  if (d.backlogCount > 0) {
    return {
      vis: "Next: /eck:next-feature",
      out: `${lbl("Next:")} ${AMBER("/eck:next-feature")}`,
    };
  }
  return {
    vis: "Next: /eck:create-feature",
    out: `${lbl("Next:")} ${SLATE("/eck:create-feature")}`,
  };
}

// Component registry
const COMPONENT_REGISTRY = {
  brand_version: compBrandVersion,
  brand_version_model: compBrandVersionModel,
  model: compModel,
  model_labeled: compModelLabeled,
  status: compStatus,
  context_bar: compContextBar,
  context_combined: compContextCombined,
  tokens_ctx: compTokensCtx,
  tokens_io: compTokensIo,
  usage_time: compUsageTime,
  dev_rigor: compDevRigor,
  style: compStyle,
  protection: compProtection,
  project_type: compProjectType,
  learning: compLearning,
  cost: compCost,
  cache_hit: compCacheHit,
  rate_limits: compRateLimits,
  lines_changed: compLinesChanged,
  lifecycle: compLifecycle,
  next_cmd: compNextCmd,
  feature: compFeature,
  phase: compPhase,
  stage: compStage,
  phase_stage: compPhaseStage,
  placeholder: compPlaceholder,
};

// ─── [grid-renderer] ────────────────────────────────────────────────
// ─── Grid Renderer ─────────────────────────────────────────────────────────

/** Column gap between cells (spaces, no pipes). */
const COL_GAP = 1;

/**
 * Render a row with equal-width grid columns (no separators).
 * Components returning null are filtered out.
 */
function renderRowGrid(components, termWidth) {
  const active = components.filter(Boolean);
  if (active.length === 0) return null;
  if (active.length === 1) {
    return padToWidth(active[0].out, termWidth);
  }

  const gapOverhead = (active.length - 1) * COL_GAP;
  const baseCol = Math.floor((termWidth - gapOverhead) / active.length);
  const remainder = termWidth - gapOverhead - baseCol * active.length;

  let result = "";
  for (let i = 0; i < active.length; i++) {
    const colW = baseCol + (i < remainder ? 1 : 0);
    if (i > 0) result += " ".repeat(COL_GAP);
    result += padToWidth(active[i].out, colW);
  }
  return result;
}

/** Render a row with flow layout (space-separated, no padding). */
function renderRowFlow(components) {
  const active = components.filter(Boolean);
  if (active.length === 0) return null;
  return active.map((c) => c.out).join(" ".repeat(COL_GAP));
}

/** Maximum grid columns at full width. */
const GRID_COLS = 5;

/** Responsive column count based on terminal width. */
function getGridCols(termWidth) {
  if (termWidth >= 140) return 5;
  if (termWidth >= 100) return 4;
  if (termWidth >= 80) return 3;
  return 2;
}

/**
 * Render all rows as a responsive grid table filling terminal width.
 * Column count adapts: 5 (>=180), 4 (>=140), 3 (>=100), 2 (<100).
 * Rows with more components than columns WRAP into additional rows.
 * Rows with fewer components pad with empty cells.
 */
function renderTable(allRowComponents, termWidth) {
  const gridCols = getGridCols(termWidth);

  // 1. Resolve each row: filter nulls
  const rows = allRowComponents.map((comps) => comps.filter(Boolean));

  // 2. Normalize: wrap overflow into new rows, pad short rows
  const normalized = [];
  for (const row of rows) {
    if (row.length === 0) continue;
    for (let i = 0; i < row.length; i += gridCols) {
      const chunk = row.slice(i, i + gridCols);
      while (chunk.length < gridCols) {
        chunk.push({ vis: "", out: "" });
      }
      normalized.push(chunk);
    }
  }

  // 3. Compute proportional column widths based on content
  const totalGaps = (gridCols - 1) * COL_GAP;
  const usable = termWidth - totalGaps;

  // Find the natural (max content) width for each column position
  const naturalWidths = new Array(gridCols).fill(0);
  for (const row of normalized) {
    for (let c = 0; c < gridCols; c++) {
      const w = visWidth(row[c].vis || row[c].out || "");
      if (w > naturalWidths[c]) naturalWidths[c] = w;
    }
  }

  const totalNatural = naturalWidths.reduce((a, b) => a + b, 0);
  const colWidths = new Array(gridCols).fill(0);

  if (totalNatural <= usable) {
    // Content fits — give each column its natural width, distribute surplus
    // evenly so narrow columns get the same extra padding as wide ones
    const surplus = usable - totalNatural;
    const perCol = Math.floor(surplus / gridCols);
    let leftover = surplus - perCol * gridCols;
    for (let c = 0; c < gridCols; c++) {
      colWidths[c] = naturalWidths[c] + perCol + (leftover > 0 ? 1 : 0);
      if (leftover > 0) leftover--;
    }
  } else {
    // Content overflows — allocate proportional to natural widths so columns
    // stay aligned across rows (equal-width collapses uneven columns and
    // makes long cells spill into neighbors visually).
    let allocated = 0;
    for (let c = 0; c < gridCols; c++) {
      const share = Math.floor((naturalWidths[c] * usable) / totalNatural);
      colWidths[c] = Math.max(share, 1);
      allocated += colWidths[c];
    }
    // Distribute remaining slots to the widest natural columns first.
    let leftover = usable - allocated;
    const widestFirst = naturalWidths
      .map((w, i) => [w, i])
      .sort((a, b) => b[0] - a[0])
      .map(([, i]) => i);
    for (const i of widestFirst) {
      if (leftover <= 0) break;
      colWidths[i]++;
      leftover--;
    }
  }

  // 4. Render each row using the computed column widths
  const lines = [];
  for (const row of normalized) {
    let line = "";
    for (let c = 0; c < gridCols; c++) {
      if (c > 0) line += " ".repeat(COL_GAP);
      line += padToWidth(row[c].out, colWidths[c]);
    }
    lines.push(line);
  }
  return lines;
}

// ─── [layout-engine] ────────────────────────────────────────────────
// ─── Layout Engine ─────────────────────────────────────────────────────────

const DEFAULT_LAYOUT = {
  rows: [
    {
      id: "r1",
      components: ["brand_version"],
      align: "grid",
    },
    {
      id: "r2",
      components: [
        "model_labeled",
        "protection",
        "project_type",
        "status",
        "lifecycle",
      ],
      align: "grid",
    },
    {
      id: "r3",
      components: [
        "context_combined",
        "tokens_io",
        "dev_rigor",
        "usage_time",
        "rate_limits",
      ],
      align: "grid",
    },
    {
      id: "r4",
      components: ["cost", "cache_hit", "style", "learning", "feature"],
      align: "grid",
    },
  ],
};

/** Load statusline config from disk, validate version. */
function loadConfig() {
  const configPath = path.join(
    os.homedir(),
    ".claude",
    "evolv-coder-kit",
    "statusline-config.json",
  );
  try {
    if (!fs.existsSync(configPath)) return null;
    const config = JSON.parse(fs.readFileSync(configPath, "utf-8"));
    if (config.version !== 1) return null;
    return config;
  } catch {
    return null;
  }
}

/** Placeholder for unknown/null components — renders as a dash. */
const PLACEHOLDER_COMPONENT = { vis: "\u2014", out: MUTED("\u2014") };

/** Collect component arrays for configurable rows (R1-R3 or custom). */
function collectConfigRows(layout, dataBag) {
  const rows = [];
  for (const row of layout.rows) {
    const components = row.components.map((name) => {
      const fn = COMPONENT_REGISTRY[name];
      if (!fn) return PLACEHOLDER_COMPONENT;
      return fn(dataBag) || PLACEHOLDER_COMPONENT;
    });
    rows.push(components);
  }
  return rows;
}

/** Legacy: render config rows individually (for custom flow layouts). */
function renderConfigRows(layout, dataBag, termWidth) {
  const lines = [];
  for (const row of layout.rows) {
    const components = row.components.map((name) =>
      COMPONENT_REGISTRY[name] ? COMPONENT_REGISTRY[name](dataBag) : null,
    );
    const rendered =
      row.align === "flow"
        ? renderRowFlow(components)
        : renderRowGrid(components, termWidth);
    if (rendered) lines.push(rendered);
  }
  return lines;
}

/** Build grid-compatible components for a directory row. */
function makeDirComponents(
  label,
  dirPath,
  branch,
  dirty,
  icon,
  linesAdded,
  linesRemoved,
  extraComp,
) {
  const dirComp = {
    vis: `${label}: ${dirPath}`,
    out: `${lbl(`${icon}${label}:`)} ${dirPath}`,
  };
  const rawBranch = branch || "\u2014";
  // Long branch names dominate the col-2 natural width and force the whole
  // table into the overflow path. Cap at 30 chars with an ellipsis so the
  // branch column stays a sane width across most terminals.
  const BRANCH_MAX = 30;
  const branchStr =
    rawBranch.length > BRANCH_MAX
      ? rawBranch.slice(0, BRANCH_MAX - 1) + "\u2026"
      : rawBranch;
  const branchComp = {
    vis: `Branch: ${branchStr}`,
    out: `${lbl(`${ICONS.branch}Branch:`)} ${SLATE(`${BOLD}${branchStr}`)}${RESET}`,
  };
  let dirtyComp;
  if (!dirty || dirty === 0 || dirty === "0") {
    dirtyComp = {
      vis: "Files: Clean",
      out: `${lbl(`${ICONS.dirty}Files:`)} ${OK("Clean")}`,
    };
  } else {
    dirtyComp = {
      vis: `Files: ${dirty} Changed`,
      out: `${lbl(`${ICONS.dirty}Files:`)} ${WARN(`${BOLD}${dirty}`)}${RESET} ${MUTED("Changed")}`,
    };
  }
  let linesComp;
  const la = linesAdded || 0;
  const lr = linesRemoved || 0;
  if (la > 0 || lr > 0) {
    linesComp = {
      vis: `Lines: +${la}/-${lr}`,
      out: `${lbl(`${ICONS.files}Lines:`)} ${OK(`${BOLD}+${la}`)}${RESET}${MUTED("/")}${ERR(`${BOLD}-${lr}`)}${RESET}`,
    };
  } else {
    linesComp = {
      vis: "Lines: \u2014",
      out: `${lbl(`${ICONS.files}Lines:`)} ${MUTED("\u2014")}`,
    };
  }
  const result = [dirComp, branchComp, dirtyComp, linesComp];
  if (extraComp) result.push(extraComp);
  return result;
}

/** Collect component arrays for directory rows (project, current, added dirs). */
function collectDirRows(dataBag) {
  const rows = [];

  const {
    projectDir,
    projectDirShort,
    projectBranch,
    projectDirty,
    currentDir,
    currentDirShort,
    currentBranch,
    currentDirty,
    addedDirs,
    stdinProjectDir,
    stdinCwd,
    linesAdded,
    linesRemoved,
  } = dataBag;

  // Compute next_cmd for first dir row's C5
  const nextCmdComp = compNextCmd(dataBag) || PLACEHOLDER_COMPONENT;

  let showCurrentRow = true;

  if (projectDir) {
    rows.push(
      makeDirComponents(
        "Project",
        projectDirShort,
        projectBranch,
        projectDirty,
        ICONS.project,
        linesAdded,
        linesRemoved,
        nextCmdComp,
      ),
    );
    if (stdinProjectDir === stdinCwd) {
      showCurrentRow = false;
    }
  } else {
    rows.push(
      makeDirComponents(
        "Current",
        currentDirShort,
        currentBranch,
        currentDirty,
        ICONS.folder,
        linesAdded,
        linesRemoved,
        nextCmdComp,
      ),
    );
    showCurrentRow = false;
  }

  if (showCurrentRow) {
    rows.push(
      makeDirComponents(
        "Current",
        currentDirShort,
        currentBranch,
        currentDirty,
        ICONS.folder,
        undefined,
        undefined,
        PLACEHOLDER_COMPONENT,
      ),
    );
  }

  if (addedDirs && addedDirs.length > 0) {
    for (const dir of addedDirs) {
      const short = shortenPath(dir, dataBag.pathMax || 40);
      const branch = gitBranch(dir);
      const dirty = gitDirtyCount(dir);
      rows.push(
        makeDirComponents(
          "Added",
          short,
          branch,
          dirty,
          ICONS.folder,
          undefined,
          undefined,
          PLACEHOLDER_COMPONENT,
        ),
      );
    }
  }

  return rows;
}

/** Legacy wrapper for backward compatibility with tests. */
function renderDirRows(dataBag) {
  const rows = collectDirRows(dataBag);
  const tw = dataBag.termWidth || 80;
  return rows.map((comps) => renderRowGrid(comps, tw)).filter(Boolean);
}

// ─── [data-gathering] ───────────────────────────────────────────────
// ─── Data Gathering ────────────────────────────────────────────────────────

/** Read all stdin synchronously. */
function parseStdin() {
  try {
    if (process.stdin.isTTY) return {};
    const buf = fs.readFileSync(0, "utf-8");
    if (!buf || !buf.trim()) return {};
    return JSON.parse(buf);
  } catch {
    return {};
  }
}

/** Resolve session ID from env var or derive from PWD + PPID. */
function resolveSessionId() {
  if (process.env.CLAUDE_SESSION_ID) {
    return process.env.CLAUDE_SESSION_ID;
  }
  const input = `${process.cwd()}${process.ppid}`;
  return crypto.createHash("sha256").update(input).digest("hex").slice(0, 8);
}

/** Read bridge file for phase description and cached git branch. */
function readBridgeFile(sessionId) {
  const bridgePath = path.join(os.tmpdir(), `claude-ctx-${sessionId}.json`);
  try {
    if (!fs.existsSync(bridgePath)) return null;
    return JSON.parse(fs.readFileSync(bridgePath, "utf-8"));
  } catch {
    return null;
  }
}

/** Read ECK version from global-state.json. */
function readEckVersion() {
  const statePath = path.join(
    os.homedir(),
    ".claude",
    "evolv-coder-kit",
    "global-state.json",
  );
  try {
    if (!fs.existsSync(statePath)) return null;
    const data = JSON.parse(fs.readFileSync(statePath, "utf-8"));
    return data.version || null;
  } catch {
    return null;
  }
}

/**
 * Read project entry from registry.yaml by matching path to projectDir or cwd.
 * Returns { dev_rigor, project_type, learning_autonomy } or null fields.
 * Falls back to project-constants.md, then user-preferences.json.
 */
function readRegistryProject(projectDir) {
  const result = {
    devRigor: null,
    projectType: null,
    learningAutonomy: null,
    state: null,
    auditStatus: null,
    isStale: false,
  };
  const registryPath = path.join(
    os.homedir(),
    ".claude",
    "evolv-coder-kit",
    "registry.yaml",
  );

  // Try registry first
  try {
    if (fs.existsSync(registryPath)) {
      const content = fs.readFileSync(registryPath, "utf-8");
      // Verify schema_version (accept both schema_version and version for compat)
      const schemaMatch =
        content.match(/^schema_version:\s*(\d+)/m) ||
        content.match(/^version:\s*(\d+)/m);
      if (!schemaMatch) {
        // Unrecognized schema — skip registry
      } else if (projectDir) {
        // Simple YAML parsing: find project entry matching the path
        // Look for project_path: "..." that matches projectDir, then read fields after it
        const escaped = projectDir.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
        const pathMatch =
          content.match(new RegExp(`project_path:\\s*"?${escaped}"?`)) ||
          content.match(new RegExp(`path:\\s*"?${escaped}"?`));
        if (pathMatch) {
          // Extract the block for this project only — bounded by next sibling key or EOF
          const blockStart = Math.max(
            0,
            content.lastIndexOf("\n", pathMatch.index),
          );
          // Find next project key at same indent level (2-space indent + key:)
          const restAfterMatch = content.slice(
            pathMatch.index + pathMatch[0].length,
          );
          const nextSiblingMatch = restAfterMatch.match(
            /\n  [a-zA-Z][\w-]*:\s*\n/,
          );
          const blockEnd = nextSiblingMatch
            ? pathMatch.index + pathMatch[0].length + nextSiblingMatch.index
            : content.length;
          const projectBlock = content.slice(blockStart, blockEnd);
          const rigorMatch = projectBlock.match(/dev_rigor:\s*"?(\w+)"?/);
          const typeMatch = projectBlock.match(/project_type:\s*"?([^"\n]+)"?/);
          const learnMatch = projectBlock.match(
            /learning_autonomy:\s*"?(\w+)"?/,
          );
          const stateMatch = projectBlock.match(/state:\s*"?(\w+)"?/);
          const auditMatch = projectBlock.match(/audit_status:\s*"?(\w+)"?/);
          if (rigorMatch) result.devRigor = rigorMatch[1];
          if (typeMatch) result.projectType = typeMatch[1].trim();
          if (learnMatch) result.learningAutonomy = learnMatch[1];
          if (stateMatch) result.state = stateMatch[1];
          if (auditMatch) result.auditStatus = auditMatch[1];
          // Compute staleness from last_audit date
          const auditDateMatch = projectBlock.match(
            /last_audit:\s*\n\s+date:\s*"?([^"\n]+)"?/,
          );
          if (auditDateMatch && auditDateMatch[1] !== "null") {
            const auditDate = new Date(auditDateMatch[1]);
            const daysSince =
              (Date.now() - auditDate.getTime()) / (1000 * 60 * 60 * 24);
            result.isStale = daysSince > 7;
          }
        }
      }
    }
  } catch {
    /* ignore */
  }

  // Fallback: project-constants.md
  if (projectDir && (!result.devRigor || !result.projectType)) {
    try {
      const pcFile = path.join(projectDir, ".claude", "project-constants.md");
      if (fs.existsSync(pcFile)) {
        const content = fs.readFileSync(pcFile, "utf-8");
        if (!result.devRigor) {
          const match =
            content.match(/^\|\s*DEV_RIGOR\s*\|\s*(\w+)\s*\|/m) ||
            content.match(/^\|\s*DEV_MODE\s*\|\s*(\w+)\s*\|/m);
          if (match) {
            const valueMap = { rapid: "lite", rigorous: "strict" };
            result.devRigor = valueMap[match[1]] || match[1];
          }
        }
        if (!result.projectType) {
          const match =
            content.match(/^\|\s*PROJECT_TYPE\s*\|\s*([^\|]+)\s*\|/m) ||
            content.match(/^\|\s*PROJECT_MATURITY\s*\|\s*([^\|]+)\s*\|/m);
          if (match) {
            const val = match[1].trim();
            // Map legacy numbers to labels
            const numMap = {
              1: "PoC",
              2: "Prototype",
              3: "MVP",
              4: "Pilot",
              5: "Production",
            };
            result.projectType = numMap[val] || val;
          }
        }
        if (!result.learningAutonomy) {
          const match = content.match(
            /^\|\s*LEARNING_AUTONOMY\s*\|\s*(\w+)\s*\|/m,
          );
          if (match) result.learningAutonomy = match[1];
        }
      }
    } catch {
      /* ignore */
    }
  }

  // Fallback: user preferences
  try {
    const prefsPath = path.join(
      os.homedir(),
      ".claude",
      "evolv-coder-kit",
      "user-preferences.json",
    );
    if (fs.existsSync(prefsPath)) {
      const prefs = JSON.parse(fs.readFileSync(prefsPath, "utf-8"));
      if (!result.devRigor) {
        const rigor = prefs.devRigor || prefs.devMode;
        if (rigor) {
          const valueMap = { rapid: "lite", rigorous: "strict" };
          result.devRigor = valueMap[rigor] || rigor;
        }
      }
      if (!result.learningAutonomy && prefs.learningAutonomy) {
        result.learningAutonomy = prefs.learningAutonomy;
      }
    }
  } catch {
    /* ignore */
  }

  return {
    devRigor: result.devRigor || "standard",
    projectType: result.projectType || null,
    learningAutonomy: result.learningAutonomy || "advisor",
    state: result.state || null,
    auditStatus: result.auditStatus || null,
    isStale: result.isStale || false,
  };
}

/** Read output style from settings.json or stdin. */
function readOutputStyle(stdinStyle) {
  // settings.json takes priority
  try {
    const settingsPath = path.join(os.homedir(), ".claude", "settings.json");
    if (fs.existsSync(settingsPath)) {
      const settings = JSON.parse(fs.readFileSync(settingsPath, "utf-8"));
      if (settings.outputStyle) {
        return settings.outputStyle.replace(/^evolv-/, "");
      }
    }
  } catch {
    /* ignore */
  }

  if (stdinStyle) return stdinStyle.replace(/^evolv-/, "");

  // Fallback: user preferences
  try {
    const prefsPath = path.join(
      os.homedir(),
      ".claude",
      "evolv-coder-kit",
      "user-preferences.json",
    );
    if (fs.existsSync(prefsPath)) {
      const prefs = JSON.parse(fs.readFileSync(prefsPath, "utf-8"));
      if (prefs.outputMode) return prefs.outputMode.replace(/^evolv-/, "");
    }
  } catch {
    /* ignore */
  }
  return "standard";
}

/** Read protection state from global-state.json. */
function readProtection() {
  const statePath = path.join(
    os.homedir(),
    ".claude",
    "evolv-coder-kit",
    "global-state.json",
  );
  try {
    if (!fs.existsSync(statePath)) return "off";
    const data = JSON.parse(fs.readFileSync(statePath, "utf-8"));
    return data.protectionsEnabled !== false ? "on" : "off";
  } catch {
    /* ignore */
  }
  return "off";
}

/** Get git branch for a directory. */
function gitBranch(dir) {
  try {
    return (
      execSync(`git -C "${dir}" rev-parse --abbrev-ref HEAD`, {
        encoding: "utf-8",
        timeout: 3000,
        stdio: ["pipe", "pipe", "pipe"],
      }).trim() || null
    );
  } catch {
    return null;
  }
}

/** Get count of dirty (modified) files in a git repo. */
function gitDirtyCount(dir) {
  try {
    const out = execSync(`git -C "${dir}" status --porcelain`, {
      encoding: "utf-8",
      timeout: 3000,
      stdio: ["pipe", "pipe", "pipe"],
    });
    const lines = out.trim().split("\n").filter(Boolean);
    return lines.length;
  } catch {
    return 0;
  }
}

/** Resolve git toplevel for a directory (returns null if not a git repo). */
function gitToplevel(dir) {
  try {
    return (
      execSync(`git -C "${dir}" rev-parse --show-toplevel`, {
        encoding: "utf-8",
        timeout: 3000,
        stdio: ["pipe", "pipe", "pipe"],
      }).trim() || null
    );
  } catch {
    return null;
  }
}

/** Deduplicate added dirs against project dir and cwd, and within the list.
 *  Compares both exact paths AND git repo identity (toplevel) to catch
 *  symlinks, mount aliases, and multiple paths into the same repo. */
function deduplicateDirs(addedDirs, projectDir, cwd) {
  if (!addedDirs || addedDirs.length === 0) return [];
  const seenPaths = new Set();
  const seenRepos = new Set();
  for (const dir of [projectDir, cwd].filter(Boolean)) {
    seenPaths.add(dir);
    const top = gitToplevel(dir);
    if (top) seenRepos.add(top);
  }
  const result = [];
  for (const dir of addedDirs) {
    if (!dir || seenPaths.has(dir)) continue;
    const top = gitToplevel(dir);
    if (top && seenRepos.has(top)) continue;
    seenPaths.add(dir);
    if (top) seenRepos.add(top);
    result.push(dir);
  }
  return result;
}

/** Write bridge file atomically for compatibility with update-stage.sh and context-monitor. */
function writeBridge(sessionId, contextPercent, stageDesc, branch) {
  const bridgePath = path.join(os.tmpdir(), `claude-ctx-${sessionId}.json`);
  const tmpPath = bridgePath + ".tmp";
  const data = {
    contextPercent: contextPercent != null ? contextPercent : null,
    stageDescription: stageDesc || "Ready",
    gitBranch: branch || "",
    timestamp: Math.floor(Date.now() / 1000),
    sessionId,
  };
  try {
    fs.writeFileSync(tmpPath, JSON.stringify(data), "utf-8");
    fs.renameSync(tmpPath, bridgePath);
  } catch {
    /* ignore write errors */
  }
}

/** Persist session ID to project .claude/.session-id so update-stage.sh can locate the bridge file. */
function writeSessionId(sessionId, projectDir) {
  if (!sessionId) return;
  const dirs = [projectDir, process.cwd()].filter(Boolean);
  for (const dir of dirs) {
    const claudeDir = path.join(dir, ".claude");
    const idPath = path.join(claudeDir, ".session-id");
    try {
      if (!fs.existsSync(claudeDir)) continue;
      fs.writeFileSync(idPath, sessionId + "\n", "utf-8");
      return; // write to first valid location
    } catch {
      /* ignore */
    }
  }
}

// ─── Session Registry (lightweight duplicates — statusline is standalone) ───

/** Write per-session JSON file to .claude/sessions/{sessionId}.json (heartbeat). */
function writeSessionFileStatus(projectDir, sessionId, data) {
  if (!projectDir || !sessionId) return;
  const sessionsDir = path.join(projectDir, ".claude", "sessions");
  try {
    fs.mkdirSync(sessionsDir, { recursive: true });
    const filePath = path.join(sessionsDir, `${sessionId}.json`);
    const tmpPath = filePath + ".tmp";
    const payload = {
      schema_version: 1,
      session_id: sessionId,
      active_feature: data.active_feature || null,
      plan_mode: data.plan_mode || false,
      stage_description: data.stage_description || null,
      git_branch: data.git_branch || null,
      last_heartbeat: new Date().toISOString(),
    };
    fs.writeFileSync(tmpPath, JSON.stringify(payload, null, 2) + "\n", "utf-8");
    fs.renameSync(tmpPath, filePath);
  } catch {
    /* ignore — non-critical */
  }
}

/** Count non-stale sessions active on a given feature slug. */
function countActiveSessionsStatus(projectDir, featureSlug, staleMinutes) {
  if (!projectDir || !featureSlug) return 0;
  if (staleMinutes === undefined) staleMinutes = 5;
  const sessionsDir = path.join(projectDir, ".claude", "sessions");
  if (!fs.existsSync(sessionsDir)) return 0;
  const cutoff = Date.now() - staleMinutes * 60 * 1000;
  let count = 0;
  try {
    for (const entry of fs.readdirSync(sessionsDir)) {
      if (!entry.endsWith(".json")) continue;
      try {
        const filePath = path.join(sessionsDir, entry);
        const stat = fs.statSync(filePath);
        if (stat.mtimeMs < cutoff) continue;
        const data = JSON.parse(fs.readFileSync(filePath, "utf-8"));
        if (data.active_feature === featureSlug) count++;
      } catch {
        /* ignore */
      }
    }
  } catch {
    /* ignore */
  }
  return count;
}

/** Resolve active feature: session file first, lifecycle.json root fallback. */
function resolveActiveFeatureStatus(projectDir, sessionId) {
  if (sessionId && projectDir) {
    try {
      const filePath = path.join(
        projectDir,
        ".claude",
        "sessions",
        `${sessionId}.json`,
      );
      if (fs.existsSync(filePath)) {
        const data = JSON.parse(fs.readFileSync(filePath, "utf-8"));
        if (data.active_feature) return data.active_feature;
      }
    } catch {
      /* fall through */
    }
  }
  // Fallback: lifecycle-state.json (v6+), then lifecycle.json (legacy)
  if (!projectDir) return null;
  try {
    const statePath = path.join(projectDir, ".claude", "lifecycle-state.json");
    if (fs.existsSync(statePath)) {
      const state = JSON.parse(fs.readFileSync(statePath, "utf-8"));
      if (state.active_feature) return state.active_feature;
    }
  } catch {
    /* fall through */
  }
  try {
    const lcPath = path.join(projectDir, ".claude", "lifecycle.json");
    if (!fs.existsSync(lcPath)) return null;
    const data = JSON.parse(fs.readFileSync(lcPath, "utf-8"));
    return data.active_feature || null;
  } catch {
    return null;
  }
}

/** Prune session files older than ttlHours. Called periodically from statusline. */
function pruneDeadSessionsStatus(projectDir, ttlHours) {
  if (!projectDir) return;
  if (ttlHours === undefined) ttlHours = 24;
  const sessionsDir = path.join(projectDir, ".claude", "sessions");
  if (!fs.existsSync(sessionsDir)) return;
  const cutoff = Date.now() - ttlHours * 60 * 60 * 1000;
  try {
    for (const entry of fs.readdirSync(sessionsDir)) {
      if (!entry.endsWith(".json")) continue;
      try {
        const filePath = path.join(sessionsDir, entry);
        const stat = fs.statSync(filePath);
        if (stat.mtimeMs < cutoff) fs.unlinkSync(filePath);
      } catch {
        /* ignore */
      }
    }
  } catch {
    /* ignore */
  }
}

// ─── [lifecycle] ────────────────────────────────────────────────────
// ─── Shared lifecycle reader (single parse per render cycle) ─────────────

/**
 * Read and parse lifecycle.json once. All other functions accept the parsed
 * data as an optional parameter to avoid redundant disk I/O. (#625)
 */
function readLifecycleData(projectDir) {
  if (!projectDir) return null;
  try {
    // Try split files first (v6+)
    const regPath = path.join(projectDir, ".claude", "lifecycle-registry.json");
    if (fs.existsSync(regPath)) {
      const registry = JSON.parse(fs.readFileSync(regPath, "utf-8"));
      // Read active_feature from state file
      let activeFeature = null;
      try {
        const statePath = path.join(
          projectDir,
          ".claude",
          "lifecycle-state.json",
        );
        if (fs.existsSync(statePath)) {
          const state = JSON.parse(fs.readFileSync(statePath, "utf-8"));
          activeFeature = state.active_feature || null;
        }
      } catch {
        /* ignore */
      }
      return {
        schema_version: registry.schema_version,
        active_feature: activeFeature,
        features: registry.features,
        project_key: registry.project_key || null,
      };
    }
    // Legacy fallback
    const lcPath = path.join(projectDir, ".claude", "lifecycle.json");
    if (!fs.existsSync(lcPath)) return null;
    return JSON.parse(fs.readFileSync(lcPath, "utf-8"));
  } catch {
    return null;
  }
}

// ─── Lifecycle ──────────────────────────────────────────────────────────────

/** Read lifecycle steps from .claude/lifecycle.json in the project dir. */
function readLifecycleSteps(projectDir, _data) {
  const data = _data || readLifecycleData(projectDir);
  if (!data || !data.active_feature || !data.features) return null;
  const feature = data.features[data.active_feature];
  if (!feature || !feature.steps) return null;
  const result = {};
  for (const step of ["spec", "design", "develop", "validate", "deploy"]) {
    result[step] = feature.steps[step] ? feature.steps[step].status : "pending";
  }
  return result;
}

/** Extract issue number from lifecycle feature slug or display name. */
function readLifecycleFeature(projectDir, _data) {
  const data = _data || readLifecycleData(projectDir);
  if (!data || !data.active_feature || !data.features) return null;
  const feature = data.features[data.active_feature];
  if (!feature) return null;
  // Prefer explicit issue_number field (set by register --issue)
  if (feature.issue_number) return `#${feature.issue_number}`;
  // Fall back: extract issue number from display_name or slug
  const name = feature.display_name || data.active_feature;
  // Match patterns like "#123", "GH-123", or bare numbers at start/after dash
  const issueMatch = name.match(/#(\d+)/);
  if (issueMatch) return `#${issueMatch[1]}`;
  // Match "123-some-feature" slug pattern
  const slugMatch = data.active_feature.match(/^(\d+)-/);
  if (slugMatch) return `#${slugMatch[1]}`;
  // Shorten the feature name for display
  const short =
    data.active_feature.length > 12
      ? data.active_feature.slice(0, 10) + ".."
      : data.active_feature;
  return short;
}

/**
 * Count parked (inactive, incomplete) features in lifecycle.json.
 * Used by compNextCmd to suggest /resume-feature when features are waiting.
 */
function readParkedCount(projectDir, _data) {
  const data = _data || readLifecycleData(projectDir);
  if (!data || !data.features) return 0;
  const STEPS = ["spec", "design", "develop", "validate", "deploy"];
  let count = 0;
  for (const [slug, feature] of Object.entries(data.features)) {
    if (feature.completed_at) continue;
    if (slug === data.active_feature) continue;
    const allDone = STEPS.every((s) => {
      const st =
        feature.steps && feature.steps[s] ? feature.steps[s].status : "pending";
      return st === "done" || st === "skipped";
    });
    if (!allDone) count++;
  }
  return count;
}

/**
 * Count features in the backlog (never started, not archived/completed/active).
 */
function readBacklogCount(projectDir, _data) {
  const data = _data || readLifecycleData(projectDir);
  if (!data || !data.features) return 0;
  let count = 0;
  for (const [slug, feature] of Object.entries(data.features)) {
    if (feature.completed_at) continue;
    if (feature.archived_at) continue;
    if (feature.first_started_at) continue;
    if (slug === data.active_feature) continue;
    count++;
  }
  return count;
}

/**
 * Find the best incomplete feature to suggest when no feature is active.
 * Returns { step, displayName } for the most recently updated parked feature
 * (previously started, not archived), or null if no incomplete features exist.
 */
function readBestIncompleteFeature(projectDir, _data) {
  const data = _data || readLifecycleData(projectDir);
  if (!data || !data.features) return null;
  const STEPS = ["spec", "design", "develop", "validate", "deploy"];
  let best = null;
  for (const [slug, feature] of Object.entries(data.features)) {
    if (feature.completed_at) continue;
    if (feature.archived_at) continue;
    if (slug === data.active_feature) continue;
    // Only consider previously-started features (parked, not backlog)
    if (!feature.first_started_at) continue;
    // Check if all steps are done/skipped — skip fully completed features
    const allDone = STEPS.every((s) => {
      const st =
        feature.steps && feature.steps[s] ? feature.steps[s].status : "pending";
      return st === "done" || st === "skipped";
    });
    if (allDone) continue;
    // Find the next pending/gated step
    let nextStep = null;
    for (const s of STEPS) {
      const st =
        feature.steps && feature.steps[s] ? feature.steps[s].status : "pending";
      if (st === "pending" || st === "gated" || st === "failed") {
        nextStep = s;
        break;
      }
    }
    if (!nextStep) continue;
    // Pick most recently updated
    const updatedAt = feature.updated_at || feature.created_at || "";
    if (!best || updatedAt > best.updatedAt) {
      best = {
        step: nextStep,
        displayName: feature.display_name || slug,
        updatedAt,
      };
    }
  }
  return best ? { step: best.step, displayName: best.displayName } : null;
}

/** Return the active feature ID ({KEY}-XXX) for display, resolving slugs if needed. */
function readLifecycleFeatureId(projectDir, _data) {
  const data = _data || readLifecycleData(projectDir);
  if (!data || !data.active_feature) return null;

  // Fast path: already {KEY}-N format. KEY may contain multiple letter
  // segments separated by '-' (e.g. ECK-DEV-12, FEAT-74, WA-1).
  if (/^[A-Z][A-Z0-9]*(?:-[A-Z][A-Z0-9]*)*-\d+$/i.test(data.active_feature)) {
    return data.active_feature;
  }

  // Slug fallback: look up feature ID in registry or features.json
  if (projectDir) {
    // Try registry first (v6+)
    try {
      const regPath = path.join(
        projectDir,
        ".claude",
        "lifecycle-registry.json",
      );
      if (fs.existsSync(regPath)) {
        const regData = JSON.parse(fs.readFileSync(regPath, "utf-8"));
        for (const [id, feat] of Object.entries(regData.features || {})) {
          if (feat.slug === data.active_feature) return id;
        }
      }
    } catch {
      /* ignore */
    }
    // Fall back to features.json
    try {
      const fpPath = path.join(projectDir, ".claude", "features.json");
      if (fs.existsSync(fpPath)) {
        const fData = JSON.parse(fs.readFileSync(fpPath, "utf-8"));
        for (const [id, feat] of Object.entries(fData.features || {})) {
          if (feat.slug === data.active_feature) return id;
        }
      }
    } catch {
      /* ignore */
    }
  }

  // Issue number fallback: derive feature ID from the feature's issue_number
  const feature = data.features ? data.features[data.active_feature] : null;
  if (feature && feature.issue_number) {
    // Read project_key from registry or features.json
    let prefix = "FEAT";
    if (data.project_key) {
      prefix = data.project_key;
    } else if (projectDir) {
      try {
        const fpPath = path.join(projectDir, ".claude", "features.json");
        if (fs.existsSync(fpPath)) {
          const fData = JSON.parse(fs.readFileSync(fpPath, "utf-8"));
          if (fData.project_key) prefix = fData.project_key;
        }
      } catch {
        /* ignore */
      }
    }
    return `${prefix}-${feature.issue_number}`;
  }

  // Last resort: return raw value
  return data.active_feature;
}

// ─── [main] ─────────────────────────────────────────────────────────
// ─── Main ──────────────────────────────────────────────────────────────────

function main() {
  // Parse stdin (single JSON.parse replaces 19 jq calls)
  const stdin = parseStdin();

  // Terminal width — Claude Code >=2.1.139 runs statusLine hooks without TTY
  // access, so stty/tput can't reach the real terminal (tput cols returns a
  // stub 80 with no TTY). The stdin schema has no terminal-width field, so we
  // rely on COLUMNS env (if the user exports it) and otherwise default wide.
  let termWidth = parseInt(process.env.COLUMNS, 10) || 0;
  if (!termWidth) {
    try {
      termWidth = process.stdout.columns || process.stderr.columns || 0;
    } catch {
      /* ignore */
    }
  }
  if (!termWidth) termWidth = 160;
  if (termWidth > 220) termWidth = 220;

  // Session + bridge
  const sessionId = resolveSessionId();
  const bridge = readBridgeFile(sessionId);

  // Extract model — handle both string and object forms
  let model;
  if (stdin.model) {
    if (typeof stdin.model === "object") {
      model = stdin.model.display_name || stdin.model.id || "unknown";
    } else {
      model = String(stdin.model);
    }
  } else {
    model = "unknown";
  }

  // Context data
  const contextPercent =
    stdin.context_window && stdin.context_window.used_percentage != null
      ? Math.round(stdin.context_window.used_percentage)
      : null;
  const contextWindowSize =
    stdin.context_window && stdin.context_window.context_window_size
      ? stdin.context_window.context_window_size
      : null;

  // Duration
  const durationMs =
    (stdin.cost && stdin.cost.total_duration_ms) || stdin.duration_ms || 0;

  // Cost
  const costUsd =
    (stdin.cost && stdin.cost.total_cost_usd) || stdin.cost_usd || 0;

  // I/O tokens
  const totalInputTokens =
    stdin.context_window && stdin.context_window.total_input_tokens
      ? stdin.context_window.total_input_tokens
      : 0;
  const totalOutputTokens =
    stdin.context_window && stdin.context_window.total_output_tokens
      ? stdin.context_window.total_output_tokens
      : 0;

  // Cache tokens
  const currentUsage =
    stdin.context_window && stdin.context_window.current_usage;
  const cacheCreate = currentUsage
    ? currentUsage.cache_creation_input_tokens
    : 0;
  const cacheRead = currentUsage ? currentUsage.cache_read_input_tokens : 0;
  const inputTokens = currentUsage ? currentUsage.input_tokens : 0;

  // Lines changed
  const linesAdded = (stdin.cost && stdin.cost.total_lines_added) || 0;
  const linesRemoved = (stdin.cost && stdin.cost.total_lines_removed) || 0;

  // Directories
  const stdinCwd =
    (stdin.workspace && stdin.workspace.current_dir) ||
    stdin.cwd ||
    process.cwd();
  const stdinProjectDir =
    stdin.workspace && stdin.workspace.project_dir
      ? stdin.workspace.project_dir
      : null;
  const stdinAddedDirs =
    stdin.workspace && Array.isArray(stdin.workspace.added_dirs)
      ? stdin.workspace.added_dirs
      : [];

  // The live status channel skills write through is `stageDescription` (via
  // update-stage.sh → bridge file). compStatus reads it directly now, so we
  // no longer need a separate `statusText` stub.
  const stageDesc = (bridge && bridge.stageDescription) || "Ready";

  // Rate limits (Claude.ai Pro/Max only) — optional, may be undefined.
  const rateLimits = stdin.rate_limits || null;

  // Data gathering
  const bridgeBranch = bridge && bridge.gitBranch ? bridge.gitBranch : null;
  const projectBranch = stdinProjectDir ? gitBranch(stdinProjectDir) : null;
  const currentBranch = bridgeBranch || gitBranch(stdinCwd);
  const projectDirty = stdinProjectDir ? gitDirtyCount(stdinProjectDir) : null;
  const currentDirty = gitDirtyCount(stdinCwd);
  const dedupedAdded = deduplicateDirs(
    stdinAddedDirs,
    stdinProjectDir,
    stdinCwd,
  );

  const pathMax = Math.min(Math.max(Math.floor(termWidth / 3), 30), 60);

  // Output style from stdin
  const stdinOutputStyle =
    stdin.output_style && stdin.output_style.name
      ? stdin.output_style.name
      : null;

  // Read lifecycle state — single parse for all lifecycle queries (#625)
  const resolvedActiveFeature = resolveActiveFeatureStatus(
    stdinProjectDir || stdinCwd,
    sessionId,
  );
  const lcData = readLifecycleData(stdinProjectDir || stdinCwd);
  const lifecycleSteps = readLifecycleSteps(
    stdinProjectDir || stdinCwd,
    lcData,
  );
  const lifecycleFeature = readLifecycleFeature(
    stdinProjectDir || stdinCwd,
    lcData,
  );
  const lifecycleFeatureId = readLifecycleFeatureId(
    stdinProjectDir || stdinCwd,
    lcData,
  );

  // Detect project setup state for next-cmd component
  const projectBaseDir = stdinProjectDir || stdinCwd;
  const isEckProject = (() => {
    if (!projectBaseDir) return false;
    try {
      return fs.existsSync(
        path.join(projectBaseDir, ".claude", "project-constants.md"),
      );
    } catch {
      return false;
    }
  })();
  // Multi-signal project configuration detection:
  // 1. lifecycle.json exists → features tracked, project is configured
  // 2. .start-project-completed sentinel → backward compat
  // 3. PROJECT_KEY filled in project-constants.md → template was populated
  const isProjectConfigured = (() => {
    if (!projectBaseDir) return false;
    const claudeDir = path.join(projectBaseDir, ".claude");
    try {
      // Signal 1: lifecycle-registry.json or lifecycle.json exists (most common for active projects)
      if (fs.existsSync(path.join(claudeDir, "lifecycle-registry.json")))
        return true;
      if (fs.existsSync(path.join(claudeDir, "lifecycle.json"))) return true;
      // Signal 2: sentinel file (backward compat with /start-project)
      if (fs.existsSync(path.join(claudeDir, ".start-project-completed")))
        return true;
      // Signal 3: PROJECT_KEY is filled in (not a {PLACEHOLDER})
      const pcPath = path.join(claudeDir, "project-constants.md");
      if (fs.existsSync(pcPath)) {
        const pc = fs.readFileSync(pcPath, "utf-8");
        const m = pc.match(/\|\s*PROJECT_KEY\s*\|\s*([^|\n]+)/);
        if (m && m[1].trim() && !m[1].includes("{")) return true;
      }
    } catch {
      /* ignore */
    }
    return false;
  })();

  // Session conflict detection
  const sessionConflict = resolvedActiveFeature
    ? countActiveSessionsStatus(projectBaseDir, resolvedActiveFeature)
    : 0;

  // Build data bag
  const dataBag = {
    lifecycleSteps,
    lifecycleFeature,
    lifecycleFeatureId,
    sessionConflict,
    isEckProject,
    isProjectConfigured,
    parkedFeatureCount: readParkedCount(projectBaseDir, lcData),
    backlogCount: readBacklogCount(projectBaseDir, lcData),
    bestIncompleteFeature: readBestIncompleteFeature(projectBaseDir, lcData),
    model,
    eckVersion: readEckVersion(),
    stageDescription: stageDesc,
    rateLimits,
    contextPercent,
    contextWindowSize,
    durationMs,
    costUsd,
    totalInputTokens,
    totalOutputTokens,
    cacheCreate,
    cacheRead,
    inputTokens,
    linesAdded,
    linesRemoved,
    ...readRegistryProject(stdinProjectDir || stdinCwd),
    outputStyle: readOutputStyle(stdinOutputStyle),
    protectionState: readProtection(),
    termWidth,
    // Dir row data
    projectDir: stdinProjectDir,
    projectDirShort: stdinProjectDir
      ? shortenPath(stdinProjectDir, pathMax)
      : null,
    projectBranch,
    projectDirty,
    currentDir: stdinCwd,
    currentDirShort: shortenPath(stdinCwd, pathMax),
    currentBranch,
    currentDirty,
    addedDirs: dedupedAdded,
    stdinProjectDir,
    stdinCwd,
    pathMax,
    // Pass git helpers for added dirs in collectDirRows
    _gitBranch: gitBranch,
    _gitDirtyCount: gitDirtyCount,
  };

  // Layout: collect all row component arrays, render as unified table
  const config = loadConfig();
  const layout = config || DEFAULT_LAYOUT;
  const configRowComps = collectConfigRows(layout, dataBag);
  const dirRowComps = collectDirRows(dataBag);
  const allRowComps = [...configRowComps, ...dirRowComps];
  const allRows = renderTable(allRowComps, termWidth);

  // Output — R1 is brand_version on its own row
  process.stdout.write(allRows.join("\n") + "\n");

  // Write bridge file
  writeBridge(sessionId, contextPercent, stageDesc, currentBranch);

  // Persist session ID so update-stage.sh can find the bridge file
  writeSessionId(sessionId, stdinProjectDir || stdinCwd);

  // Write per-session file (heartbeat) and prune stale sessions
  const sessionProjectDir = stdinProjectDir || stdinCwd;
  const planModeActive = (() => {
    if (!sessionProjectDir) return false;
    try {
      return fs.existsSync(path.join(sessionProjectDir, ".planning-session"));
    } catch {
      return false;
    }
  })();
  writeSessionFileStatus(sessionProjectDir, sessionId, {
    active_feature: resolvedActiveFeature,
    plan_mode: planModeActive,
    stage_description: stageDesc,
    git_branch: currentBranch,
  });
  pruneDeadSessionsStatus(sessionProjectDir);
}


// ─── Exports & Entry Point ─────────────────────────────────────────────────

module.exports = {
  // Helpers
  stripAnsi,
  visWidth,
  padToWidth,
  formatTokens,
  shortenPath,
  // Components
  compBrandVersion,
  compBrandVersionModel,
  compModel,
  compModelLabeled,
  compStatus,
  compContextBar,
  compContextCombined,
  compTokensCtx,
  compTokensIo,
  compLifecycle,
  compNextCmd,
  compFeature,
  compPhase,
  compStage,
  compPhaseStage,
  readParkedCount,
  readBacklogCount,
  readBestIncompleteFeature,
  compPlaceholder,
  compUsageTime,
  compDevRigor,
  compStyle,
  compProtection,
  compProjectType,
  compCost,
  compCacheHit,
  compRateLimits,
  compLearning,
  compLinesChanged,
  COMPONENT_REGISTRY,
  // Renderers
  renderRowGrid,
  renderRowFlow,
  renderTable,
  // Layout
  COL_GAP,
  GRID_COLS,
  getGridCols,
  DEFAULT_LAYOUT,
  loadConfig,
  collectConfigRows,
  renderConfigRows,
  collectDirRows,
  renderDirRows,
  // Data
  parseStdin,
  resolveSessionId,
  readBridgeFile,
  readEckVersion,
  readRegistryProject,
  readOutputStyle,
  readProtection,
  gitBranch,
  gitDirtyCount,
  gitToplevel,
  deduplicateDirs,
  writeBridge,
  writeSessionId,
  writeSessionFileStatus,
  countActiveSessionsStatus,
  resolveActiveFeatureStatus,
  pruneDeadSessionsStatus,
  readLifecycleSteps,
  readLifecycleFeature,
  readLifecycleFeatureId,
  // Main
  main,
};

if (require.main === module) { main(); }