import { spawn } from 'node:child_process';
import * as fs from 'node:fs/promises';
import * as os from 'node:os';
import * as path from 'node:path';
import { buildChildEnv } from '../utils/child-env.js';
import { PROMPT as DEFAULT_PROMPT } from './modes/default.js';
import type { TextBlock } from '../types/blocks.js';
import type { MemoryStore } from '../types/memory.js';
import type { ModeStore } from '../types/mode.js';
import type { SkillLoader } from '../types/skill.js';
import type { SystemPromptContributor } from '../types/system-prompt-contributor.js';
import type {
  BuildContext,
  ModelCapabilities,
  SystemPromptBuilder,
} from '../types/system-prompt.js';
import type { Tool } from '../types/tool.js';

export const LAYER_1_IDENTITY = DEFAULT_PROMPT;

export interface DefaultSystemPromptBuilderOptions {
  memoryStore?: MemoryStore | undefined;
  skillLoader?: SkillLoader | undefined;
  modeStore?: ModeStore | undefined;
  /** Pre-resolved active mode id — shown in environment block. */
  modeId?: string | undefined;
  /** Pre-resolved mode prompt — avoids redundant modeStore.getActiveMode() call. */
  modePrompt?: string | undefined;
  /** Pre-resolved model capabilities — enables adaptive context thresholds. */
  modelCapabilities?: ModelCapabilities | undefined;
  todayIso?: string | undefined;
  /**
   * Path to the session's plan JSON, or a getter that returns it. When
   * set, the builder reads the file on every `build()` call and injects
   * an "Active plan" block listing open items, so the LLM is anchored to
   * the strategic roadmap every turn — not just at resume. The block is
   * tagged `ephemeral` so a plan edit on turn N doesn't invalidate the
   * provider's prefix cache for earlier turns.
   *
   * The function form lets callers bind the builder before the session
   * id is known (e.g. DI containers that resolve the builder lazily) —
   * the getter is called at build-time, after the session has been
   * created.
   */
  planPath?: string | (() => string | undefined);
  /**
   * System prompt contributors — called on every `build()` to inject
   * additional TextBlocks. Use `ExtensionRegistry.listSystemPromptContributors()`
   * or pass a plain array. Contributors are called in order; a throwing
   * contributor is caught and logged without aborting the build.
   */
  contributors?: readonly SystemPromptContributor[] | undefined;
}

export class DefaultSystemPromptBuilder implements SystemPromptBuilder {
  /**
   * Cached environment block, keyed by projectRoot. A single builder
   * instance is normally reused across turns of the same agent run, but
   * tests and library consumers may reuse it across runs with different
   * roots; keying the cache prevents leaking the first call's project
   * state into a later call against an unrelated project.
   */
  private envCacheByRoot = new Map<string, string>();
  private skillCache?: string | undefined;
  /** Cached full skill bodies (after frontmatter), built once per session. */
  private skillBodyCache?: string | undefined;
  /** Tools from last build — used for memory relevance scoring. */
  private _lastBuildTools?: Tool[] | undefined;
  constructor(private readonly opts: DefaultSystemPromptBuilderOptions = {}) {}

