/**
 * engine/playbook.js — Playbook rendering, system prompt building, agent context,
 * task context resolution, and repo-host helpers.
 * Extracted from engine.js.
 */

const fs = require('fs');
const os = require('os');
const path = require('path');
const shared = require('./shared');
const queries = require('./queries');
const { wrapUntrusted, buildSource } = require('./untrusted-fence');

const { safeJson, safeRead, getProjects, log, ts, dateStamp, truncateTextBytes, ENGINE_DEFAULTS, WI_STATUS, WORK_TYPE, PR_STATUS, DISPATCH_RESULT, getProjectOrg } = shared;
const { getConfig, getDispatch, getNotes, getAgentCharter, getPrs, getKnowledgeBaseIndex, AGENTS_DIR } = queries;

const MINIONS_DIR = shared.MINIONS_DIR;
const PLAYBOOKS_DIR = path.join(MINIONS_DIR, 'playbooks');

// Import tempAgents from routing module
const { tempAgents } = require('./routing');

// ─── Repo Host Helpers ──────────────────────────────────────────────────────

function getRepoHost(project) {
  return project?.repoHost || 'ado';
}

function getPrCreateInstructions(project) {
  const host = getRepoHost(project);
  const repoId = project?.repositoryId || '';
  if (host === 'github') {
    const org = getProjectOrg(project);
    const repo = project?.repoName || '';
    const mainBranch = project?.localPath ? shared.resolveMainBranch(project.localPath, project.mainBranch) : (project?.mainBranch || 'main');
    return `Use \`gh pr create\` to create a pull request:\n` +
      `- Write the PR description to a temporary Markdown file, then run: \`gh pr create --base ${mainBranch} --head <your-branch> --title "PR title" --body-file <body-file.md> --repo ${org}/${repo}\`\n` +
      `- Always set --base to \`${mainBranch}\` (the main branch)\n` +
      `- Always set --repo to \`${org}/${repo}\` to target the correct repository\n` +
      `- Use --head to specify your feature branch name\n` +
      `- Include a meaningful --title and body file describing the changes`;
  }
  // Default: Azure DevOps — prefer `az` CLI first, ADO MCP only as fallback
  const adoOrg = project?.adoOrg || '';
  const adoProject = project?.adoProject || '';
  const repoName = project?.repoName || '';
  const mainBranch = project?.localPath ? shared.resolveMainBranch(project.localPath, project.mainBranch) : (project?.mainBranch || 'main');
  return `For Azure DevOps, use the \`az\` CLI first to create a pull request:\n` +
    `- Run \`az devops configure --defaults organization=https://dev.azure.com/${adoOrg} project="${adoProject}"\` once per session if defaults are not yet set\n` +
    `- Then: \`az repos pr create --repository "${repoName}" --source-branch <your-branch> --target-branch ${mainBranch} --title "PR title" --description @<body-file.md>\`\n` +
    `- Use \`@<file>\` syntax for \`--description\` so Markdown, quotes, and newlines pass safely\n` +
    `- Always set --target-branch to \`${mainBranch}\` (the main branch)\n\n` +
    `If \`az\` is unavailable or insufficient for this operation, fall back to \`mcp__azure-ado__repo_create_pull_request\` with repositoryId \`${repoId}\`. Do not use \`gh\` for Azure DevOps repositories.`;
}

function getPrCommentInstructions(project) {
  const host = getRepoHost(project);
  const repoId = project?.repositoryId || '';
  if (host === 'github') {
    const org = getProjectOrg(project);
    const repo = project?.repoName || '';
    return `Post the comment via \`minions pr comment\` (preferred) — it prepends the hidden \`<!-- minions:agent=… kind=… -->\` marker that the engine's classifier needs to recognize agent posts and avoid spurious fix-dispatches:\n` +
      `- \`minions pr comment ${org}/${repo} <number> --agent <your-agent-id> --kind <kind> [--wi <work-item-id>] --body-file <body-file.md>\`\n` +
      `- Pick \`--kind\` from: \`review-decision\` | \`verify-report\` | \`rebase-report\` | \`positive-signal\` | \`fix-summary\` | \`other\`\n` +
      `- Replace <number> with the PR number\n` +
      `- Use --body-file so Markdown, quotes, and newlines are passed safely\n\n` +
      `If \`minions pr comment\` is unavailable, fall back to raw \`gh pr comment\` BUT the FIRST line of your body file MUST be the marker:\n` +
      "```\n" +
      "<!-- minions:agent=<your-agent-id> kind=<kind> wi=<work-item-id> -->\n" +
      "\n" +
      "<your markdown body here>\n" +
      "```\n" +
      `Then run: \`gh pr comment <number> --body-file <body-file.md> --repo ${org}/${repo}\`. Without the marker, the engine cannot tell your post from a real human comment and will queue redundant fix-dispatches.`;
  }
  // Azure DevOps — prefer `az` CLI first, ADO MCP only as fallback
  const repoName = project?.repoName || '';
  return `For Azure DevOps, use the \`az\` CLI first to post a comment on the PR:\n` +
    `- Write the Markdown comment to a temporary file, then run: \`az repos pr comment create --pull-request-id <number> --content @<body-file.md>\` (substitute your project's repo \`${repoName}\` if not using \`az devops configure\` defaults)\n` +
    `- Use \`@<file>\` syntax for \`--content\` so Markdown, quotes, and newlines pass safely\n\n` +
    `If \`az repos pr comment\` is unavailable or insufficient (e.g. older az-devops extension, thread/status semantics needed), fall back to \`mcp__azure-ado__repo_create_pull_request_thread\` with repositoryId \`${repoId}\`. Do not use \`gh\` for Azure DevOps repositories.`;
}

function getPrFetchInstructions(project) {
  const host = getRepoHost(project);
  if (host === 'github') {
    const org = getProjectOrg(project);
    const repo = project?.repoName || '';
    const mainBranch = project?.localPath ? shared.resolveMainBranch(project.localPath, project.mainBranch) : (project?.mainBranch || 'main');
    return `Use \`gh pr view\` to fetch PR status:\n` +
      `- \`gh pr view <number> --json number,title,state,mergeable,reviewDecision,headRefName,baseRefName,statusCheckRollup --repo ${org}/${repo}\`\n` +
      `- This returns JSON with PR state, mergeability, review decision, and check statuses\n` +
      `- To fetch the PR branch locally:\n` +
      `  1. \`git fetch origin <branch-name>\`\n` +
      `  2. \`git checkout <branch-name>\`\n` +
      `- Or use \`gh pr checkout <number> --repo ${org}/${repo}\` to fetch and checkout in one step\n` +
      `- The base branch is \`${mainBranch}\``;
  }
  // Azure DevOps — prefer `az` CLI first, ADO MCP only as fallback
  const mainBranch = project?.localPath ? shared.resolveMainBranch(project.localPath, project.mainBranch) : (project?.mainBranch || 'main');
  return `For Azure DevOps, use the \`az\` CLI first to fetch PR status:\n` +
    `- \`az repos pr show --id <number>\` returns PR state, mergeStatus, source/target branches, vote summary, and policy/build evaluations\n` +
    `- For the local branch: \`git fetch origin <branch-name>\` then inspect via \`git show\`/\`git diff\` (do NOT checkout in your main working tree)\n` +
    `- The base branch is \`${mainBranch}\`\n\n` +
    `If \`az\` is unavailable or insufficient, fall back to \`mcp__azure-ado__repo_get_pull_request_by_id\`. Do not use \`gh\` for Azure DevOps repositories.`;
}