  async build(ctx: BuildContext): Promise<TextBlock[]> {
    this._lastBuildTools = ctx.tools;
    // Pre-load skill entries so we can include them in the environment block
    // (which is cached). Skills are static per-session, so this is safe.
    if (this.opts.skillLoader && !this.skillCache) {
      try {
        const entries = await this.opts.skillLoader.listEntries();
        if (entries.length > 0) {
          const lines: string[] = [];
          for (const e of entries) {
            // Compact format: name + shortened trigger (full body in Active Skills)
            const shortTrigger = compactTrigger(e.trigger);
            lines.push(`- **${e.name}**  (${shortTrigger})`);
          }
          this.skillCache = lines.join('\n');
        }
      } catch {
        // skip
      }
    }

    const layer1 = LAYER_1_IDENTITY;
    const layer2 = this.buildToolUsage(ctx.tools, ctx);
    const layer3 = await this.buildEnvironment(ctx);
    const layer3WithDir = `${layer3}\n- Project root: ${ctx.projectRoot}`;
    const layer4 = await this.buildMemoryAndSkills();
    const layer5 = await this.buildMode();
    // Plans anchor the HOST agent across turns. Subagents run one
    // narrow task and shouldn't carry the host's strategic context —
    // it just bloats their prompt and risks them mutating a plan
    // they weren't supposed to touch.
    const layer6 = ctx.subagent ? '' : await this.buildActivePlan();

    const blocks: TextBlock[] = [
      { type: 'text', text: layer1 },
      { type: 'text', text: layer2 },
      { type: 'text', text: layer3WithDir },
    ];

    if (layer4.trim()) {
      blocks.push({
        type: 'text',
        text: layer4,
        cache_control: { type: 'ephemeral' },
      });
    }

    if (layer5.trim()) {
      blocks.push({
        type: 'text',
        text: layer5,
        cache_control: { type: 'ephemeral' },
      });
    }

    // Suggested skills for the active mode — helps the model know which
    // domain instructions to prioritize when multiple skills are loaded.
    if (this.opts.modeStore && this.opts.skillLoader) {
      try {
        const activeMode = await this.opts.modeStore.getActiveMode();
        if (activeMode?.suggestedSkills && activeMode.suggestedSkills.length > 0) {
          const skills = await this.opts.skillLoader.list();
          const loadedNames = new Set(skills.map((s) => s.name));
          const available = activeMode.suggestedSkills.filter((n) => loadedNames.has(n));
          if (available.length > 0) {
            blocks.push({
              type: 'text',
              text: `Mode "${activeMode.id}" works best with these skills: ${available.join(', ')}. Their full instructions are in the Active Skills block above.`,
              cache_control: { type: 'ephemeral' },
            });
          }
        }
      } catch {
        // skip — non-critical hint
      }
    }

    if (layer6.trim()) {
      blocks.push({
        type: 'text',
        text: layer6,
        cache_control: { type: 'ephemeral' },
      });
    }

    // System prompt contributors — plugins inject ephemeral context here.
    if (this.opts.contributors && this.opts.contributors.length > 0) {
      for (const c of this.opts.contributors) {
        try {
          const contributed = await c(ctx);
          blocks.push(...contributed);
        } catch {
          // Contributor errors are swallowed — a bad plugin shouldn't
          // break the system prompt assembly.
        }
      }
    }

    return blocks;
  }

  /**
   * Cached plan content keyed by (planPath, mtimeMs). The plan is read
   * once per system-prompt build; most turns don't change the plan, so
   * this avoids a blocking fs.readFile + JSON.parse on every iteration.
   * Cleared when the file's mtime changes (the `/plan` tool mutated it).
   */
  private _planCache?: { path: string; mtimeMs: number; text: string } | undefined;

  /**
   * Reads `<sessionId>.plan.json` (when configured) and produces a short
   * "Active plan" block listing open items so the model is anchored to
   * the strategic roadmap every turn. Reads on every `build()` so a
   * plan edit (via `/plan` or the `plan` tool) reflects on the next
   * turn without restarting the session.
   */
  private async buildActivePlan(): Promise<string> {
    const planPath =
      typeof this.opts.planPath === 'function' ? this.opts.planPath() : this.opts.planPath;
    if (!planPath) return '';

    let raw: string;
    try {
      // Check mtime before reading — plans change at human pace (a few times
      // per session), not on every iteration. Stat is O(1) metadata; readFile
      // + JSON.parse is O(n) for the file content.
      const stat = await fs.stat(planPath);
      if (
        this._planCache &&
        this._planCache.path === planPath &&
        this._planCache.mtimeMs === stat.mtimeMs
      ) {
        return this._planCache.text;
      }
      raw = await fs.readFile(planPath, 'utf8');
      const text = this._formatPlan(raw);
      this._planCache = { path: planPath, mtimeMs: stat.mtimeMs, text };
      return text;
    } catch {
      // File missing, unreadable, or corrupt — clear cache and return empty.
      this._planCache = undefined;
      return '';
    }
  }

  private _formatPlan(raw: string): string {
    let parsed: { items?: Array<{ status?: string | undefined; title?: string | undefined }>; title?: string | undefined };
    try {
      parsed = JSON.parse(raw);
    } catch {
      return '';
    }
    if (!Array.isArray(parsed.items) || parsed.items.length === 0) return '';
    const open = parsed.items.filter((i) => i?.status !== 'done');
    if (open.length === 0) return '';
    const lines = ['## Active plan'];
    if (parsed.title) lines.push(`*${parsed.title}*`, '');
    parsed.items.forEach((it, idx) => {
      const mark = it?.status === 'done' ? '[x]' : it?.status === 'in_progress' ? '[~]' : '[ ]';
      lines.push(`${idx + 1}. ${mark} ${it?.title ?? '(untitled)'}`);
    });
    lines.push(
      '',
      'Use `/plan` (user) or the `plan` tool to update status as you progress. The roadmap survives session resume.',
    );
    return lines.join('\n');
  }

  private buildToolUsage(tools: Tool[], ctx: BuildContext): string {
    if (tools.length === 0) return '## Tool usage\n\nNo tools registered.';

    // Group tools by category for a cleaner listing when categories are used.
    const byCat = new Map<string, Tool[]>();
    const uncategorized: Tool[] = [];
    for (const t of tools) {
      if (t.category) {
        let group = byCat.get(t.category);
        if (!group) {
          group = [];
          byCat.set(t.category, group);
        }
        group.push(t);
      } else {
        uncategorized.push(t);
      }
    }

    const lines = ['## Tool usage'];

    // Categorized tools
    for (const [cat, catTools] of byCat) {
      lines.push(`\n### ${cat}`);
      for (const t of catTools) {
        const hint = t.usageHint ?? t.description;
        const desc = hint.length > 80 ? `${hint.slice(0, 77)}...` : hint.trim();
        lines.push(`- **${t.name}** — ${desc}`);
      }
    }

    // Uncategorized tools
    if (uncategorized.length > 0) {
      if (byCat.size > 0) lines.push('');
      for (const t of uncategorized) {
        const hint = t.usageHint ?? t.description;
        lines.push(`\n### ${t.name}\n${hint.trim()}`);
      }
    }

    // Common tool chain patterns — teaches model how to compose tools effectively.
    lines.push(`
## Common patterns

- **Inspect before edit:** \`read\`/\`glob\`/\`grep\` → locate target → \`edit\`
- **Search then operate:** \`grep\`/\`glob\` → identify targets → \`batch_tool_use\` or iterative \`edit\`
- **Verify after mutate:** \`write\`/\`edit\`/\`patch\` → \`read\` back to confirm → report outcome
- **Explore project:** \`glob\` for structure → \`read\` key files → \`grep\` for patterns
- **Batch ops:** Use \`replace\` with glob patterns for multi-file surgical changes

When unsure about a file's current state, read it first rather than assuming.`);

    // Delegation guidance — included when the `delegate` tool is present.
    // Without this block the model doesn't know that multi-agent work is
    // even an option, and `delegate` sits unused while the host agent
    // tries to do everything in one expensive context.
    const hasDelegate = tools.some((t) => t.name === 'delegate');
    if (hasDelegate) {
      const delegateTool = tools.find((t) => t.name === 'delegate');
      const enumValues = (() => {
        const role = (
          delegateTool?.inputSchema as
            | { properties?: { role?: { enum?: unknown | undefined } } }
            | undefined
        )?.properties?.role?.enum;
        return Array.isArray(role) ? (role.filter((r) => typeof r === 'string') as string[]) : [];
      })();
      const roleList = enumValues.length > 0 ? enumValues.join(', ') : '(no roster configured)';
      lines.push(`
## Delegation

You have a \`delegate\` tool that hands a discrete piece of work to a
dedicated subagent (its own context, its own LLM call, its own budget
cap) and waits for the result. Use it proactively when:

- **The task fans out naturally** — e.g. "audit these 5 files for
  security issues" splits cleanly into 5 parallel \`delegate\` calls,
  one per file or per role. Fire them through the provider's
  parallel-tool-call surface in the same turn.
- **A specialized role exists** — the roster has tuned prompts and
  budgets for: ${roleList}. Reach for a role when the description
  matches your subtask; otherwise pass \`name\` + \`provider\` + \`model\`.
- **A subtask would blow up your context** — long log analyses, large
  diff reviews, multi-file refactor plans. The subagent absorbs the
  reading cost and hands back a summary.
- **You'd otherwise switch hats mid-turn** — instead of stopping a code
  fix to do a security pass, delegate the security pass.

### Scope it tight — narrow tasks succeed, broad tasks time out

A subagent has a finite iteration / tool-call budget (typically 50–80
iterations, 200–300 tool calls). Tasks that mention "ALL files" or "the
entire codebase" reliably exhaust that budget without producing a clean
answer — the delegate returns with \`stopReason: budget_exhausted\` and
no useful output.

- ❌ BAD: \`"Analyze ALL .ts files in src/ for bugs"\`
- ❌ BAD: \`"Audit the codebase for security issues"\`
- ❌ BAD: \`"Plan a refactor of the whole project"\`
- ✅ GOOD: \`"Audit src/auth/session.ts for null-deref bugs in the login flow"\`
- ✅ GOOD: \`"Check packages/core/src/storage/*.ts for unhandled promise rejections (~6 files)"\`
- ✅ GOOD: \`"Plan a phased refactor of the InMemoryBridge transport (3 files in coordination/)"\`

If you need fleet-wide coverage, **fan out**: list the target files
yourself first (one quick \`glob\` call), then fire one \`delegate\` per
chunk of ≤5–10 files in parallel.

### Reading the result

\`delegate\` returns a structured object. Look at \`stopReason\`:

- \`end_turn\` — subagent finished cleanly, \`result\` has the answer.
- \`budget_exhausted\` — task was too broad; \`partial.lastAssistantText\`
  has whatever it managed. Narrow the next try.
- \`subagent_timeout\` / \`host_timeout\` — likewise partial; raise
  \`timeoutMs\` only if you have a reason to believe more time would help.
- \`aborted\` — the user or another tool stopped this worker; don't retry
  silently.
- \`error\` — infrastructure problem; surface it.

Stay in-process (no \`delegate\`) when:
- The task is trivial or atomic.
- The information needed is already in your context.
- The user is mid-conversation and expects an immediate reply from you,
  not a research detour through a subagent.

\`delegate\` auto-promotes the host into director mode the first time
it's called — you do not need to call any setup tool. For fine-grained
control over a long-running fleet (spawn N workers, hand them tasks
one by one, roll up results), use \`spawn_subagent\` + \`assign_task\` +
\`await_tasks\` directly; \`delegate\` is the one-call shortcut.`);
    }

    // Mailbox guidance — included when any mailbox tool is present.
    const hasMailbox = tools.some(
      (t) => t.name === 'mailbox' || t.name === 'mail_send' || t.name === 'mail_inbox',
    );
    if (hasMailbox) {
      // Build online agents info if provided
      let onlineAgentsInfo = '';
      if (ctx.onlineAgents && ctx.onlineAgents.length > 0) {
        const totalCount = ctx.onlineAgents.length;
        const agentList = ctx.onlineAgents
          .map((a) => `- **${a.name}** (${a.source ?? 'unknown'}${a.sessionId ? `, session: ${a.sessionId.slice(0, 8)}` : ''})`)
          .join('\n');
        onlineAgentsInfo = `\n\n**Currently online (${totalCount} agent${totalCount !== 1 ? 's' : ''}):**\n${agentList}`;
      }
      lines.push(`
## Inter-agent mailbox${onlineAgentsInfo}

You share a persistent project mailbox with every other agent working on
this project — other terminals, TUIs and WebUIs included. You are
EXPECTED to use it: announce what you do, hand work off, ask questions,
and answer mail addressed to you. Coordination is part of the job, not
an optional extra.

### Your identity

You are addressable as \`<your-name>@<session-tag>\` (your session-unique
id — visible in the online list). Every session has its own tag, so two
sessions running under the same name never mix. Mail sent to your bare
base name (e.g. \`leader\`) reaches every live session running under that
name; mail to your exact id reaches only you. When replying, use the
sender's exact \`from\` id.

### Receiving

Unread mail (direct, base-name, and \`*\` broadcasts) is injected into
your conversation automatically before each step — urgent steer/btw
inline, the rest as a summary. To catch up explicitly:

- \`mail_inbox\` — read your unread mail and mark it read
- \`mailbox action=query from=<agent> type=result\` — find specific results

### Sending

- \`mail_send to=<agentId> subject="..." body="..."\` — direct message
- \`mail_send to="*" subject="..." body="..."\` — broadcast to everyone
  (\`to="all"\` works too)
- Message types: \`note\` (info), \`ask\` (question), \`assign\` (task handoff),
  \`steer\` (change approach), \`btw\` (non-urgent info), \`status\` (your current
  task), \`result\` (task outcome)

### Agent discovery

- \`mailbox action=online\` — who is live right now (ids to address)
- \`mailbox action=status\` — all agents and their current tasks. Use this
  to find who to ask for help or who can pick up a broadcast task.

### Etiquette — when to mail

- **Broadcast milestones**: when you finish a significant change
  ("refactored src/auth/*, tests green"), \`mail_send to="*"\` so parallel
  agents don't collide with or duplicate your work.
- **Hand off matching work**: if another agent's role fits a task better
  (a reviewer online while you just wrote code → "can you review X?"),
  send it to them instead of doing everything yourself.
- **Answer your mail**: when an \`ask\` arrives, reply to the sender's
  exact id with a \`result\` or \`note\` — silence stalls the other agent.
- Post a \`status\` when you start something significant; post a \`result\`
  when someone is waiting on you.

### Acknowledging

- \`mailbox action=ack messageId=<id> completed=true outcome="What you did"\`
- Messages you \`check\` are auto-marked as read; use \`ack\` to mark complete.`);
    }

    // Context management guidance — included when context_manager is present.
    // This layer teaches the model WHEN and HOW to use it proactively.
    const hasContextManager = tools.some((t) => t.name === 'context_manager');
    if (hasContextManager) {
      // Adaptive threshold based on model context window size.
      // Small context (<=32k) → trigger earlier; large context (>=128k) → more relaxed.
      const maxCtx = this.opts.modelCapabilities?.maxContextTokens ?? 128000;
      const threshold = maxCtx <= 32000 ? '50' : '70';
      lines.push(`
## Context management

When the conversation grows long and context window usage exceeds what you can track,
use the context_manager tool proactively — do NOT wait to be told:

- Call \`context_manager\` with \`{"action":"check"}\` to see current token budget and message counts.
- When the conversation exceeds ~${threshold}% of your context window, call \`{"action":"summary"}\` or \`{"action":"compact"}\` to reclaim space.
- Use \`{"action":"prune"}\` to surgically remove specific irrelevant message ranges (e.g. old debug output).
- Use \`{"action":"add_note"}\` to inject a summary note at a specific point after a complex operation.

**Never** stuff redundant information into a tool result. If you summarize a file, do not paste its full content —
summarize it, and let the tool result hold only the summary.`);
    }

    return lines.join('\n');
  }