function getPrVoteInstructions(project) {
  const host = getRepoHost(project);
  const repoId = project?.repositoryId || '';
  if (host === 'github') {
    const org = getProjectOrg(project);
    const repo = project?.repoName || '';
    return `**IMPORTANT: GitHub blocks self-approval** — all agents share the same credentials, so \`--approve\` and \`--request-changes\` will fail with "can't approve your own PR." Submit your verdict as a comment instead, prefixed with the marker so the engine recognizes you as a minion.\n\n` +
      `Preferred — \`minions pr comment\` (auto-prepends the marker; pick \`--kind review-decision\`):\n` +
      `- \`minions pr comment ${org}/${repo} <number> --agent <your-agent-id> --kind review-decision [--wi <work-item-id>] --body-file <verdict.md>\`\n` +
      `- The verdict body file's first non-marker line MUST be \`VERDICT: APPROVE\` or \`VERDICT: REQUEST_CHANGES\` — the engine parses this to record your vote\n\n` +
      `Fallback — raw \`gh pr review --comment\` with the marker as the first line of \`<verdict.md>\`:\n` +
      "```\n" +
      "<!-- minions:agent=<your-agent-id> kind=review-decision wi=<work-item-id> -->\n" +
      "VERDICT: APPROVE\n" +
      "\n" +
      "<your review body here>\n" +
      "```\n" +
      `Then run: \`gh pr review <number> --comment --body-file <verdict.md> --repo ${org}/${repo}\`. Do NOT use \`--approve\` or \`--request-changes\` flags — they will fail.`;
  }
  // Azure DevOps — prefer `az` CLI first, ADO MCP only as fallback
  return `For Azure DevOps, use the \`az\` CLI first to set your reviewer vote:\n` +
    `- \`az repos pr set-vote --id <number> --vote {approve | approve-with-suggestions | reject | reset | wait-for-author}\`\n` +
    `- Pair the vote with \`az repos pr comment create --pull-request-id <number> --content @<verdict.md>\` so the verdict body is recorded as a thread comment\n\n` +
    `If \`az\` is unavailable or insufficient, fall back to \`mcp__azure-ado__repo_update_pull_request_reviewers\` with repositoryId \`${repoId}\` (vote integers: 10=approve, 5=approve-with-suggestions, 0=no-vote, -5=wait-for-author, -10=reject). Do not use \`gh\` for Azure DevOps repositories.`;
}

function getRepoHostLabel(project) {
  const host = getRepoHost(project);
  if (host === 'github') return 'GitHub';
  return 'Azure DevOps';
}

function getRepoHostToolRule(project) {
  const host = getRepoHost(project);
  if (host === 'github') return 'Use GitHub MCP tools or `gh` CLI for PR operations';
  return 'For Azure DevOps, use the `az` CLI first for PR operations (e.g. `az repos pr create`, `az repos pr show`, `az repos pr comment`, `az repos pr set-vote`); use ADO MCP tools (`mcp__azure-ado__*`) only as a fallback when `az` is unavailable or insufficient. Do not use `gh` for Azure DevOps repositories.';
}

// ─── Task Context Resolution ────────────────────────────────────────────────
// Resolves implicit references in task descriptions (e.g., "ripley's plan",
// "dallas's PR") to actual artifacts and injects their content.

function resolveTaskContext(item, config) {
  const title = (item.title || '').toLowerCase();
  const desc = (item.description || '').toLowerCase();
  const text = title + ' ' + desc;
  const agentNames = Object.entries(config.agents || {}).map(([id, a]) => ({
    id,
    name: (a.name || id).toLowerCase(),
  }));
  const resolved = { additionalContext: '', referencedFiles: [] };

  function truncateReferencedContext(content, maxBytes, label) {
    return truncateTextBytes(content, maxBytes, `\n\n_...${label} truncated — read the full file if needed._`);
  }


  // Match agent references: "ripley's plan", "dallas's pr", "lambert's output", etc.
  for (const agent of agentNames) {
    const patterns = [
      new RegExp(`${agent.name}(?:'s|s)?\\s+plan`, 'i'),
      new RegExp(`${agent.id}(?:'s|s)?\\s+plan`, 'i'),
      new RegExp(`plan\\s+(?:created|made|written|generated)\\s+by\\s+${agent.name}`, 'i'),
      new RegExp(`plan\\s+(?:created|made|written|generated)\\s+by\\s+${agent.id}`, 'i'),
    ];
    const matchesPlan = patterns.some(p => p.test(text));
    if (matchesPlan) {
      // Find plans created by this agent (check work items for plan tasks dispatched to this agent)
      try {
        const plans = fs.readdirSync(path.join(MINIONS_DIR, 'plans')).filter(f => f.endsWith('.md') || f.endsWith('.json'));
        // Check work-items to find which plan file this agent created
        const workItems = queries.getWorkItems();
        const agentPlanItems = workItems.filter(w =>
          w.type === WORK_TYPE.PLAN && w.dispatched_to === agent.id && w.status === WI_STATUS.DONE && w._planFileName
        ).sort((a, b) => (b.completedAt || '').localeCompare(a.completedAt || ''));

        if (agentPlanItems.length > 0) {
          const planFile = agentPlanItems[0]._planFileName;
          const planPath = path.join(MINIONS_DIR, 'plans', planFile);
          try {
            const content = safeRead(planPath);
            const truncated = truncateReferencedContext(content, ENGINE_DEFAULTS.maxReferencedPlanBytes, 'referenced plan');
            const fenced = wrapUntrusted(truncated, buildSource('wi-reference', { path: `plans/${planFile}` }));
            resolved.additionalContext += `\n\n## Referenced Plan: ${planFile} (created by ${agent.name})\n\n${fenced || truncated}`;
            resolved.referencedFiles.push(planPath);
            log('info', `Context resolution: found plan "${planFile}" by ${agent.name} for work item ${item.id}`);
          } catch (e) { log('warn', 'resolve plan context: ' + e.message); }
        } else if (plans.length > 0) {
          // Fallback: try to find a plan file with the agent's name or ID in it
          const match = plans.find(f => f.toLowerCase().includes(agent.id) || f.toLowerCase().includes(agent.name));
          if (match) {
            const planPath = path.join(MINIONS_DIR, 'plans', match);
            try {
              const content = safeRead(planPath);
              const truncated = truncateReferencedContext(content, ENGINE_DEFAULTS.maxReferencedPlanBytes, 'referenced plan');
              const fenced = wrapUntrusted(truncated, buildSource('wi-reference', { path: `plans/${match}` }));
              resolved.additionalContext += `\n\n## Referenced Plan: ${match}\n\n${fenced || truncated}`;
              resolved.referencedFiles.push(planPath);
              log('info', `Context resolution: found plan "${match}" (name match) for work item ${item.id}`);
            } catch (e) { log('warn', 'resolve plan fallback context: ' + e.message); }
          }
        }
      } catch (e) { log('warn', 'resolve agent plan context: ' + e.message); }
    }

    // Match agent output/notes references
    const outputPatterns = [
      new RegExp(`${agent.name}(?:'s|s)?\\s+(?:output|findings|notes|results)`, 'i'),
      new RegExp(`(?:output|findings|notes|results)\\s+(?:from|by)\\s+${agent.name}`, 'i'),
    ];
    if (outputPatterns.some(p => p.test(text))) {
      // Find the agent's latest inbox notes
      try {
        const inboxDir = path.join(MINIONS_DIR, 'notes', 'inbox');
        const files = fs.readdirSync(inboxDir)
          .filter(f => f.startsWith(agent.id + '-'))
          .sort().reverse();
        if (files.length > 0) {
          const content = safeRead(path.join(inboxDir, files[0]));
          const truncated = truncateReferencedContext(content, ENGINE_DEFAULTS.maxReferencedNotesBytes, 'referenced notes');
          const fenced = wrapUntrusted(truncated, buildSource('inbox', { filename: files[0] }));
          resolved.additionalContext += `\n\n## Referenced Notes by ${agent.name}: ${files[0]}\n\n${fenced || truncated}`;
          resolved.referencedFiles.push(path.join(inboxDir, files[0]));
          log('info', `Context resolution: found notes "${files[0]}" by ${agent.name} for work item ${item.id}`);
        }
      } catch (e) { log('warn', 'resolve plan context outer: ' + e.message); }
    }
  }

  // If no specific reference was resolved but the text mentions "the plan" or "latest plan",
  // find the most recent plan
  if (!resolved.additionalContext && /\b(the|latest|last|recent)\s+plan\b/i.test(text)) {
  
    try {
      const plans = fs.readdirSync(path.join(MINIONS_DIR, 'plans'))
        .filter(f => f.endsWith('.md') || f.endsWith('.json'))
        .sort().reverse();
      if (plans.length > 0) {
        const planPath = path.join(MINIONS_DIR, 'plans', plans[0]);
        const content = safeRead(planPath);
        const truncated = truncateReferencedContext(content, ENGINE_DEFAULTS.maxReferencedPlanBytes, 'referenced plan');
        const fenced = wrapUntrusted(truncated, buildSource('wi-reference', { path: `plans/${plans[0]}` }));
        resolved.additionalContext += `\n\n## Referenced Plan (latest): ${plans[0]}\n\n${fenced || truncated}`;
        resolved.referencedFiles.push(planPath);
        log('info', `Context resolution: using latest plan "${plans[0]}" for work item ${item.id}`);
      }
    } catch (e) { log('warn', 'resolve latest plan context: ' + e.message); }
  }

  if (resolved.additionalContext) {
    resolved.additionalContext = truncateTextBytes(
      resolved.additionalContext,
      ENGINE_DEFAULTS.maxResolvedTaskContextBytes,
      '\n\n_...additional referenced context truncated — read the referenced files if needed._'
    );
  }
  return resolved;
}