  private async buildEnvironment(ctx: BuildContext): Promise<string> {
    const cached = this.envCacheByRoot.get(ctx.projectRoot);
    if (cached) return cached;
    const today = this.opts.todayIso ?? new Date().toISOString().slice(0, 10);
    const platform = `${os.platform()} ${os.release()}`;
    const shell = process.env.SHELL ?? process.env.ComSpec ?? 'unknown';
    const node = process.version;
    const isGit = await this.dirExists(path.join(ctx.projectRoot, '.git'));
    // Fan out the per-root probes so the prompt build doesn't serialize
    // ~12 fs.access calls plus the git status spawn back-to-back. On a
    // cold cache (CI / first turn) this trims hundreds of ms.
    const [git, langs] = await Promise.all([
      isGit ? this.gitStatus(ctx.projectRoot) : Promise.resolve('not a git repo'),
      this.detectLanguages(ctx.projectRoot),
    ]);

    const lines = [
      '## Environment',
      '',
      `- Operating system: ${platform}`,
      `- Shell: ${shell}`,
      `- Node.js: ${node}`,
      `- Detected languages: ${langs}`,
      `- Git status: ${git}`,
      `- Today's date: ${today}`,
    ];
    if (ctx.provider || ctx.model) {
      lines.push(
        `- Running on: ${ctx.provider ?? '<unknown provider>'}/${ctx.model ?? '<unknown model>'}`,
      );
    }
    if (this.opts.modeId && this.opts.modeId !== 'default') {
      lines.push(`- Mode: ${this.opts.modeId}`);
    }
    if (this.opts.modelCapabilities) {
      lines.push(
        `- Context window: ${this.opts.modelCapabilities.maxContextTokens.toLocaleString()} tokens max`,
      );
    }
    if (this.skillCache) {
      lines.push(
        '',
        '## Skills in scope for this session',
        this.skillCache,
        '',
        'Full skill instructions are injected in the Active Skills block below.',
      );
    }
    const text = lines.join('\n');
    this.envCacheByRoot.set(ctx.projectRoot, text);
    return text;
  }