// ─── Required Template Variables ────────────────────────────────────────────
// Defines which caller-provided variables are mandatory per playbook type.
// Base vars (from buildBaseVars) and project vars (injected by renderPlaybook)
// are always present and excluded. Variables in conditional blocks ({{#key}})
// are optional by design. Only variables that make the playbook non-functional
// when absent are listed here.

// ─── Optional Template Variables ────────────────────────────────────────────
// Variables that legitimately resolve to empty string for ad-hoc work items.
// These are silently filtered out of the empty-string warning to avoid
// masking real warnings. Add new optional vars here when they are contextual
// (plan-linked, checkpoint-dependent, etc.) and not required for the playbook
// to function.

const PLAYBOOK_OPTIONAL_VARS = new Set([
  'source_plan',          // only set when work item is linked to a plan
  'plan_slug',            // derived from source_plan
  'additional_context',   // only set when item has a prompt
  'references',           // only set when item.references has entries
  'acceptance_criteria',  // only set when item.acceptanceCriteria has entries
  'checkpoint_context',   // only set when resuming from a prior timeout
  // Host-specific identifiers — always one of {ado_*, github_*} is empty
  // (project is either ADO or GitHub, never both). Keeping them as warns
  // produces ~96 noise lines per day on a single-host project.
  'ado_org',              // empty for GitHub projects
  'ado_project',          // empty for GitHub projects
  'github_org',           // empty for ADO projects
  // Meeting / context vars that legitimately render empty
  'human_notes',          // meetings without human notes have an empty list
  'notes_content',        // empty when team notes are injected via the appendix instead
  'all_findings',         // only set in debate/conclude rounds, not investigate
  'all_debate',           // only set in conclude round
  'existing_prd_json',    // only set when re-running plan-to-prd over an existing PRD
  'branch_strategy_hint', // only set for shared-branch plans
  'review_note',          // only set on fix/review tasks tied to a comment
  // PR-context vars on non-PR tasks (implement/explore/etc.)
  'pr_id', 'pr_number', 'pr_title', 'pr_branch', 'pr_author', 'pr_url',
  'reviewer',
  // W-mq16xtdx001a347e — review dispatches inject this block (or empty string
  // when discovery finds no project-local review skills). Optional because
  // non-review playbooks never reference it and review-against-skill-less
  // projects legitimately resolve it to ''.
  'project_review_skills_block',
  'skip_project_review_skills',
  // W-mq5l3f2u000i9045 — opt-out flag for the PR description audit + screenshot
  // refresh fragment inlined into fix.md / implement.md. Truthy (set when
  // item.meta.skipDescriptionAudit is true) renders the "audit suppressed"
  // notice; falsy/empty leaves the notice block out and the agent runs the
  // standard audit. Optional because (a) every other playbook ignores the var
  // and (b) the default state is the empty string, which the playbook's
  // conditional block treats as "not skipped".
  'skip_description_audit',
  // P-e6b3c2d8 — QA Session template vars. session_id / target_kind /
  // flows_raw / managed_spawn_name are required (declared in
  // PLAYBOOK_REQUIRED_VARS['qa-session-setup']); these target_* sub-fields
  // are conditional on target.kind so only one is populated per session.
  // runner_hint / capture / target_json / session_mode / session_phase are
  // surfaced for completeness but legitimately empty when not specified.
  'target_pr_id',          // populated only for target.kind === 'pr'
  'target_branch',         // populated only for target.kind === 'branch'
  'target_sha',            // populated only for target.kind === 'commit'
  'target_worktree',       // populated only for target.kind === 'current'
  'target_json',           // JSON-encoded target spec (always present when session_id is)
  'runner_hint',           // explicit runner name; empty = auto-detect
  'capture',               // 'video,screenshots,logs' summary
  'session_mode',          // 'confirm' | 'auto'
  'session_phase',         // 'setup' | 'draft' | 'execute'
  // P-f9a2e1b4 — Runner adapter briefs. engine.js calls the registered
  // runner's generateBrief()/executeBrief() at render time and surfaces the
  // resulting markdown strings here. Required for the DRAFT/EXECUTE
  // playbooks (declared in PLAYBOOK_REQUIRED_VARS below); empty in the
  // SETUP phase, which is why they live in the optional set — we don't
  // want SETUP renders to emit unresolved-var warnings.
  'runner_brief',          // generateBrief() output — used by qa-session-draft
  'runner_execute_brief',  // executeBrief() output — used by qa-session-execute
  'test_file',             // session.testFile — set after DRAFT, used by EXECUTE
  // W-mpq6xqzj000606d0 — Multi-project QA Session fan-out. SETUP playbook
  // gets one work item per project; these vars distinguish the "primary"
  // (which owns DRAFT/EXECUTE handoff and the canonical managed-spawn name)
  // from N "co-service" WIs (which just declare their own managed-spawn and
  // exit). Optional because single-project sessions never set them.
  'role',                  // 'primary' | 'co-service'
  'primary_project',       // canonical primary project name; empty on single-project sessions
  'co_services_json',      // JSON-encoded co-service project names (e.g. ["api","worker"]); empty when none
  // W-mq16xtdx001a347e (PR-82) — project-local review-skill discovery vars.
  // Always rendered (empty string when no discoveries); listed here so the
  // unresolved-var check doesn't complain when discovery returns nothing or
  // projects legitimately resolve it to ''.
  'project_review_skills_block',
  'skip_project_review_skills',
  // W-mq1cczi90006b21f — generic, intent-filtered project skills block.
  // Spliced by review/fix/implement/plan/etc. when discovery + intent map
  // produce matches. Optional for the same reason as the review-only alias.
  'project_skills_block',
  'skip_project_skills',
]);

const PLAYBOOK_REQUIRED_VARS = {
  'implement':            ['item_id', 'item_name', 'branch_name', 'project_path'],
  'implement-shared':     ['item_id', 'item_name', 'branch_name', 'worktree_path'],
  'fix':                  ['pr_id', 'pr_branch'],
  'review':               ['pr_id', 'pr_branch'],
  'build-and-test':       ['pr_id', 'pr_branch', 'project_path'],
  'explore':              ['task_description'],
  'ask':                  ['question'],
  'plan':                 ['task_description', 'project_path'],
  'plan-to-prd':          ['plan_content', 'plan_file', 'prd_filename', 'project_path'],
  'decompose':            ['item_id', 'item_description', 'project_path'],
  'verify':               ['task_description'],
  'test':                 ['item_name'],
  'docs':                 ['item_id', 'item_name'],
  'setup':                ['item_id', 'item_name', 'project_path'],
  'qa-validate':          ['item_id', 'item_name', 'qa_run_id'],
  // P-e6b3c2d8 — QA Session SETUP phase. Required vars are session
  // identification + target kind + flows + the deterministic managed-spawn
  // name the engine joins back to the session on. Conditional target_*
  // sub-fields (target_pr_id / target_branch / target_sha / target_worktree)
  // are optional — only one is populated per target.kind.
  'qa-session-setup':     ['session_id', 'target_kind', 'flows_raw', 'managed_spawn_name'],
  // P-f9a2e1b4 — QA Session DRAFT phase. Required vars are the session id +
  // flows + the live managed-spawn name + the runner adapter's
  // generateBrief() output. The brief is computed at render time by
  // engine.js (lazy-required qa-sessions + qa-runners + managed-spawn).
  'qa-session-draft':     ['session_id', 'flows_raw', 'managed_spawn_name', 'runner_brief'],
  // P-f9a2e1b4 — QA Session EXECUTE phase. Required vars are session id +
  // live managed-spawn name + executeBrief() output + the qa-runs record id
  // (the agent stamps it on the result sidecar so lifecycle.js can ingest it).
  'qa-session-execute':   ['session_id', 'managed_spawn_name', 'runner_execute_brief', 'qa_run_id'],
  'work-item':            ['item_id', 'item_name'],
  'meeting-investigate':  ['meeting_title', 'agenda'],
  'meeting-debate':       ['meeting_title', 'agenda'],
  'meeting-conclude':     ['meeting_title', 'agenda'],
};

/**
 * Validate that all required template variables for a playbook type are present
 * and non-empty in the provided vars object.
 * @param {string} playbookName - The playbook type name
 * @param {object} vars - The template variables object
 * @returns {{ valid: boolean, missing: string[] }}
 */
function validatePlaybookVars(playbookName, vars) {
  const required = PLAYBOOK_REQUIRED_VARS[playbookName];
  if (!required) return { valid: true, missing: [] };

  const missing = required.filter(key => {
    const val = vars[key];
    return val === undefined || val === null || String(val).trim() === '';
  });

  return { valid: missing.length === 0, missing };
}