  private async buildMemoryAndSkills(): Promise<string> {
    const parts: string[] = [];
    if (this.opts.memoryStore) {
      try {
        // Use relevance scoring when available, fall back to full dump.
        if (this.opts.memoryStore.scoreRelevant) {
          const toolNames = this._lastBuildTools?.map((t) => t.name) ?? [];
          const scored = await this.opts.memoryStore.scoreRelevant(
            {
              currentTask: '',
              toolNames,
            },
            'project-memory',
            8,
          );
          if (scored.length > 0) {
            const lines: string[] = ['# Relevant Memory'];
            for (const e of scored) {
              const badge = e.type
                ? `[\`${e.type.replace('_', '-')}\`] `
                : '';
              const priorityMark = e.priority === 'critical' ? '⚡' : e.priority === 'high' ? '▲' : '';
              lines.push(`- ${priorityMark}${badge}${e.text}${e.tags ? ` \`#${e.tags.join(' #')}\`` : ''}`);
            }
            parts.push(lines.join('\n'));
          }
        } else {
          const mem = await this.opts.memoryStore.readAll();
          if (mem.trim()) parts.push(`# Project Memory\n\n${mem}`);
        }
      } catch {
        // skip
      }
    }
    // Skill bodies — load once and cache for the session lifetime.
    // Skills are listed by name+trigger in buildEnvironment (envCache);
    // here we inject the full body content so the model has the actual
    // domain instructions, not just a trigger hint.
    if (this.opts.skillLoader && this.skillBodyCache === undefined) {
      try {
        const skills = await this.opts.skillLoader.list();
        if (skills.length > 0) {
          const bodies: string[] = [];
          for (const s of skills) {
            try {
              const raw = await this.opts.skillLoader.readBody(s.name);
              // Strip YAML frontmatter — keep only the markdown body.
              const body = stripFrontmatter(raw);
              if (body.trim()) {
                bodies.push(`## Skill: ${s.name}\n\n${body.trim()}`);
              }
            } catch {
              // skip unreadable skill
            }
          }
          if (bodies.length > 0) {
            this.skillBodyCache = bodies.join('\n\n---\n\n');
          } else {
            this.skillBodyCache = ''; // mark as resolved to avoid retrying
          }
        } else {
          this.skillBodyCache = '';
        }
      } catch {
        this.skillBodyCache = '';
      }
    }
    if (this.skillBodyCache) {
      parts.push(`# Active Skills\n\n${this.skillBodyCache}`);
    }
    return parts.join('\n\n');
  }

  private async buildMode(): Promise<string> {
    // Use pre-resolved modePrompt if available (avoids redundant async call).
    if (this.opts.modePrompt) return this.opts.modePrompt;
    if (!this.opts.modeStore) return '';
    const mode = await this.opts.modeStore.getActiveMode();
    if (!mode?.prompt) return '';
    return mode.prompt;
  }

  private async dirExists(p: string): Promise<boolean> {
    try {
      const stat = await fs.stat(p);
      return stat.isDirectory();
    } catch {
      return false;
    }
  }

  private async gitStatus(root: string): Promise<string> {
    return new Promise((resolve) => {
      let settled = false;
      const finish = (s: string): void => {
        if (settled) return;
        settled = true;
        resolve(s);
      };
      let proc: ReturnType<typeof spawn> | undefined;
      // 2 s ceiling: a hung git status (corrupt index, .git/index.lock
      // held by another process, network FS hiccup) must not stall the
      // whole prompt build for a turn.
      const timer = setTimeout(() => {
        proc?.kill('SIGKILL');
        finish('git timeout');
      }, 2000);
      try {
        proc = spawn('git', ['status', '--porcelain=v1', '--branch'], {
          cwd: root,
          env: buildChildEnv(),
          stdio: ['ignore', 'pipe', 'ignore'],
        });
        let buf = '';
        proc.stdout?.on('data', (c) => {
          buf += c.toString();
        });
        proc.on('error', () => {
          clearTimeout(timer);
          finish('git error');
        });
        proc.on('close', () => {
          clearTimeout(timer);
          const lines = buf.split('\n').filter(Boolean);
          const branchLine = lines[0] ?? '';
          const branchMatch = branchLine.match(/## ([^\s.]+)/);
          const branch = branchMatch?.[1] ?? 'detached';
          const dirty = lines.slice(1);
          const staged = dirty.filter((l) => /^[MARCD]/.test(l)).length;
          const modified = dirty.length - staged;
          finish(`branch=${branch}, ${modified} modified, ${staged} staged`);
        });
      } catch {
        clearTimeout(timer);
        finish('git unavailable');
      }
    });
  }

  private async detectLanguages(root: string): Promise<string> {
    const checks: Array<[string, string]> = [
      ['package.json', 'JavaScript/TypeScript'],
      ['tsconfig.json', 'TypeScript'],
      ['go.mod', 'Go'],
      ['Cargo.toml', 'Rust'],
      ['pyproject.toml', 'Python'],
      ['requirements.txt', 'Python'],
      ['Gemfile', 'Ruby'],
      ['pom.xml', 'Java'],
      ['build.gradle', 'Java/Kotlin'],
      ['composer.json', 'PHP'],
      ['mix.exs', 'Elixir'],
    ];
    // Fan out the marker probes. Sequential await on 11 fs.access calls
    // adds latency on cold cache for no reason — each probe is independent.
    const hits = await Promise.all(
      checks.map(async ([marker, lang]) => {
        try {
          await fs.access(path.join(root, marker));
          return lang;
        } catch {
          return null;
        }
      }),
    );
    const langs = new Set(hits.filter((l): l is string => l !== null));
    return langs.size === 0 ? 'unknown' : Array.from(langs).join(', ');
  }
}

/** Strip YAML frontmatter from a SKILL.md file, returning only the body. */
function stripFrontmatter(raw: string): string {
  if (!raw.startsWith('---')) return raw;
  const end = raw.indexOf('\n---', 4);
  if (end === -1) return raw;
  // Skip past the closing `---` and the following newline
  let body = raw.slice(end + 4);
  if (body.startsWith('\n')) body = body.slice(1);
  return body;
}

/**
 * Compact a skill trigger description into a short label.
 * "Use this skill when scanning source code for bugs..."
 * → "scanning source code for bugs, anti-patterns, code smells"
 */
function compactTrigger(trigger: string): string {
  // Strip common prefixes
  let s = trigger
    .replace(/^Use this skill when /i, '')
    .replace(/^Use this skill for /i, '')
    .replace(/^Use when /i, '')
    .replace(/\.$/, '');
  // Truncate to ~72 chars at a word boundary
  if (s.length > 72) {
    const cut = s.lastIndexOf(' ', 68);
    s = cut > 50 ? s.slice(0, cut) + '…' : s.slice(0, 68) + '…';
  }
  return s;
}