// ─── Playbook Path Resolution ───────────────────────────────────────────────

/**
 * Resolve playbook file path, checking project-local override first.
 * If projects/<projectName>/playbooks/<playbookType>.md exists, use it.
 * Otherwise fall back to the global playbooks/<playbookType>.md.
 * @param {string|null|undefined} projectName — project name (directory under projects/)
 * @param {string} playbookType — playbook type name (e.g. 'implement', 'review')
 * @returns {string} absolute path to the playbook file
 */
function isSafeProjectPlaybookName(projectName) {
  const name = String(projectName || '').trim();
  return !!name &&
    !name.includes('\0') &&
    !name.includes('..') &&
    !/[\\/]/.test(name) &&
    !path.isAbsolute(name) &&
    !/^[a-zA-Z]:/.test(name);
}

function isSafePlaybookType(playbookType) {
  const name = String(playbookType || '').trim();
  return !!name &&
    !name.includes('\0') &&
    !name.includes('..') &&
    !/[\\/]/.test(name) &&
    !path.isAbsolute(name) &&
    !/^[a-zA-Z]:/.test(name) &&
    // W-mq5l3f2u000i9045 — files named `_*.md` are shared fragments inlined
    // into dispatching playbooks (e.g. `_pr-description-audit.md`). They are
    // not standalone playbooks; reject `_…` types so a dispatch can never
    // resolve to a fragment file.
    !name.startsWith('_');
}

function resolvePlaybookPath(projectName, playbookType) {
  const playbookTypeName = String(playbookType || '').trim();
  if (!isSafePlaybookType(playbookTypeName)) {
    log('warn', `Skipping unsafe playbook type lookup: ${playbookTypeName.replace(/\0/g, '')}`);
    return path.join(PLAYBOOKS_DIR, '__invalid__.md');
  }
  if (projectName) {
    const projectDirName = String(projectName).trim();
    if (isSafeProjectPlaybookName(projectDirName)) {
      const projectsDir = path.join(MINIONS_DIR, 'projects');
      const localPath = path.resolve(projectsDir, projectDirName, 'playbooks', `${playbookTypeName}.md`);
      if (!shared.isPathInside(localPath, projectsDir)) {
        log('warn', `Skipping project-local playbook outside projects/: ${projectDirName}`);
        return path.join(PLAYBOOKS_DIR, `${playbookTypeName}.md`);
      }
      try {
        fs.accessSync(localPath, fs.constants.R_OK);
        log('info', `Using project-local playbook: projects/${projectDirName}/playbooks/${playbookTypeName}.md`);
        return localPath;
      } catch { /* no local override — fall through to global */ }
    } else {
      log('warn', `Skipping project-local playbook lookup for unsafe project name: ${projectDirName}`);
    }
  }
  return path.join(PLAYBOOKS_DIR, `${playbookTypeName}.md`);
}

// W-mpeiwz6k0005bf34-c — Build the QA Run Context block that renderPlaybook
// injects when vars.qa_run_id is set. Pure formatter: takes the runbook +
// target snapshot the dispatcher captured (and stored on the work item meta)
// and renders a compact, prompt-friendly summary. Heavy guards against
// missing fields because dispatch callers may supply partial snapshots when
// the managed-process state has rotated between schedule and dispatch.
function buildQaValidateContextBlock({ runId, runbook, target, artifactsDir }) {
  if (!runId) return '';
  const lines = [];
  lines.push('## QA Run Context');
  lines.push('');
  lines.push(`- **runId:** \`${runId}\``);
  if (artifactsDir) lines.push(`- **artifactsDir:** \`${artifactsDir}\``);
  lines.push('');

  const rb = runbook && typeof runbook === 'object' ? runbook : null;
  if (rb) {
    lines.push('### Runbook');
    lines.push(`- **id:** \`${rb.id || ''}\``);
    if (rb.name) lines.push(`- **name:** ${rb.name}`);
    if (Array.isArray(rb.steps) && rb.steps.length > 0) {
      lines.push('- **steps:**');
      rb.steps.forEach((s, i) => {
        if (!s || typeof s !== 'object') return;
        const desc = String(s.description || '').trim();
        const cmd = s.command ? `  \`${String(s.command).trim()}\`` : '';
        lines.push(`  ${i + 1}. ${desc}${cmd}`);
      });
    }
    if (Array.isArray(rb.expectedArtifacts) && rb.expectedArtifacts.length > 0) {
      lines.push('- **expectedArtifacts:**');
      for (const a of rb.expectedArtifacts) {
        if (!a || typeof a !== 'object') continue;
        const type = String(a.type || 'other');
        const label = String(a.label || '').trim();
        const hint = a.path ? ` (\`${a.path}\`)` : '';
        lines.push(`  - \`${type}\` — ${label}${hint}`);
      }
    }
    lines.push('');
  }

  const t = target && typeof target === 'object' ? target : null;
  if (t) {
    lines.push('### Target (managed-process snapshot)');
    if (t.name) lines.push(`- **name:** \`${t.name}\``);
    if (t.owner_project) lines.push(`- **project:** \`${t.owner_project}\``);
    if (typeof t.healthy === 'boolean') lines.push(`- **healthy:** ${t.healthy}`);
    if (Array.isArray(t.ports) && t.ports.length > 0) lines.push(`- **ports:** ${t.ports.join(', ')}`);
    if (t.attrs && typeof t.attrs === 'object') {
      const base = t.attrs.base_url || t.attrs.baseUrl;
      const framework = t.attrs.framework;
      if (base) lines.push(`- **base_url:** ${base}`);
      if (framework) lines.push(`- **framework:** ${framework}`);
    }
    lines.push('');
  }

  lines.push('Use this context to execute the runbook against the live target. Write the result sidecar to `agents/<your-id>/qa-run-result.json` before exit — the engine consumes it in `engine/lifecycle.js` and calls `qaRuns.completeRun(runId, ...)`.');

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


// ─── Playbook Renderer ──────────────────────────────────────────────────────

/**
 * Parse YAML-ish frontmatter at the top of a playbook file.
 * Returns { meta: {key:value}, body: string }. Recognized values:
 *   true|yes|1  → boolean true
 *   false|no|0  → boolean false
 *   everything else → raw string
 */
function _parsePlaybookFrontmatter(content) {
  const m = String(content || '').match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
  if (!m) return { meta: {}, body: content || '' };
  const meta = {};
  for (const line of m[1].split(/\r?\n/)) {
    const lm = line.match(/^([\w-]+):\s*(.*)$/);
    if (!lm) continue;
    const key = lm[1];
    const raw = lm[2].trim();
    if (/^(true|yes|1)$/i.test(raw)) meta[key] = true;
    else if (/^(false|no|0)$/i.test(raw)) meta[key] = false;
    else meta[key] = raw;
  }
  return { meta, body: m[2].replace(/^\r?\n+/, '') };
}

function renderPlaybook(type, vars) {
  const projectName = vars.project_name || null;
  const pbPath = resolvePlaybookPath(projectName, type);
  let rawContent;
  try { rawContent = fs.readFileSync(pbPath, 'utf8'); } catch {
    log('warn', `Playbook not found: ${type}`);
    return null;
  }

  // P-bf07 — parse frontmatter and enforce requiresProjectContext before any
  // substitution work. Hard-fail dispatch when the playbook requires a
  // project but none is configured (i.e. fresh install) so the agent prompt
  // can never expand {{project_name}} / {{repo_name}} / {{ado_org}} /
  // {{github_org}} to empty strings or the bogus 'Unknown' defaults.
  const { meta: playbookMeta, body: bodyAfterFm } = _parsePlaybookFrontmatter(rawContent);
  let content = bodyAfterFm;

  const configEarly = getConfig();
  const projectsEarly = getProjects(configEarly);
  const matchedProject = (vars.repo_id && projectsEarly.find(p => p.repositoryId === vars.repo_id))
    || (vars.repo_name && projectsEarly.find(p => p.repoName === vars.repo_name))
    || projectsEarly[0]
    || null;
  // Callers (PR dispatches, fan-out, unit tests) may supply project context
  // in vars even when on-disk config has zero projects. Treat that as
  // "project context provided" — the only true bug is when buildBaseVars
  // fell back to the placeholder defaults ('Unknown Project' / 'Unknown')
  // because no project object was available.
  const callerSuppliedProjectContext = !!(
    vars.repo_id
    || (vars.repo_name && vars.repo_name !== 'Unknown')
    || (vars.project_name && vars.project_name !== 'Unknown Project')
  );
  if (!matchedProject && !callerSuppliedProjectContext && playbookMeta.requiresProjectContext === true) {
    const err = new Error(`Playbook ${type} requires a project but none is configured. Add a project via \`minions project add\`.`);
    err.code = 'PLAYBOOK_REQUIRES_PROJECT';
    err.playbook = type;
    throw err;
  }

  // Inject shared rules (apply to all playbooks). Strip any frontmatter so the
  // P-bf07 audit metadata doesn't leak into the rendered prompt.
  try {
    const sharedRulesRaw = fs.readFileSync(path.join(PLAYBOOKS_DIR, 'shared-rules.md'), 'utf8');
    const { body: sharedRules } = _parsePlaybookFrontmatter(sharedRulesRaw);
    if (sharedRules) content += '\n\n' + sharedRules;
  } catch { /* optional — shared rules file may not exist */ }

  const inertAppendices = [];

  // Inject pinned context (always visible to agents) — capped at 4KB.
  // F5 (W-mpeklod3000we69c): wrap in <UNTRUSTED-INPUT> fence — human-edited
  // file that ends up in every agent prompt.
  let pinnedContent = '';
  try { pinnedContent = fs.readFileSync(path.join(MINIONS_DIR, 'pinned.md'), 'utf8'); } catch { /* optional */ }
  if (pinnedContent) {
    if (pinnedContent.length > 4096) pinnedContent = pinnedContent.slice(0, 4096) + '\n\n_...pinned.md truncated (read full file if needed)_';
    const fenced = wrapUntrusted(pinnedContent, buildSource('pinned-note', { path: 'pinned.md' }));
    inertAppendices.push('\n\n---\n\n## Pinned Context (CRITICAL — READ FIRST)\n\n' + (fenced || pinnedContent));
  }

  // Inject team notes (single injection point — not in buildAgentContext) — capped via ENGINE_DEFAULTS.
  // F5: wrap in <UNTRUSTED-INPUT> fence — notes.md is an LLM-consolidated mix
  // of agent inbox notes (semi-trusted) and human edits.
  let notes = getNotes();
  if (notes) {
    if (Buffer.byteLength(notes, 'utf8') > ENGINE_DEFAULTS.maxNotesPromptBytes) {
      const sections = notes.split(/(?=^### )/m);
      const recent = sections.slice(-10).join('') || notes;
      const olderCount = Math.max(0, sections.length - 10);
      const footer = olderCount > 0 ? `\n\n_${olderCount} older entries in \`notes.md\` — Read if needed._` : '';
      const budget = Math.max(0, ENGINE_DEFAULTS.maxNotesPromptBytes - Buffer.byteLength(footer, 'utf8'));
      notes = truncateTextBytes(recent, budget, '\n\n_...notes truncated_') + footer;
    }
    const fenced = wrapUntrusted(notes, buildSource('team-notes', { path: 'notes.md' }));
    inertAppendices.push('\n\n---\n\n## Team Notes (MUST READ)\n\n' + (fenced || notes));
  }

  // Inject per-agent memory file (knowledge/agents/<agentId>.md) — personal
  // notebook curated by the consolidation pipeline. Capped at the same
  // notes budget; missing file degrades gracefully (silent skip).
  // F5: fence — agent-authored inbox notes routed into this file; any agent
  // could include attacker-controlled quoted material.
  const agentIdForMemory = vars.agent_id;
  if (agentIdForMemory && /^[a-z][a-z0-9-]{0,40}$/i.test(agentIdForMemory) && !String(agentIdForMemory).toLowerCase().startsWith('temp-')) {
    const agentMemRel = `knowledge/agents/${String(agentIdForMemory).toLowerCase()}.md`;
    const agentMemPath = path.join(MINIONS_DIR, agentMemRel);
    let agentMem = '';
    try { agentMem = fs.readFileSync(agentMemPath, 'utf8'); } catch { /* optional — file may not exist */ }
    if (agentMem && agentMem.trim()) {
      if (Buffer.byteLength(agentMem, 'utf8') > ENGINE_DEFAULTS.maxNotesPromptBytes) {
        const sections = agentMem.split(/(?=^### )/m);
        const recent = sections.slice(-10).join('') || agentMem;
        const budget = Math.max(0, ENGINE_DEFAULTS.maxNotesPromptBytes);
        agentMem = truncateTextBytes(recent, budget, '\n\n_...agent memory truncated_');
      }
      const fenced = wrapUntrusted(agentMem, buildSource('agent-memory', { path: agentMemRel }));
      inertAppendices.push('\n\n---\n\n## Personal Memory (your past learnings — MUST READ)\n\n' + (fenced || agentMem));
    }
  }

  // W-mp68q6ke0010de68 — opt-in keep_processes hint. Injected only when the
  // dispatcher set vars.keep_processes (truthy) from the work item's
  // `meta.keep_processes`. Built via the keep-process-sweep module so the
  // copy stays in sync with the engine-side caps it actually enforces.
  if (vars.keep_processes) {
    try {
      const keepProcessSweep = require('./keep-process-sweep');
      const hint = keepProcessSweep.buildKeepProcessesHint({
        agentId: vars.agent_id,
        workItemId: vars.item_id || vars.task_id,
        ttlMinutes: vars.keep_processes_ttl_minutes,
        minionsDir: MINIONS_DIR,
      });
      if (hint) inertAppendices.push(hint);
    } catch (e) { log('warn', `keep_processes hint render failed: ${e.message}`); }
  }

  // P-1f9c3a45 — opt-in managed_spawn dispatch hint. Mirrors keep_processes:
  // injected only when the dispatcher set vars.managed_spawn (truthy) from the
  // work item's `meta.managed_spawn`. Tells the agent how to write the
  // managed-spawn sidecar so the engine takes over spawn + healthcheck.
  if (vars.managed_spawn) {
    try {
      const managedSpawn = require('./managed-spawn');
      const hint = managedSpawn.buildManagedSpawnHint({
        agentId: vars.agent_id,
        workItemId: vars.item_id || vars.task_id,
        ttlMinutes: vars.managed_spawn_ttl_minutes,
        minionsDir: MINIONS_DIR,
      });
      if (hint) inertAppendices.push(hint);
    } catch (e) { log('warn', `managed_spawn hint render failed: ${e.message}`); }
  }

  // P-1f9c3a45 — auto-inject live managed processes block, project-scoped.
  // Unconditional: any healthy+alive spec whose owner_project matches
  // vars.project_name is surfaced to the dispatched agent so downstream WIs
  // discover services stood up by earlier WIs without human hand-off. Cap at
  // ENGINE_DEFAULTS.managedSpawn.promptContextMaxBytes (default 2KB) — the
  // helper falls back to a compact name+base_url list when over cap. Empty
  // string is returned when nothing matches, so we short-circuit on falsy.
  if (vars.project_name) {
    try {
      const managedSpawn = require('./managed-spawn');
      const liveBlock = managedSpawn.buildLiveManagedProcessesBlock({
        project: vars.project_name,
      });
      if (liveBlock) inertAppendices.push(liveBlock);
    } catch (e) { log('warn', `managed-spawn live-processes inject failed: ${e.message}`); }
  }

  // W-mq16xtdx001a347e (PR #82) + W-mq1cczi90006b21f (generalization) —
  // surface project-local skills relevant to the playbook being dispatched.
  //
  // PR-82 hard-scoped this to type === 'review'. This wiring generalizes it:
  //   1. Discovery walks every project skill / command / documented slash-
  //      command (engine/discover-project-skills.js).
  //   2. engine/playbook-intents.js maps playbook → intent set.
  //   3. Discovered entries are filtered to the playbook's intent set and
  //      rendered into `project_skills_block`. Empty intent set or zero
  //      matches → empty string (no stray header).
  //
  // Header copy: review dispatches keep PR-82's "## Project review skills"
  // header (existing agents/tests grep for it). All other playbooks use the
  // generalized "## Project skills" header.
  //
  // Backward-compat: `project_review_skills_block` and
  // `vars.skip_project_review_skills` remain wired so external consumers of
  // the PR-82 names keep working. They alias to the generic var/flag.
  const skipProjectSkills = !!(vars.skip_project_skills || vars.skip_project_review_skills);
  let projectSkillsBlock = '';
  if (!skipProjectSkills) {
    try {
      const discover = require('./discover-project-skills');
      const { intentsForPlaybook } = require('./playbook-intents');
      const projectPath = matchedProject?.localPath || '';
      if (projectPath) {
        // Inherit intent set from parent dispatch type when the engine
        // passes vars.parent_dispatch_type (followup-dispatch flow).
        const effectiveType = vars.parent_dispatch_type || type;
        const intents = intentsForPlaybook(effectiveType);
        if (intents.length > 0) {
          const entries = discover.discoverProjectSkills({ projectPath });
          const filtered = discover.filterByIntents(entries, intents);
          // type === 'review' keeps PR-82 header copy + meta.review.* outcome
          // guidance; everything else uses the generic header that points at
          // meta.skill.* in the completion report.
          projectSkillsBlock = (type === 'review')
            ? discover.renderReviewSkillsBlock(filtered)
            : discover.renderProjectSkillsBlock(filtered);
        }
      }
    } catch (e) {
      log('warn', `discover-project-skills failed: ${e.message}`);
    }
  }
  vars.project_skills_block = projectSkillsBlock;
  // Backward-compat alias for any consumer that still references
  // {{project_review_skills_block}} directly. Same content; cheaper than
  // re-rendering. Non-review playbooks that don't reference the alias
  // ignore it (it's in PLAYBOOK_OPTIONAL_VARS).
  vars.project_review_skills_block = projectSkillsBlock;

  // W-mpeiwz6k0005bf34-c — opt-in qa-validate context block. Injected only
  // when the dispatcher set vars.qa_run_id (truthy) from the work item's
  // `meta.qaRunId`. Mirrors the managed_spawn hint pattern: the playbook is
  // pure markdown; this block surfaces the live runbook + target snapshot so
  // the agent doesn't need to re-resolve them from disk.
  if (vars.qa_run_id) {
    try {
      const block = buildQaValidateContextBlock({
        runId: vars.qa_run_id,
        runbook: vars.qa_runbook,
        target: vars.qa_target,
        artifactsDir: vars.qa_artifacts_dir,
      });
      if (block) inertAppendices.push(block);
    } catch (e) { log('warn', `qa-validate context render failed: ${e.message}`); }
  }

  // Inject KB guardrail
  content += `\n\n---\n\n## Knowledge Base Rules\n\n`;
  content += `**Never delete, move, or overwrite files in \`knowledge/\`.** The sweep (consolidation engine) is the only process that writes to \`knowledge/\`. If you think a KB file is wrong, note it in your learnings file — do not touch \`knowledge/\` directly.\n`;

  // Inject learnings requirement
  const timeStamp = ts().slice(11, 16).replace(':', '');
  const inboxSlug = [vars.agent_id || 'agent', vars.task_id || '', dateStamp(), timeStamp].filter(Boolean).join('-');
  content += `\n\n---\n\n## REQUIRED: Write Learnings\n\n`;
  const noteId = `NOTE-${shared.uid()}`;
  content += `After completing your task, write **one** findings file to:\n`;
  content += `\`${MINIONS_DIR}/notes/inbox/${inboxSlug}.md\`\n\n`;
  content += `Start the file with this YAML frontmatter (required for tracking):\n`;
  content += `\`\`\`\n---\nid: ${noteId}\nagent: ${vars.agent_id || 'agent'}\ndate: ${dateStamp()}\n---\n\`\`\`\n\n`;
  content += `**IMPORTANT: Write exactly ONE inbox file per task.** If the playbook above already specifies an inbox path, use THAT path instead and include your learnings in the same document. Do NOT create a second file — duplicates clog consolidation.\n\n`;
  content += `Include in your findings file:\n`;
  content += `- What you learned about the codebase\n`;
  content += `- Patterns you discovered or established\n`;
  content += `- Gotchas or warnings for future agents\n`;
  content += `- Conventions to follow\n`;
  content += `- **SOURCE REFERENCES for every finding** — file paths with line numbers, PR URLs, API endpoints, config keys. Format: \`(source: path/to/file.ts:42)\` or \`(source: PR-12345)\`. Without references, findings cannot be verified.\n\n`;

  // Inject project-level variables from config
  const config = configEarly;
  const projects = projectsEarly;
  // Find the specific project being dispatched (match by repo_id or repo_name from vars).
  // P-bf07 — when no project resolves we keep the legacy `{}` fallback ONLY for
  // playbooks that opted out (requiresProjectContext: false). Required-context
  // playbooks already short-circuited above with a structured error.
  const dispatchProject = matchedProject || {};
  const renderProject = {
    ...dispatchProject,
    adoOrg: vars.ado_org || vars.adoOrg || dispatchProject.adoOrg,
    adoProject: vars.ado_project || vars.adoProject || dispatchProject.adoProject,
    githubOrg: vars.github_org || vars.githubOrg || dispatchProject.githubOrg,
    repoName: vars.repo_name || vars.repoName || dispatchProject.repoName,
    repoHost: vars.repo_host || vars.repoHost || dispatchProject.repoHost,
  };
  const repoHost = getRepoHost(renderProject);
  const projectVars = {
    project_name: renderProject.name || 'Unknown Project',
    ado_org: repoHost === 'ado' ? (renderProject.adoOrg || 'Unknown') : '',
    github_org: repoHost === 'github' ? getProjectOrg(renderProject) : '',
    ado_project: renderProject.adoProject || 'Unknown',
    repo_name: renderProject.repoName || 'Unknown',
    repo_host: repoHost,
    ado_shared_rules: repoHost === 'ado' ? '1' : '',
    github_shared_rules: repoHost === 'github' ? '1' : '',
    pr_create_instructions: getPrCreateInstructions(renderProject),
    pr_comment_instructions: getPrCommentInstructions(renderProject),
    pr_fetch_instructions: getPrFetchInstructions(renderProject),
    pr_vote_instructions: getPrVoteInstructions(renderProject),
    repo_host_label: getRepoHostLabel(renderProject),
  };
  const allVars = { ...projectVars, ...vars };

  // Default optional vars to empty string when caller omitted them entirely.
  // Without this, an unset optional var leaves `{{varname}}` in the content and
  // trips the "unresolved template variables" warning at the end of render.
  // The empty-string filter (line below) already silences the empty-vars warn
  // for PLAYBOOK_OPTIONAL_VARS.
  for (const key of PLAYBOOK_OPTIONAL_VARS) {
    if (allVars[key] === undefined) allVars[key] = '';
  }

  // Validate required template variables before substitution
  const validation = validatePlaybookVars(type, allVars);
  if (!validation.valid) {
    log('error', `Playbook "${type}": missing required template variables: ${validation.missing.join(', ')} — skipping render`);
    return null;
  }

  // Capture which vars are actually referenced in the template before substitution
  const referencedVars = new Set((content.match(/\{\{(\w+)\}\}/g) || []).map(m => m.slice(2, -2)));

  // Process conditional blocks: {{#key}}...{{/key}} — include block only if key is truthy
  content = content.replace(/\{\{#(\w+)\}\}([\s\S]*?)\{\{\/\1\}\}/g, (_, key, block) => {
    const val = allVars[key];
    return (val && String(val).trim()) ? block : '';
  });

  // Substitute variables
  for (const [key, val] of Object.entries(allVars)) {
    content = content.replace(new RegExp(`\\{\\{${key}\\}\\}`, 'g'), String(val));
  }

  // Warn when a substituted value itself contains {{...}} patterns (potential self-reference)
  const selfRefVars = Object.entries(allVars)
    .filter(([, val]) => /\{\{\w+\}\}/.test(String(val)))
    .map(([key]) => key);
  if (selfRefVars.length > 0) {
    log('warn', `Playbook "${type}": substituted values contain unresolved {{...}} patterns (potential self-reference): ${selfRefVars.join(', ')}`);
  }

  // Warn on variables that resolved to empty string — only for vars actually used in the template
  const emptyVars = Object.entries(allVars)
    .filter(([key, val]) => String(val) === '' && referencedVars.has(key) && !PLAYBOOK_OPTIONAL_VARS.has(key))
    .map(([key]) => key);
  if (emptyVars.length > 0) {
    log('warn', `Playbook "${type}": template variables resolved to empty string: ${emptyVars.join(', ')}`);
  }

  // Warn on any remaining unresolved {{variable}} placeholders
  const unresolved = [...new Set((content.match(/\{\{(\w+)\}\}/g) || []).map(m => m.slice(2, -2)))];
  if (unresolved.length > 0) {
    log('warn', `Playbook "${type}": unresolved template variables: ${unresolved.join(', ')}`);
  }

  if (inertAppendices.length > 0) {
    content += inertAppendices.join('');
  }

  return content;
}

// ─── System Prompt Builder ──────────────────────────────────────────────────

// Lean system prompt: agent identity + rules only (~2-4KB, never grows)
function buildSystemPrompt(agentId, config, project) {
  const agent = config.agents[agentId] || tempAgents.get(agentId) || { name: agentId, role: 'Temporary Agent', skills: [] };
  const charter = getAgentCharter(agentId); // returns '' for temp agents (no charter file)
  project = project || getProjects(config)[0] || {};

  let prompt = '';

  // Agent identity
  prompt += `# You are ${agent.name} (${agent.role})\n\n`;
  prompt += `Agent ID: ${agentId}\n`;
  prompt += `Expertise: ${(agent.skills || []).join(', ')}\n\n`;

  // Charter (detailed instructions — typically 1-2KB)
  if (charter) {
    prompt += `## Your Charter\n\n${charter}\n\n`;
  }

  // Project context (fixed size)
  prompt += `## Project: ${project.name || 'Unknown Project'}\n\n`;
  prompt += `- Repo: ${project.repoName || 'Unknown'} (${getProjectOrg(project) || 'Unknown'}${project.repoHost === 'ado' ? '/' + (project.adoProject || 'Unknown') : ''})\n`;
  prompt += `- Repo ID: ${project.repositoryId || ''}\n`;
  prompt += `- Repo host: ${getRepoHostLabel(project)}\n`;
  prompt += `- Main branch: ${project.mainBranch || 'main'}\n\n`;

  // Critical rules (fixed size)
  prompt += `## Critical Rules\n\n`;
  prompt += `1. Use git worktrees — NEVER checkout on main working tree\n`;
  prompt += `2. ${getRepoHostToolRule(project)}\n`;
  prompt += `3. Follow the project conventions in CLAUDE.md if present\n`;
  prompt += `4. Write learnings to the path specified in the task prompt (format: \`notes/inbox/{agent}-{work-item-id}-{date}-{time}.md\`)\n`;
  prompt += `5. Agent status is managed by the engine via dispatch.json — agents do not need to track their own status\n\n`;

  return prompt;
}

// E2.b (W-mp7goxe4000p75f7): build the `## Existing Skills (do not duplicate)`
// section for buildAgentContext. Lists name + first-line description for every
// personal-scope/installed-plugin skill discovered by queries.getSkills().
// Caps at ~2KB by truncating alphabetically with a "_...and N more_" tail.
// Returns '' when there are no personal-scope skills to advertise.
const EXISTING_SKILLS_MAX_BYTES = 2048;
const PERSONAL_SKILL_SCOPES = new Set(['claude-code', 'copilot', 'agent-skill', 'plugin', 'copilot-plugin']);

function _buildExistingSkillsSection(config) {
  let skills;
  try {
    skills = queries.getSkills(config) || [];
  } catch { return ''; }
  const personal = skills
    .filter(s => PERSONAL_SKILL_SCOPES.has(s.scope))
    .map(s => ({
      name: String(s.name || '').trim(),
      description: String(s.description || '').split('\n')[0].trim(),
    }))
    .filter(s => s.name)
    .sort((a, b) => a.name.localeCompare(b.name));
  if (personal.length === 0) return '';

  const header = `## Existing Skills (do not duplicate)\n\nThese personal-scope skills already exist on disk. Do NOT emit a \`\`\`skill block whose name duplicates or near-duplicates one of these — the engine will flag it for human review and skip the write.\n\n`;
  const lines = personal.map(s => s.description ? `- ${s.name} — ${s.description}` : `- ${s.name}`);

  let body = lines.join('\n') + '\n\n';
  if (Buffer.byteLength(header + body, 'utf8') <= EXISTING_SKILLS_MAX_BYTES) {
    return header + body;
  }

  // Truncate alphabetically until we fit, leaving room for the tail line.
  let kept = lines.length;
  while (kept > 0) {
    const dropped = lines.length - kept;
    const tail = `_...and ${dropped} more (alphabetical truncation; full list on disk)_\n\n`;
    body = lines.slice(0, kept).join('\n') + '\n' + tail;
    if (Buffer.byteLength(header + body, 'utf8') <= EXISTING_SKILLS_MAX_BYTES) {
      return header + body;
    }
    kept--;
  }
  // Pathological: header alone exceeds the cap. Return at least the header
  // with a tail showing every skill was dropped, so the agent still sees the
  // section heading.
  return header + `_...and ${lines.length} more (alphabetical truncation; full list on disk)_\n\n`;
}

// Bulk context: history, notes, conventions, skills — prepended to user/task prompt.
// This is the content that grows over time and would bloat the system prompt.
function buildAgentContext(agentId, config, project) {
  project = project || getProjects(config)[0] || {};
  let context = '';

  function appendContextFile(heading, filePath, maxBytes, extra = '') {
    if (!filePath) return;
    const content = safeRead(filePath);
    if (!content || !content.trim()) return;
    const truncated = Buffer.byteLength(content, 'utf8') > maxBytes
      ? truncateTextBytes(content, maxBytes, '\n\n_...truncated; read the full file if needed_')
      : content;
    context += `## ${heading}\n\n`;
    if (extra) context += `${extra}\n\n`;
    context += `${truncated}\n\n`;
  }

  function appendIndex(heading, body, maxBytes) {
    if (!body || !String(body).trim()) return;
    const truncated = Buffer.byteLength(body, 'utf8') > maxBytes
      ? truncateTextBytes(body, maxBytes, '\n\n_...index truncated; use Glob/Read for the full list_')
      : body;
    context += `## ${heading}\n\n${truncated.replace(/^## .+\n\n/, '')}\n`;
  }


  // Agent history — last 5 tasks only (keeps it relevant, avoids 37KB dumps)
  const history = safeRead(path.join(AGENTS_DIR, agentId, 'history.md'));
  if (history && history.trim() !== '# Agent History') {
    const entries = history.split(/(?=^### )/m);
    const header = entries[0].startsWith('#') && !entries[0].startsWith('### ') ? entries.shift() : '';
    const recent = entries.slice(-5);
    const trimmed = (header ? header + '\n' : '') + recent.join('');
    context += `## Your Recent History (last 5 tasks)\n\n${trimmed}\n\n`;
  }

  // Project conventions and instruction files — explicit, inert context for
  // every runtime. Runtime-native skills/commands stay in their native indexes.
  if (project.localPath) {
    appendContextFile('Project Conventions (from CLAUDE.md)', path.join(project.localPath, 'CLAUDE.md'), 8192);
    appendContextFile('Project Agent Instructions (from AGENTS.md)', path.join(project.localPath, 'AGENTS.md'), 8192,
      'These instructions are explicitly injected because some runtimes suppress automatic AGENTS.md loading. Follow them unless they conflict with the Minions task contract or playbook.');
    appendContextFile('Project Copilot Instructions (from .github/copilot-instructions.md)', path.join(project.localPath, '.github', 'copilot-instructions.md'), 8192,
      'Follow these repository instructions unless they conflict with the Minions task contract or playbook.');
  }

  appendContextFile('User Claude Instructions (from ~/.claude/CLAUDE.md)', path.join(os.homedir(), '.claude', 'CLAUDE.md'), 8192,
    'These are the user-level Claude Code instructions available in regular Claude usage. Follow them unless they conflict with the Minions task contract or playbook.');

  appendIndex('Knowledge Base Reference', getKnowledgeBaseIndex(), 8192);

  context += `## Reference Files\n\nKnowledge base entries are in \`knowledge/{category}/*.md\`, and project-local playbooks live in \`projects/<project>/playbooks/\`. Runtime-native skills and commands live in their native indexes (the catalog of names + descriptions is injected below as "Existing Skills"; full skill bodies are not).\n\n`;

  // E2.b (W-mp7goxe4000p75f7): inject existing personal-scope skill catalog
  // so agents can see what already exists before emitting a near-duplicate
  // skill block. Caps at ~2KB; truncates alphabetically with a tail count.
  context += _buildExistingSkillsSection(config);

  // Minions awareness: what's in flight, who's doing what
  const dispatch = getDispatch();
  const activeItems = (dispatch.active || []).map(d =>
    `- **${d.agent}**: ${d.type} — ${(d.task || '').slice(0, 100)}${d.agent === agentId ? ' ← (you)' : ''}`
  );
  if (activeItems.length > 0) {
    context += `## Active Agents\n\n${activeItems.join('\n')}\n\n`;
  }

  // Recent completions (last 5, not 10)
  const recentCompleted = (dispatch.completed || []).slice(-5).reverse().map(d =>
    `- **${d.agent}** ${d.result === DISPATCH_RESULT.SUCCESS ? 'completed' : 'failed'}: ${(d.task || '').slice(0, 80)}${d.resultSummary ? ' — ' + d.resultSummary.slice(0, 100) : ''}`
  );
  if (recentCompleted.length > 0) {
    context += `## Recently Completed\n\n${recentCompleted.join('\n')}\n\n`;
  }

  // Active + linked PRs across projects — coordination awareness
  const projects = getProjects(config);
  const allPrs = [];
  for (const p of projects) {
    const prs = getPrs(p).filter(pr => pr.status === PR_STATUS.ACTIVE);
    for (const pr of prs) allPrs.push({ ...pr, _project: p.name });
  }
  // Also check central pull-requests.json
  try {
    const centralPrs = safeJson(path.join(MINIONS_DIR, 'pull-requests.json')) || [];
    for (const pr of centralPrs.filter(pr => pr.status === PR_STATUS.ACTIVE)) {
      if (!allPrs.some(p => p.id === pr.id)) allPrs.push({ ...pr, _project: 'central' });
    }
  } catch (e) { log('warn', 'read central pull-requests: ' + e.message); }
  if (allPrs.length > 0) {
    const prLines = allPrs.map(pr =>
      `- **${pr.id}** (${pr._project}): ${(pr.title || '').slice(0, 80)} [${(pr.reviewStatus || 'pending')}${pr.buildStatus === 'failing' ? ', BUILD FAILING' : ''}]${pr.branch ? ' branch: `' + pr.branch + '`' : ''}${formatPrContextSuffix(pr)}`
    );
    context += `## Active Pull Requests\n\n${prLines.join('\n')}\n\n`;
  }

  // Pending work items
  const pendingItems = (dispatch.pending || []).slice(0, 10).map(d =>
    `- ${d.type}: ${(d.task || '').slice(0, 80)}`
  );
  if (pendingItems.length > 0) {
    context += `## Pending Work Queue (${(dispatch.pending || []).length} items)\n\n${pendingItems.join('\n')}${(dispatch.pending || []).length > 10 ? '\n- ... and ' + ((dispatch.pending || []).length - 10) + ' more' : ''}\n\n`;
  }

  // Team notes injected via renderPlaybook (single injection point, with truncation)
  // Not duplicated here to avoid double-injection and token waste

  return context;
}

function formatPrContextSuffix(pr) {
  if (!Object.prototype.hasOwnProperty.call(pr, '_context')) return '';
  const value = pr._context;
  if (value === undefined || value === '') return '';
  if (typeof value === 'string') return ` — ${value.slice(0, 100)}`;

  const type = value === null ? 'null' : Array.isArray(value) ? 'array' : typeof value;
  const serialized = (value !== null && typeof value === 'object') ? JSON.stringify(value) : String(value);
  const preview = serialized ? `: ${serialized.slice(0, 100)}` : '';
  return ` — [invalid _context: expected string, got ${type}${preview}]`;
}

// ─── Work Discovery Helpers ──────────────────────────────────────────────────

function buildBaseVars(agentId, config, project) {
  // W-mq15r0cx — surface the LLM model id alongside the agent name so PR
  // comment sign-offs can stamp `<agent> · <model>` and humans can tell
  // which agent + which model authored the comment. Falls through to
  // 'unknown' (defensive only) rather than crashing or omitting the
  // field when neither agent.model nor engine.defaultModel resolves —
  // omitting would leave a dangling `· ` in the rendered sign-off.
  const resolvedModel = shared.resolveAgentModel(
    config?.agents?.[agentId] || null,
    config?.engine || null
  );
  return {
    agent_id: agentId,
    agent_name: config.agents[agentId]?.name || agentId,
    agent_role: config.agents[agentId]?.role || 'Agent',
    agent_model: resolvedModel || 'unknown',
    team_root: MINIONS_DIR,
    repo_id: project?.repositoryId || '',
    project_name: project?.name || 'Unknown Project',
    ado_org: project?.repoHost === 'ado' ? (project?.adoOrg || 'Unknown') : '',
    ado_project: project?.repoHost === 'ado' ? (project?.adoProject || 'Unknown') : '',
    github_org: project?.repoHost === 'github' ? getProjectOrg(project) : '',
    repo_name: project?.repoName || 'Unknown',
    repo_host: getRepoHost(project),
    main_branch: project?.mainBranch || 'main',
    date: dateStamp(),
  };
}

function selectPlaybook(workType, item) {
  // W-mpeiwz6k0005bf34-c — explicit playbook override via item.meta.playbook.
  // Used by /api/qa/runbooks/run to route a `test`-type work item to the
  // qa-validate playbook without minting a new work-type. Validated against
  // PLAYBOOK_REQUIRED_VARS so a typo'd override falls through to work-item
  // rather than mis-rendering.
  const playbookOverride = (item?.meta?.playbook || item?.playbook || '').toString().trim();
  if (playbookOverride && PLAYBOOK_REQUIRED_VARS[playbookOverride]) {
    return playbookOverride;
  }
  if (item?.branchStrategy === 'shared-branch' && (workType === WORK_TYPE.IMPLEMENT || workType === WORK_TYPE.IMPLEMENT_LARGE)) {
    return 'implement-shared';
  }
  // implement:large uses the same playbook as implement (no separate playbook file)
  if (workType === WORK_TYPE.IMPLEMENT || workType === WORK_TYPE.IMPLEMENT_LARGE) {
    return 'implement';
  }
  const hasPrContext = !!(item?._pr || item?.pr_id || item?.targetPr || item?.sourcePr || item?.pr);
  if (workType === WORK_TYPE.REVIEW && !hasPrContext) {
    return 'work-item';
  }
  if (workType === WORK_TYPE.FIX && hasPrContext) {
    return 'fix';
  }
  const typeSpecificPlaybooks = ['explore', 'review', 'test', 'plan-to-prd', 'plan', 'ask', 'verify', 'decompose', 'docs', 'setup', 'meeting-investigate', 'meeting-debate', 'meeting-conclude'];
  return typeSpecificPlaybooks.includes(workType) ? workType : 'work-item';
}

function buildPrDispatch(agentId, config, project, pr, type, extraVars, taskLabel, meta) {
  const dispatchId = `${agentId || 'unassigned'}-${type}-${shared.uid()}`;
  // W-mpg6wptq0011cc68: PR-context dispatches (review/fix/build-and-test) all
  // operate on the PR's branch, so default `branch_name` to `pr_branch` here
  // so shared-rules.md's `{{branch_name}}` reference resolves. Without this,
  // every PR fix/review render emits a recurring `unresolved template
  // variables: branch_name` warn. Callers may still override explicitly.
  const vars = {
    ...buildBaseVars(agentId, config, project),
    branch_name: extraVars?.pr_branch || '',
    // W-mq16xtdx001a347e + W-mq1cczi90006b21f — honor both meta flag names
    // (skipProjectReviewSkills is the PR-82 alias). Default OFF: review/fix
    // dispatches surface the project-local skills block.
    skip_project_review_skills: !!(meta && meta.skipProjectReviewSkills),
    // W-mq1cczi90006b21f — generalized project-skills suppression flag.
    // Honors either the new `meta.skipProjectSkills` or the PR-82
    // `meta.skipProjectReviewSkills` alias so older work items keep working.
    skip_project_skills: !!(meta && (meta.skipProjectSkills || meta.skipProjectReviewSkills)),
    // W-mq5l3f2u000i9045 — opt-out for the inlined PR description audit
    // section in fix.md. Truthy renders the "audit suppressed" notice; falsy
    // (the default) leaves the standard audit instructions in place.
    skip_description_audit: !!(meta && meta.skipDescriptionAudit),
    ...extraVars,
    task_id: dispatchId,
  };
  const playbookName = type === 'test' ? 'build-and-test' : (type === 'review' ? 'review' : 'fix');
  const prompt = renderPlaybook(playbookName, vars);
  if (!prompt) return null;
  return {
    id: dispatchId,
    type,
    agent: agentId,
    agentName: config.agents[agentId]?.name,
    agentRole: config.agents[agentId]?.role,
    task: `[${project?.name || 'project'}] ${taskLabel}`,
    prompt,
    meta,
  };
}

module.exports = {
  resolvePlaybookPath,
  renderPlaybook,
  validatePlaybookVars,
  PLAYBOOK_REQUIRED_VARS,
  PLAYBOOK_OPTIONAL_VARS,
  _parsePlaybookFrontmatter,
  buildSystemPrompt,
  buildAgentContext,
  selectPlaybook,
  buildBaseVars,
  buildPrDispatch,
  buildQaValidateContextBlock,
  resolveTaskContext,
  // Repo host helpers (used by engine.js for buildProjectContext)
  getRepoHost,
  getRepoHostLabel,
  getRepoHostToolRule,
  getPrCreateInstructions,
  getPrCommentInstructions,
  getPrFetchInstructions,
  getPrVoteInstructions,
};
