#!/usr/bin/env bash
set -euo pipefail

usage() {
  cat <<'USAGE'
Usage:
  agentrail init [--target DIR] [--force] [--github-labels]
  agentrail install [--target DIR] [--force] [--github-labels]
  agentrail upgrade [--target DIR] [--force]
  agentrail doctor [--target DIR]
  agentrail status [--target DIR]
  agentrail context sources [--target DIR]
  agentrail context index [--target DIR]
  agentrail context embed [--target DIR]
  agentrail context query "<task>" [--target DIR] [--json] [--limit N]
  agentrail context build issue NUMBER --phase PHASE [--target DIR] [--json]
  agentrail memory recall QUERY [--target DIR]
  agentrail memory capture KIND TITLE [--target DIR]
  agentrail skills validate [--target DIR]
  agentrail skills list [--target DIR]
  agentrail skills resolve "<task text>" [--target DIR] [--skill NAME] [--no-auto-skills]
  agentrail resume [--target DIR] [--output FILE]
  agentrail labels sync [--target DIR]
  agentrail prompt grill "<idea>" [--agent codex|claude] [--target DIR]
  agentrail prompt issue NUMBER [--agent codex|claude] [--target DIR] [--skill NAME] [--no-auto-skills]
  agentrail prompt review PR_NUMBER [--agent codex|claude] [--target DIR]
  agentrail afk [--concurrency 2] [--max-waves 20] [--base main] [--engine codex] [--afk-label afk] [--dry-run]
  agentrail console [--target DIR]
  agentrail cleanup [--target DIR] [--dry-run] [--merged] [--force]
  agentrail run [--agent codex|claude] [--target DIR] [--command CMD] [--log-dir DIR]
  agentrail run issue NUMBER [--agent codex|claude] [--target DIR] [--command CMD] [--log-dir DIR]
  agentrail run batch ISSUE... [--concurrency 2] [--agent claude] [--target DIR] [--base main]

Commands:
  init      Initialize AgentRail workflow files.
  install   Install AgentRail workflow files.
  upgrade   Upgrade managed AgentRail files without overwriting local edits.
  doctor    Inspect AgentRail installation health.
  status    Print install status and current workflow state.
  context   Inspect local context engine sources.
  memory    Recall or template project memory entries.
  skills    Inspect or validate AgentRail-managed skills.
  resume    Print and write an agent handoff summary.
  labels    Sync expected GitHub labels.
  prompt    Print an agent-ready prompt without executing an agent.
  afk       Run the AFK queue/worktree loop through the AgentRail CLI.
  console   Show dashboard status and setup instructions.
  cleanup   Inspect or remove AgentRail-owned worktrees.
  run       Generate a bounded prompt and execute a configured agent command.
USAGE
}

source_path="${BASH_SOURCE[0]}"
while [[ -L "$source_path" ]]; do
  source_dir="$(cd -P "$(dirname "$source_path")" && pwd)"
  linked_path="$(readlink "$source_path")"
  if [[ "$linked_path" == /* ]]; then
    source_path="$linked_path"
  else
    source_path="${source_dir}/${linked_path}"
  fi
done

script_dir="$(cd -P "$(dirname "$source_path")" && pwd)"
repo_dir="$(cd "${script_dir}/.." && pwd)"
agentrail_default_max_execution_attempts=5
if [[ -f "${repo_dir}/.agentrail/source/package.json" ]]; then
  repo_dir="$(cd "${repo_dir}/.agentrail/source" && pwd)"
fi
installer="${script_dir}/install-workflow"
if [[ -x "${repo_dir}/scripts/install-workflow" ]]; then
  installer="${repo_dir}/scripts/install-workflow"
fi

resolve_api_key() {
  local target_dir="${1:-.}"
  if [[ -n "${AGENTRAIL_API_KEY:-}" ]]; then
    printf '%s' "$AGENTRAIL_API_KEY"
    return 0
  fi
  local config_file="${target_dir}/.agentrail/config.json"
  if [[ -f "$config_file" ]] && command -v node >/dev/null 2>&1; then
    local key
    key="$(node -e "try{const c=JSON.parse(require('fs').readFileSync(process.argv[1],'utf8'));if(c.apiKey)process.stdout.write(c.apiKey)}catch(e){}" "$config_file" 2>/dev/null || true)"
    if [[ -n "$key" ]]; then
      printf '%s' "$key"
      return 0
    fi
  fi
  return 1
}

has_api_key() {
  resolve_api_key "${1:-.}" >/dev/null 2>&1
}

print_dashboard_hint() {
  if ! has_api_key "${1:-.}"; then
    echo "Tip: set AGENTRAIL_API_KEY to track runs in the dashboard — https://agentrail.dev" >&2
  fi
}

parse_target() {
  local target_var="$1"
  shift

  printf -v "$target_var" '%s' "$(pwd)"
  while [[ $# -gt 0 ]]; do
    case "$1" in
      --target)
        local value="${2:-}"
        [[ -n "$value" && "$value" != --* ]] || { echo "--target requires a directory" >&2; exit 2; }
        printf -v "$target_var" '%s' "$value"
        shift 2
        ;;
      -h|--help)
        usage
        exit 0
        ;;
      *)
        echo "Unknown option: $1" >&2
        usage >&2
        exit 2
        ;;
    esac
  done
}

is_agentrail_source_checkout() {
  local target_dir="$1"

  [[ -f "${target_dir}/package.json" ]] || return 1
  [[ -d "${target_dir}/templates/scripts" ]] || return 1
  [[ -x "${target_dir}/scripts/agentrail" ]] || return 1

  node - "${target_dir}/package.json" <<'NODE'
const fs = require("fs");
const packageJson = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
process.exit(packageJson.name === "@bensigo/agentrail" ? 0 : 1);
NODE
}

ensure_source_run_allowed() {
  local target_dir="$1"
  local action="$2"

  if is_agentrail_source_checkout "$target_dir" && [[ "${AGENTRAIL_ALLOW_SOURCE_RUN:-0}" != "1" ]]; then
    cat >&2 <<ERROR
Refusing to ${action} in the AgentRail source checkout.

This repo is the AgentRail package source, not an installed target project. A stray
.agentrail/state.json here can make agents start the installed-project workflow
against the workflow package itself.

Use a real target project, or set AGENTRAIL_ALLOW_SOURCE_RUN=1 only for deliberate
source dogfooding.
ERROR
    exit 1
  fi
}

parse_upgrade_args() {
  local target_var="$1"
  local force_var="$2"
  shift 2

  printf -v "$target_var" '%s' "$(pwd)"
  printf -v "$force_var" '%s' "0"
  while [[ $# -gt 0 ]]; do
    case "$1" in
      --target)
        local value="${2:-}"
        [[ -n "$value" && "$value" != --* ]] || { echo "--target requires a directory" >&2; exit 2; }
        printf -v "$target_var" '%s' "$value"
        shift 2
        ;;
      --force)
        printf -v "$force_var" '%s' "1"
        shift
        ;;
      -h|--help)
        usage
        exit 0
        ;;
      *)
        echo "Unknown option: $1" >&2
        usage >&2
        exit 2
        ;;
    esac
  done
}

run_install() {
  "$installer" "$@"
}

run_upgrade() {
  local target_dir force
  parse_upgrade_args target_dir force "$@"

  AGENTRAIL_REPO_DIR="$repo_dir" \
  AGENTRAIL_TARGET_DIR="$target_dir" \
  AGENTRAIL_FORCE="$force" \
  node <<'NODE'
const fs = require("fs");
const path = require("path");
const crypto = require("crypto");

const repoDir = process.env.AGENTRAIL_REPO_DIR;
const targetDir = process.env.AGENTRAIL_TARGET_DIR;
const force = process.env.AGENTRAIL_FORCE === "1";
const statePath = path.join(targetDir, ".agentrail/state.json");
const packageJson = JSON.parse(fs.readFileSync(path.join(repoDir, "package.json"), "utf8"));
const roots = [
  { root: path.join(repoDir, "templates"), prefix: "" },
  { root: path.join(repoDir, "skills"), prefix: "skills" },
];
const hiddenTemplatePrefix = `scripts${path.sep}`;
const skipPatterns = [
  /^TASTE\.md$/,
  /^docs[/\\]memory[/\\]/,
  /^docs[/\\]prd[/\\]context-engine\.md$/,
  /^\.claude[/\\]agents[/\\]/,
  /^\.codex[/\\]agents[/\\]/,
];
const extraFiles = [
  { sourcePath: path.join(repoDir, "scripts/agentrail"), path: "scripts/agentrail" },
];

function sha256Buffer(buffer) {
  return `sha256:${crypto.createHash("sha256").update(buffer).digest("hex")}`;
}

function sha256File(file) {
  return sha256Buffer(fs.readFileSync(file));
}

function readJson(file) {
  try {
    return JSON.parse(fs.readFileSync(file, "utf8"));
  } catch (error) {
    if (error.code === "ENOENT") {
      throw new Error("missing .agentrail/state.json; run agentrail init first");
    }
    throw error;
  }
}

function walkFiles(root) {
  const results = [];
  for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
    const fullPath = path.join(root, entry.name);
    if (entry.isDirectory()) {
      results.push(...walkFiles(fullPath));
    } else if (entry.isFile()) {
      results.push(fullPath);
    }
  }
  return results;
}

function copyFile(sourcePath, targetPath) {
  fs.mkdirSync(path.dirname(targetPath), { recursive: true });
  fs.copyFileSync(sourcePath, targetPath);
  const mode = fs.statSync(sourcePath).mode & 0o777;
  fs.chmodSync(targetPath, mode);
}

const state = readJson(statePath);
if (!Array.isArray(state.managedFiles)) {
  throw new Error("invalid .agentrail/state.json: managedFiles must be an array");
}

const previousByPath = new Map(state.managedFiles.map((file) => [file.path, file]));
const legacyAdoptedState = Boolean(state.legacyAdopted);
const inventory = [];
for (const { root, prefix } of roots) {
  for (const sourcePath of walkFiles(root).sort()) {
    const relativeToRoot = path.relative(root, sourcePath);
    if (!prefix && relativeToRoot.startsWith(hiddenTemplatePrefix)) continue;
    if (!prefix && skipPatterns.some((re) => re.test(relativeToRoot))) continue;
    const managedPath = prefix ? path.join(prefix, relativeToRoot) : relativeToRoot;
    inventory.push({
      path: managedPath.split(path.sep).join("/"),
      source: path.relative(repoDir, sourcePath).split(path.sep).join("/"),
      sourcePath,
      sourceHash: sha256File(sourcePath),
    });
  }
}
for (const item of extraFiles) {
  inventory.push({
    path: item.path,
    source: path.relative(repoDir, item.sourcePath).split(path.sep).join("/"),
    sourcePath: item.sourcePath,
    sourceHash: sha256File(item.sourcePath),
  });
}

const now = new Date().toISOString();
const nextManagedFiles = [];

console.log(`AgentRail upgrade: ${targetDir}`);

for (const item of inventory) {
  const previous = previousByPath.get(item.path);
  const targetPath = path.join(targetDir, item.path);
  const targetExists = fs.existsSync(targetPath);
  const currentHash = targetExists ? sha256File(targetPath) : null;
  const userOwned = previous && (
    previous.installStatus === "legacy-adopted" ||
    (previous.installStatus === "preserved" && legacyAdoptedState)
  );

  let category = "unchanged";
  let installStatus = previous?.installStatus || "preserved";
  let shouldCopy = false;

  if (!previous) {
    category = "added";
    installStatus = targetExists && currentHash !== item.sourceHash && !force ? "legacy-adopted" : "added";
    shouldCopy = !targetExists || force || currentHash === item.sourceHash;
  } else if (!targetExists) {
    category = "missing";
    installStatus = "restored";
    shouldCopy = true;
  } else if (userOwned && item.sourceHash !== previous.contentHash) {
    category = "locally modified";
    installStatus = force ? "forced" : "preserved";
    shouldCopy = force;
  } else if (currentHash !== previous.contentHash && currentHash !== item.sourceHash) {
    category = "locally modified";
    installStatus = force ? "forced" : "preserved";
    shouldCopy = force;
  } else if (item.sourceHash !== previous.contentHash) {
    category = "changed";
    installStatus = "updated";
    shouldCopy = true;
  }

  if (category !== "unchanged") {
    console.log(`${category}: ${item.path}`);
  }

  if (shouldCopy) {
    copyFile(item.sourcePath, targetPath);
    if (installStatus === "forced") {
      console.log(`forced: ${item.path}`);
    } else if (installStatus === "restored") {
      console.log(`restored: ${item.path}`);
    } else if (installStatus === "updated") {
      console.log(`updated: ${item.path}`);
    } else {
      console.log(`installed: ${item.path}`);
    }
  } else if (category === "locally modified") {
    console.log(`preserved local: ${item.path}`);
  } else if (category === "added") {
    console.log(`preserved existing untracked: ${item.path}`);
  }

  const finalHash = category === "locally modified" && !force && previous
    ? previous.contentHash
    : (fs.existsSync(targetPath) ? sha256File(targetPath) : item.sourceHash);
  nextManagedFiles.push({
    path: item.path,
    source: item.source,
    contentHash: finalHash,
    installStatus,
  });
}

const defaultWorkflow = {
  phase: "idle",
  activePhase: null,
  activeIssue: null,
  activePullRequest: null,
  activePrd: null,
  activeMilestone: null,
  activeRun: null,
  completedRuns: [],
  goals: [],
  worktrees: [],
  lastCompletedStep: null,
  nextSuggestedAction: "Pick a ready-for-agent issue or create a PRD/milestone before starting implementation.",
};

const nextState = {
  schemaVersion: 1,
  agentrailVersion: packageJson.version,
  installedAt: state.installedAt || now,
  updatedAt: now,
  legacyAdopted: Boolean(state.legacyAdopted),
  managedFiles: nextManagedFiles,
  workflow: {
    ...defaultWorkflow,
    ...(state.workflow || {}),
    completedRuns: Array.isArray(state.workflow?.completedRuns) ? state.workflow.completedRuns : [],
    goals: Array.isArray(state.workflow?.goals) ? state.workflow.goals : [],
    worktrees: Array.isArray(state.workflow?.worktrees) ? state.workflow.worktrees : [],
  },
};

fs.mkdirSync(path.dirname(statePath), { recursive: true });
fs.writeFileSync(statePath, `${JSON.stringify(nextState, null, 2)}\n`);
console.log("updated: .agentrail/state.json");

const configPath = path.join(targetDir, ".agentrail/config.json");
if (!fs.existsSync(configPath) || force) {
  const defaultConfig = {
    schemaVersion: 1,
    runner: {
      name: "codex",
      command: "codex exec --sandbox danger-full-access -",
    },
    context: {
      includeGlobs: ["**/*"],
      excludeGlobs: [
        ".git/**",
        "node_modules/**",
        "dist/**",
        "build/**",
        ".next/**",
        "target/**",
        "coverage/**",
        ".cache/**",
        ".turbo/**",
        ".agentrail/context/**",
        ".agentrail/source/**",
        ".env",
        ".env.*",
        "**/.env",
        "**/.env.*",
        "**/*.pem",
        "**/*.key",
        "**/*credentials*",
        "**/*secret*"
      ],
      maxFileSizeBytes: 262144,
      skipBinary: true,
      respectGitIgnore: true,
      secretRedaction: {
        enabled: true,
        action: "exclude",
        denyGlobs: [
          ".env",
          ".env.*",
          "**/.env",
          "**/.env.*",
          "**/*.pem",
          "**/*.key",
          "**/*credentials*",
          "**/*secret*"
        ]
      },
      embedding: {
        mode: "disabled",
        provider: null,
        model: null
      },
      summary: {
        mode: "disabled",
        provider: null,
        model: null
      },
      externalSources: []
    },
  };
  fs.writeFileSync(configPath, `${JSON.stringify(defaultConfig, null, 2)}\n`);
  console.log("updated: .agentrail/config.json");
}
NODE

  local source_support_dir="${target_dir}/.agentrail/source"
  if [[ "$(cd "$repo_dir" && pwd -P)" != "$(mkdir -p "$source_support_dir" && cd "$source_support_dir" && pwd -P)" ]]; then
    mkdir -p "${source_support_dir}/scripts"
    cp "${repo_dir}/package.json" "${source_support_dir}/package.json"
    cp "${repo_dir}/scripts/agentrail" "${source_support_dir}/scripts/agentrail"
    cp "${repo_dir}/scripts/agentrail-legacy" "${source_support_dir}/scripts/agentrail-legacy"
    cp "${repo_dir}/scripts/install-workflow" "${source_support_dir}/scripts/install-workflow"
    chmod +x "${source_support_dir}/scripts/agentrail" "${source_support_dir}/scripts/agentrail-legacy" "${source_support_dir}/scripts/install-workflow"
    rm -rf "${source_support_dir}/templates" "${source_support_dir}/skills" "${source_support_dir}/agentrail"
    cp -R "${repo_dir}/templates" "${source_support_dir}/templates"
    cp -R "${repo_dir}/skills" "${source_support_dir}/skills"
    cp -R "${repo_dir}/agentrail" "${source_support_dir}/agentrail"
    echo "updated: .agentrail/source"
  fi
}

print_path_status() {
  local target_dir="$1"
  local label="$2"
  local path="$3"
  local kind="$4"
  local full_path="${target_dir}/${path}"

  case "$kind" in
    file)
      [[ -f "$full_path" ]] || { echo "  missing ${label}"; return 1; }
      ;;
    dir)
      [[ -d "$full_path" ]] || { echo "  missing ${label}"; return 1; }
      ;;
    executable)
      [[ -x "$full_path" ]] || { echo "  missing ${label}"; return 1; }
      ;;
  esac

  echo "  ok ${label}"
}

remote_is_github() {
  local target_dir="$1"
  local remote
  remote="$(git -C "$target_dir" remote get-url origin 2>/dev/null || true)"
  [[ "$remote" == *github.com* ]]
}

check_github_labels() {
  local target_dir="$1"

  echo "github:"
  if ! command -v gh >/dev/null 2>&1 || ! remote_is_github "$target_dir"; then
    echo "  skipped no connected GitHub repo"
    return
  fi

  local labels_json
  if ! labels_json="$(cd "$target_dir" && gh label list --limit 200 --json name 2>/dev/null)"; then
    echo "  warn could not read GitHub labels"
    return
  fi

  LABELS_JSON="$labels_json" node <<'NODE'
const labels = new Set(JSON.parse(process.env.LABELS_JSON).map((label) => label.name));
const required = ["ready-for-agent", "afk", "afk-in-progress", "review-fix", "memory-suggestion", "pr-reviewed"];
const missing = required.filter((label) => !labels.has(label));
if (missing.length === 0) {
  console.log("  ok GitHub labels");
} else {
  console.log(`  warn missing GitHub labels: ${missing.join(", ")}`);
}
NODE
}

validate_skill_registry() {
  local target_dir="$1"

  AGENTRAIL_REPO_DIR="$repo_dir" \
  AGENTRAIL_TARGET_DIR="$target_dir" \
  node <<'NODE'
const fs = require("fs");
const path = require("path");

const repoDir = process.env.AGENTRAIL_REPO_DIR;
const targetDir = process.env.AGENTRAIL_TARGET_DIR;
const installedRegistry = path.join(targetDir, "docs/agents/skill-registry.json");
const sourceRegistry = path.join(repoDir, "templates/docs/agents/skill-registry.json");
const validateSourceRegistry = !fs.existsSync(installedRegistry) && path.resolve(targetDir) === path.resolve(repoDir);
const registryPath = validateSourceRegistry ? sourceRegistry : installedRegistry;
const skillRoot = validateSourceRegistry ? repoDir : targetDir;
const errors = [];

function fail(message) {
  errors.push(message);
}

function isNonEmptyString(value) {
  return typeof value === "string" && value.trim().length > 0;
}

function isStringArray(value) {
  return Array.isArray(value) && value.every(isNonEmptyString);
}

function isSafeRelativePath(value) {
  return isNonEmptyString(value) &&
    !path.isAbsolute(value) &&
    !value.split(/[\\/]+/).includes("..") &&
    value.endsWith("/SKILL.md");
}

const requiredSkillSections = [
  "## Activation Guidance",
  "## Context To Inspect",
  "## Constraints",
  "## Verification Requirements",
  "## Expected PR Evidence",
  "## Provenance / Audit",
];

let registry;
let parsedRegistry = false;
try {
  registry = JSON.parse(fs.readFileSync(registryPath, "utf8"));
  parsedRegistry = true;
} catch (error) {
  fail(`cannot read registry: ${error.message}`);
}

if (parsedRegistry && (registry === null || typeof registry !== "object" || Array.isArray(registry))) {
  fail("registry root must be an object");
} else if (parsedRegistry) {
  if (registry.schemaVersion !== 1) fail("schemaVersion must be 1");
  if (!Array.isArray(registry.skills)) {
    fail("skills must be an array");
  } else {
    const names = new Set();
    for (const [index, skill] of registry.skills.entries()) {
      const label = skill && isNonEmptyString(skill.name) ? skill.name : `entry ${index}`;
      if (!skill || typeof skill !== "object" || Array.isArray(skill)) {
        fail(`${label}: skill entry must be an object`);
        continue;
      }

      for (const field of ["name", "localPath", "description", "licenseStatus", "auditStatus"]) {
        if (!isNonEmptyString(skill[field])) fail(`${label}: missing required field ${field}`);
      }

      if (isNonEmptyString(skill.name)) {
        if (names.has(skill.name)) fail(`${skill.name}: duplicate skill name`);
        names.add(skill.name);
      }

      if (typeof skill.bundledByDefault !== "boolean") {
        fail(`${label}: bundledByDefault must be boolean`);
      }

      if (!isSafeRelativePath(skill.localPath)) {
        fail(`${label}: invalid localPath`);
      } else if (!fs.existsSync(path.join(skillRoot, skill.localPath))) {
        fail(`${label}: localPath does not exist: ${skill.localPath}`);
      } else {
        const skillBody = fs.readFileSync(path.join(skillRoot, skill.localPath), "utf8");
        for (const section of requiredSkillSections) {
          if (!skillBody.includes(section)) fail(`${label}: missing SKILL.md section ${section}`);
        }
      }

      if (!skill.triggers || typeof skill.triggers !== "object" || Array.isArray(skill.triggers)) {
        fail(`${label}: triggers must be an object`);
      } else {
        const triggerKeys = ["keywords", "fileGlobs", "projectSignals"];
        let triggerCount = 0;
        for (const key of triggerKeys) {
          if (!Object.prototype.hasOwnProperty.call(skill.triggers, key)) {
            fail(`${label}: triggers.${key} is required`);
          } else if (!isStringArray(skill.triggers[key])) {
            fail(`${label}: triggers.${key} must be an array of non-empty strings`);
          } else {
            triggerCount += skill.triggers[key].length;
          }
        }
        if (triggerCount === 0) fail(`${label}: triggers must include at least one trigger`);
      }

      if (!skill.provenance || typeof skill.provenance !== "object" || Array.isArray(skill.provenance)) {
        fail(`${label}: provenance must be an object`);
      } else if (!Array.isArray(skill.provenance.candidates) || skill.provenance.candidates.length === 0) {
        fail(`${label}: provenance.candidates must be a non-empty array`);
      } else {
        for (const [candidateIndex, candidate] of skill.provenance.candidates.entries()) {
          const candidateLabel = `${label}: provenance.candidates[${candidateIndex}]`;
          for (const field of ["sourceName", "url", "relationship", "verifiedStatus", "auditNotes"]) {
            if (!isNonEmptyString(candidate?.[field])) fail(`${candidateLabel} missing ${field}`);
          }
          if (candidate?.autoInstall === true) {
            fail(`${candidateLabel} must not be marked autoInstall`);
          }
        }
      }
    }
  }
}

if (errors.length > 0) {
  console.log("SKILL_REGISTRY=invalid");
  for (const error of errors) console.log(`SKILL_REGISTRY_ERROR=${error}`);
  process.exit(1);
}

console.log("SKILL_REGISTRY=ok");
console.log(`SKILL_REGISTRY_PATH=${path.relative(targetDir, registryPath) || registryPath}`);
NODE
}

run_skill_list() {
  local target_dir
  parse_target target_dir "$@"

  AGENTRAIL_REPO_DIR="$repo_dir" \
  AGENTRAIL_TARGET_DIR="$target_dir" \
  node <<'NODE'
const fs = require("fs");
const path = require("path");

const repoDir = process.env.AGENTRAIL_REPO_DIR;
const targetDir = process.env.AGENTRAIL_TARGET_DIR;
const installedRegistry = path.join(targetDir, "docs/agents/skill-registry.json");
const sourceRegistry = path.join(repoDir, "templates/docs/agents/skill-registry.json");
const registryPath = fs.existsSync(installedRegistry) ? installedRegistry : sourceRegistry;
const registry = JSON.parse(fs.readFileSync(registryPath, "utf8"));

console.log(`AgentRail skills list: ${targetDir}`);
for (const skill of registry.skills.filter((entry) => entry.bundledByDefault)) {
  console.log(`- ${skill.name}`);
  console.log(`  path: ${skill.localPath}`);
  console.log(`  description: ${skill.description}`);
}
NODE
}

parse_skill_resolution_options() {
  local target_var="$1"
  local auto_var="$2"
  local explicit_var="$3"
  shift 3

  printf -v "$target_var" '%s' "$(pwd)"
  printf -v "$auto_var" '%s' "1"
  printf -v "$explicit_var" '%s' ""
  while [[ $# -gt 0 ]]; do
    case "$1" in
      --target)
        local value="${2:-}"
        [[ -n "$value" && "$value" != --* ]] || { echo "--target requires a directory" >&2; exit 2; }
        printf -v "$target_var" '%s' "$value"
        shift 2
        ;;
      --skill)
        local value="${2:-}"
        [[ -n "$value" && "$value" != --* ]] || { echo "--skill requires a skill name" >&2; exit 2; }
        if [[ -n "${!explicit_var}" ]]; then
          printf -v "$explicit_var" '%s' "${!explicit_var}"$'\n'"$value"
        else
          printf -v "$explicit_var" '%s' "$value"
        fi
        shift 2
        ;;
      --no-auto-skills)
        printf -v "$auto_var" '%s' "0"
        shift
        ;;
      -h|--help)
        usage
        exit 0
        ;;
      *)
        echo "Unknown option: $1" >&2
        usage >&2
        exit 2
        ;;
    esac
  done
}

resolve_skills_json() {
  local target_dir="$1"
  local task_text="$2"
  local auto_skills="$3"
  local explicit_skills="$4"

  AGENTRAIL_REPO_DIR="$repo_dir" \
  AGENTRAIL_TARGET_DIR="$target_dir" \
  AGENTRAIL_TASK_TEXT="$task_text" \
  AGENTRAIL_AUTO_SKILLS="$auto_skills" \
  AGENTRAIL_EXPLICIT_SKILLS="$explicit_skills" \
  node <<'NODE'
const fs = require("fs");
const path = require("path");

const repoDir = process.env.AGENTRAIL_REPO_DIR;
const targetDir = process.env.AGENTRAIL_TARGET_DIR;
const taskText = process.env.AGENTRAIL_TASK_TEXT || "";
const autoSkills = process.env.AGENTRAIL_AUTO_SKILLS !== "0";
const explicitNames = (process.env.AGENTRAIL_EXPLICIT_SKILLS || "").split(/\n+/).map((name) => name.trim()).filter(Boolean);
const installedRegistry = path.join(targetDir, "docs/agents/skill-registry.json");
const sourceRegistry = path.join(repoDir, "templates/docs/agents/skill-registry.json");
const registryPath = fs.existsSync(installedRegistry) ? installedRegistry : sourceRegistry;
const registry = JSON.parse(fs.readFileSync(registryPath, "utf8"));
const allBundledSkills = registry.skills.filter((skill) => skill.bundledByDefault);
const skillsByName = new Map(allBundledSkills.map((skill) => [skill.name, skill]));
const lowerTask = taskText.toLowerCase();
const maxAutoSkills = 4;

function localSkillPath(skill) {
  return path.join(targetDir, skill.localPath);
}

function isSkillAvailable(skill) {
  return fs.existsSync(localSkillPath(skill));
}

const unavailable = [];
const skills = allBundledSkills.filter((skill) => {
  if (isSkillAvailable(skill)) return true;
  unavailable.push({ name: skill.name, localPath: skill.localPath });
  return false;
});

function addReason(bucket, reason) {
  if (!bucket.includes(reason)) bucket.push(reason);
}

const resolved = new Map();
function includeSkill(skill, reason) {
  if (!resolved.has(skill.name)) {
    resolved.set(skill.name, { name: skill.name, localPath: skill.localPath, description: skill.description, reasons: [] });
  }
  addReason(resolved.get(skill.name).reasons, reason);
}

for (const name of explicitNames) {
  const skill = skillsByName.get(name);
  if (!skill) {
    console.error(`Unknown skill: ${name}`);
    process.exit(1);
  }
  if (!isSkillAvailable(skill)) {
    console.error(`Unavailable skill: ${name} (${skill.localPath} not found under target)`);
    process.exit(1);
  }
  includeSkill(skill, "explicit --skill");
}

function walkFiles(root) {
  const ignored = new Set([".git", "node_modules", ".agentrail", "dist", "build", ".next", "target"]);
  const files = [];
  function walk(dir) {
    if (files.length >= 1000) return;
    let entries = [];
    try {
      entries = fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name));
    } catch {
      return;
    }
    for (const entry of entries) {
      if (files.length >= 1000) return;
      const fullPath = path.join(dir, entry.name);
      if (entry.isDirectory()) {
        if (!ignored.has(entry.name)) walk(fullPath);
      } else if (entry.isFile()) {
        files.push(path.relative(root, fullPath).split(path.sep).join("/"));
      }
    }
  }
  walk(root);
  return files;
}

function readPackageSignals(root) {
  const packagePath = path.join(root, "package.json");
  try {
    const packageJson = JSON.parse(fs.readFileSync(packagePath, "utf8"));
    const deps = { ...(packageJson.dependencies || {}), ...(packageJson.devDependencies || {}) };
    return Object.keys(deps).sort();
  } catch {
    return [];
  }
}

function hasSegment(file, segment) {
  return file === segment || file.startsWith(`${segment}/`) || file.includes(`/${segment}/`);
}

function matchFileSignal(skillName, file) {
  if (skillName === "frontend-web") {
    return /\.(tsx|jsx|css)$/.test(file) || hasSegment(file, "app") || hasSegment(file, "components");
  }
  if (skillName === "desktop-tauri") {
    return file.startsWith("src-tauri/") || file.includes("/src-tauri/") || file === "tauri.conf.json" || file.endsWith("/tauri.conf.json");
  }
  if (skillName === "backend-api") {
    return hasSegment(file, "api") || hasSegment(file, "server") || hasSegment(file, "routes") || hasSegment(file, "controllers") || hasSegment(file, "prisma");
  }
  if (skillName === "devops-deploy") {
    return file.startsWith(".github/workflows/") || file.endsWith("/.github/workflows/ci.yml") || file === "Dockerfile" || file.endsWith("/Dockerfile") || file === "docker-compose.yml" || file.endsWith("/docker-compose.yml") || file === "vercel.json" || file.endsWith("/vercel.json") || hasSegment(file, "infra");
  }
  if (skillName === "docs-current") {
    return hasSegment(file, "docs");
  }
  return false;
}

function packageReason(skillName, deps) {
  const hasAny = (names) => names.find((name) => deps.includes(name));
  if (skillName === "frontend-web") {
    const dep = hasAny(["react", "next", "vite", "tailwindcss"]);
    return dep ? `project dependency: ${dep} in package.json` : null;
  }
  if (skillName === "desktop-tauri") {
    const dep = deps.find((name) => name.startsWith("@tauri-apps/"));
    return dep ? `project dependency: ${dep} in package.json` : null;
  }
  if (skillName === "backend-api") {
    const dep = hasAny(["express", "fastify", "hono", "@nestjs/core", "prisma", "@prisma/client"]);
    return dep ? `project dependency: ${dep} in package.json` : null;
  }
  return null;
}

function keywordMatches(keyword) {
  const normalized = keyword.toLowerCase();
  if (!/^[a-z0-9]+(?: [a-z0-9]+)*$/.test(normalized)) {
    return lowerTask.includes(normalized);
  }
  return new RegExp(`\\b${normalized.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`).test(lowerTask);
}

function shouldUseKeyword(skillName, keyword) {
  if (skillName !== "docs-current") return true;
  return ["current", "latest", "docs", "documentation", "sdk", "license", "provenance", "tauri"].includes(keyword.toLowerCase());
}

if (autoSkills) {
  const files = walkFiles(targetDir);
  const deps = readPackageSignals(targetDir);
  const candidates = [];

  for (const skill of skills) {
    const reasons = [];
    for (const keyword of skill.triggers.keywords) {
      if (shouldUseKeyword(skill.name, keyword) && keywordMatches(keyword)) addReason(reasons, `task keyword: ${keyword.toLowerCase()}`);
    }

    const depReason = packageReason(skill.name, deps);
    if (depReason) addReason(reasons, depReason);

    const fileMatch = files.find((file) => matchFileSignal(skill.name, file));
    if (fileMatch) {
      if (skill.name !== "docs-current" || /\b(current|latest|docs|documentation|sdk|license|provenance)\b/.test(lowerTask)) {
        addReason(reasons, `file signal: ${fileMatch}`);
      }
    }

    if (reasons.length > 0) {
      candidates.push({ skill, reasons });
    }
  }

  for (const candidate of candidates.slice(0, maxAutoSkills)) {
    for (const reason of candidate.reasons.slice(0, 4)) includeSkill(candidate.skill, reason);
  }
}

console.log(JSON.stringify({
  registryPath,
  targetDir,
  autoSkills,
  maxAutoSkills,
  unavailable,
  resolved: Array.from(resolved.values()),
}, null, 2));
NODE
}

print_skill_resolution() {
  local target_dir="$1"
  local task_text="$2"
  local auto_skills="$3"
  local explicit_skills="$4"
  local mode="${5:-cli}"

  local resolution
  resolution="$(resolve_skills_json "$target_dir" "$task_text" "$auto_skills" "$explicit_skills")" || return 1

  AGENTRAIL_SKILL_RESOLUTION="$resolution" \
  AGENTRAIL_TASK_TEXT="$task_text" \
  AGENTRAIL_MODE="$mode" \
  node <<'NODE'
const resolution = JSON.parse(process.env.AGENTRAIL_SKILL_RESOLUTION);
const taskText = process.env.AGENTRAIL_TASK_TEXT || "";
const mode = process.env.AGENTRAIL_MODE || "cli";

if (mode === "prompt") {
  if (resolution.resolved.length === 0) {
    console.log("Resolved AgentRail skills:");
    if (!resolution.autoSkills) console.log("- Automatic skill resolution disabled.");
    console.log("- No skills resolved.");
  } else {
    console.log("Resolved AgentRail skills:");
    console.log("Read these SKILL.md files before editing. If a resolved skill does not apply after inspection, report that in the PR or run notes.");
    for (const skill of resolution.resolved) {
      console.log(`- ${skill.name}`);
      console.log(`  path: ${skill.localPath}`);
      for (const reason of skill.reasons) console.log(`  reason: ${reason}`);
    }
  }
  console.log("");
  process.exit(0);
}

console.log(`AgentRail skills resolve: ${resolution.targetDir}`);
console.log(`task: ${taskText}`);
if (!resolution.autoSkills) console.log("Automatic skill resolution disabled.");
if (resolution.resolved.length === 0) {
  console.log("No skills resolved.");
} else {
  for (const skill of resolution.resolved) {
    console.log(`- ${skill.name}`);
    console.log(`  path: ${skill.localPath}`);
    for (const reason of skill.reasons) console.log(`  reason: ${reason}`);
  }
}
NODE
}

run_skill_resolve() {
  local task_text="${1:-}"
  [[ -n "$task_text" && "$task_text" != --* ]] || { echo "skills resolve requires task text" >&2; exit 2; }
  shift

  local target_dir auto_skills explicit_skills
  parse_skill_resolution_options target_dir auto_skills explicit_skills "$@"
  print_skill_resolution "$target_dir" "$task_text" "$auto_skills" "$explicit_skills"
}

run_skills() {
  local kind="${1:-}"
  [[ -n "$kind" ]] || { usage; exit 1; }
  shift || true

  case "$kind" in
    list)
      run_skill_list "$@"
      ;;
    resolve)
      run_skill_resolve "$@"
      ;;
    validate)
      local target_dir
      parse_target target_dir "$@"
      local output
      if output="$(validate_skill_registry "$target_dir")"; then
        echo "AgentRail skills validate: ${target_dir}"
        while IFS= read -r line; do
          case "$line" in
            SKILL_REGISTRY=ok)
              echo "  ok skill registry"
              ;;
            SKILL_REGISTRY_PATH=*)
              echo "  path ${line#SKILL_REGISTRY_PATH=}"
              ;;
          esac
        done <<<"$output"
      else
        echo "AgentRail skills validate: ${target_dir}"
        while IFS= read -r line; do
          case "$line" in
            SKILL_REGISTRY_ERROR=*)
              echo "  error ${line#SKILL_REGISTRY_ERROR=}"
              ;;
          esac
        done <<<"$output"
        return 1
      fi
      ;;
    ""|-h|--help)
      usage
      exit 0
      ;;
    *)
      echo "Unknown skills command: $kind" >&2
      usage >&2
      exit 2
      ;;
  esac
}

sync_github_labels() {
  local target_dir
  parse_target target_dir "$@"

  command -v gh >/dev/null 2>&1 || { echo "labels sync: gh CLI is required" >&2; exit 1; }
  if ! (cd "$target_dir" && gh auth status >/dev/null 2>&1); then
    echo "labels sync: gh CLI is not authenticated or cannot access GitHub" >&2
    exit 1
  fi
  remote_is_github "$target_dir" || { echo "labels sync: ${target_dir} does not have a GitHub origin remote" >&2; exit 1; }

  (
    cd "$target_dir"
    gh label create ready-for-agent --color 0E8A16 --description "Fully specified, ready for an agent to implement." --force
    gh label create afk --color 5319E7 --description "Approved for unattended AFK agent execution." --force
    gh label create afk-in-progress --color BFDADC --description "Currently claimed by AFK workflow." --force
    gh label create review-fix --color D93F0B --description "Follow-up issue created from PR review." --force
    gh label create memory-suggestion --color FBCA04 --description "Suggested project memory update for human review." --force
    gh label create pr-reviewed --color 1D76DB --description "Implementation PR has been reviewed." --force
  )

  echo "labels sync: ok"
}

run_context_sources() {
  local target_dir
  parse_target target_dir "$@"

  AGENTRAIL_TARGET_DIR="$target_dir" \
  node <<'NODE'
const fs = require("fs");
const path = require("path");
const crypto = require("crypto");
const childProcess = require("child_process");

const targetDir = path.resolve(process.env.AGENTRAIL_TARGET_DIR);
const configPath = path.join(targetDir, ".agentrail/config.json");

const defaultContextConfig = {
  includeGlobs: ["**/*"],
  excludeGlobs: [
    ".git/**",
    "node_modules/**",
    "dist/**",
    "build/**",
    ".next/**",
    "target/**",
    "coverage/**",
    ".cache/**",
    ".turbo/**",
    ".agentrail/context/**",
    ".agentrail/source/**",
    ".env",
    ".env.*",
    "**/.env",
    "**/.env.*",
    "**/*.pem",
    "**/*.key",
    "**/*credentials*",
    "**/*secret*",
  ],
  maxFileSizeBytes: 262144,
  skipBinary: true,
  respectGitIgnore: true,
  secretRedaction: {
    enabled: true,
    action: "exclude",
    denyGlobs: [
      ".env",
      ".env.*",
      "**/.env",
      "**/.env.*",
      "**/*.pem",
      "**/*.key",
      "**/*credentials*",
      "**/*secret*",
    ],
  },
  embedding: {
    mode: "disabled",
    provider: null,
    model: null,
  },
  summary: {
    mode: "disabled",
    provider: null,
    model: null,
  },
  externalSources: [],
};

function readContextConfig() {
  try {
    const config = JSON.parse(fs.readFileSync(configPath, "utf8"));
    const context = config.context || {};
    const redaction = {
      ...defaultContextConfig.secretRedaction,
      ...(context.secretRedaction || {}),
    };
    return {
      ...defaultContextConfig,
      ...context,
      includeGlobs: Array.isArray(context.includeGlobs) ? context.includeGlobs : defaultContextConfig.includeGlobs,
      excludeGlobs: Array.isArray(context.excludeGlobs) ? context.excludeGlobs : defaultContextConfig.excludeGlobs,
      maxFileSizeBytes: Number.isFinite(context.maxFileSizeBytes) ? context.maxFileSizeBytes : defaultContextConfig.maxFileSizeBytes,
      skipBinary: context.skipBinary !== undefined ? Boolean(context.skipBinary) : defaultContextConfig.skipBinary,
      respectGitIgnore: context.respectGitIgnore !== undefined ? Boolean(context.respectGitIgnore) : defaultContextConfig.respectGitIgnore,
      secretRedaction: {
        ...redaction,
        denyGlobs: Array.isArray(redaction.denyGlobs) ? redaction.denyGlobs : defaultContextConfig.secretRedaction.denyGlobs,
      },
      embedding: {
        ...defaultContextConfig.embedding,
        ...(context.embedding || {}),
        mode: context.embedding?.mode || defaultContextConfig.embedding.mode,
      },
      summary: {
        ...defaultContextConfig.summary,
        ...(context.summary || {}),
        mode: context.summary?.mode || defaultContextConfig.summary.mode,
      },
      externalSources: Array.isArray(context.externalSources) ? context.externalSources : [],
    };
  } catch (error) {
    if (error.code === "ENOENT") {
      return defaultContextConfig;
    }
    throw new Error(`invalid .agentrail/config.json: ${error.message}`);
  }
}

function toPosix(filePath) {
  return filePath.split(path.sep).join("/");
}

function escapeRegex(value) {
  return value.replace(/[.+^${}()|[\]\\]/g, "\\$&");
}

function globToRegex(glob) {
  let regex = "";
  for (let i = 0; i < glob.length; i += 1) {
    const char = glob[i];
    const next = glob[i + 1];
    if (char === "*" && next === "*" && glob[i + 2] === "/") {
      regex += "(?:.*/)?";
      i += 2;
    } else if (char === "*" && next === "*") {
      regex += ".*";
      i += 1;
    } else if (char === "*") {
      regex += "[^/]*";
    } else if (char === "?") {
      regex += "[^/]";
    } else {
      regex += escapeRegex(char);
    }
  }
  return new RegExp(`^${regex}$`, "i");
}

const globRegexCache = new Map();
function matchesGlob(glob, relativePath, isDirectory = false) {
  if (glob === "**/*") return true;
  if (glob.endsWith("/**")) {
    const prefix = glob.slice(0, -3);
    return relativePath === prefix || relativePath.startsWith(`${prefix}/`);
  }
  if (glob.startsWith("**/")) {
    const suffix = glob.slice(3);
    const suffixRegex = globToRegex(suffix);
    const optionalPrefixRegex = new RegExp(`^(?:.*/)?${suffixRegex.source.slice(1, -1)}$`, "i");
    if (optionalPrefixRegex.test(relativePath)) return true;
  }
  const target = isDirectory ? `${relativePath}/` : relativePath;
  if (!globRegexCache.has(glob)) globRegexCache.set(glob, globToRegex(glob));
  return globRegexCache.get(glob).test(relativePath) || globRegexCache.get(glob).test(target);
}

function matchesAny(globs, relativePath, isDirectory = false) {
  return globs.some((glob) => matchesGlob(glob, relativePath, isDirectory));
}

function walkFiles(root, config) {
  const files = [];
  function walk(dir) {
    let entries = [];
    try {
      entries = fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name));
    } catch {
      return;
    }
    for (const entry of entries) {
      const fullPath = path.join(dir, entry.name);
      const relativePath = toPosix(path.relative(root, fullPath));
      if (entry.isDirectory()) {
        if (!matchesAny(config.excludeGlobs, relativePath, true)) walk(fullPath);
      } else if (entry.isFile()) {
        files.push({ fullPath, relativePath });
      }
    }
  }
  walk(root);
  return files;
}

function gitIgnoredSet(files, config) {
  if (!config.respectGitIgnore || files.length === 0) return new Set();
  try {
    childProcess.execFileSync("git", ["-C", targetDir, "rev-parse", "--is-inside-work-tree"], { stdio: "ignore" });
  } catch {
    return new Set();
  }
  const input = files.map((file) => file.relativePath).join("\n");
  const result = childProcess.spawnSync("git", ["-C", targetDir, "check-ignore", "--stdin"], {
    input,
    encoding: "utf8",
    maxBuffer: 1024 * 1024 * 10,
  });
  if (!result.stdout) return new Set();
  return new Set(result.stdout.split(/\r?\n/).filter(Boolean));
}

function isBinaryFile(fullPath) {
  const fd = fs.openSync(fullPath, "r");
  try {
    const buffer = Buffer.alloc(8192);
    const bytesRead = fs.readSync(fd, buffer, 0, buffer.length, 0);
    return buffer.subarray(0, bytesRead).includes(0);
  } finally {
    fs.closeSync(fd);
  }
}

function sha256Buffer(buffer) {
  return `sha256:${crypto.createHash("sha256").update(buffer).digest("hex")}`;
}

function sha256File(fullPath) {
  return sha256Buffer(fs.readFileSync(fullPath));
}

function sourceTypeFor(relativePath) {
  if (relativePath === "CONTEXT.md" || relativePath.endsWith("/CONTEXT.md")) return "context_doc";
  if (relativePath === "TASTE.md" || relativePath.endsWith("/TASTE.md")) return "taste_doc";
  if (relativePath.startsWith("docs/agents/")) return "agent_doc";
  if (relativePath.startsWith("templates/docs/agents/")) return "agent_doc";
  if (relativePath.startsWith("docs/memory/")) return "memory";
  if (relativePath.startsWith("templates/docs/memory/")) return "memory";
  if (relativePath.startsWith("docs/prd/")) return "prd";
  if (relativePath.startsWith("templates/docs/prd/")) return "prd";
  if (relativePath.startsWith("docs/milestones/")) return "milestone";
  if (relativePath.startsWith("templates/docs/milestones/")) return "milestone";
  if (relativePath === ".agentrail/state.json" || relativePath === ".agentrail/config.json") return "agentrail_state";
  if (relativePath.startsWith(".agentrail/runs/") || relativePath.startsWith(".agentrail/handoffs/")) return "run_artifact";
  if (relativePath.startsWith("skills/")) return "skill";
  return "code";
}

function authorityFor(sourceType, relativePath) {
  if (relativePath === "CONTEXT.md" || relativePath === ".agentrail/state.json") return "critical";
  if (["taste_doc", "agent_doc", "prd", "milestone"].includes(sourceType)) return "high";
  if (sourceType === "agentrail_state") return "high";
  if (sourceType === "run_artifact" || sourceType === "memory") return "normal";
  return "normal";
}

function linkedNumbers(text, regex) {
  const values = new Set();
  let match;
  while ((match = regex.exec(text)) !== null) {
    values.add(Number(match[1]));
  }
  return Array.from(values).sort((a, b) => a - b);
}

function linkedRefs(fullPath) {
  let text = "";
  try {
    text = fs.readFileSync(fullPath, "utf8");
  } catch {
    return { linkedIssues: [], linkedPullRequests: [] };
  }
  const linkedIssues = new Set([
    ...linkedNumbers(text, /(?:^|[^A-Za-z])#(\d+)/g),
    ...linkedNumbers(text, /\/issues\/(\d+)/g),
  ]);
  const linkedPullRequests = new Set(linkedNumbers(text, /\/pull\/(\d+)/g));
  return {
    linkedIssues: Array.from(linkedIssues).sort((a, b) => a - b),
    linkedPullRequests: Array.from(linkedPullRequests).sort((a, b) => a - b),
  };
}

function auditRefFor(relativePath) {
  const slug = relativePath.replace(/[^A-Za-z0-9]+/g, "-").replace(/^-|-$/g, "").toLowerCase();
  return `audit:source:${slug || "root"}`;
}

function redactText(value) {
  let redacted = String(value);
  const findings = [];
  function recordFinding(detectorName) {
    const existing = findings.find((finding) => finding.detector === detectorName);
    if (existing) existing.count += 1;
    else findings.push({ detector: detectorName, count: 1 });
  }
  const detectors = [
    { name: "private_key", regex: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g, token: "[REDACTED:private_key]" },
    { name: "api_key", regex: /\bsk-[A-Za-z0-9_-]{10,}\b/g, token: "[REDACTED:api_key]" },
    { name: "token", regex: /\b(?:ghp_[A-Za-z0-9_]{20,}|github_pat_[A-Za-z0-9_]+)\b/g, token: "[REDACTED:token]" },
    { name: "aws_access_key", regex: /\bAKIA[0-9A-Z]{16}\b/g, token: "[REDACTED:aws_access_key]" },
    { name: "database_url", regex: /\b(?:postgres|postgresql|mysql|redis|mongodb):\/\/[^\s"'`<>]+/gi, token: "[REDACTED:database_url]" },
    { name: "bearer_token", regex: /\bBearer\s+[A-Za-z0-9._~+/=-]{8,}\b/gi, token: "Bearer [REDACTED:bearer_token]" },
  ];
  for (const detector of detectors) {
    let count = 0;
    redacted = redacted.replace(detector.regex, () => {
      count += 1;
      return detector.token;
    });
    if (count > 0) findings.push({ detector: detector.name, count });
  }
  redacted = redacted.replace(/((?:"password"|'password')\s*:\s*)(["'])(.*?)\2/gi, (_match, prefix, quote) => {
    recordFinding("password");
    return `${prefix}${quote}[REDACTED:password]${quote}`;
  });
  redacted = redacted.replace(/(\bpassword\b\s*[:=]\s*)(["'])(.*?)\2/gi, (_match, prefix, quote) => {
    recordFinding("password");
    return `${prefix}${quote}[REDACTED:password]${quote}`;
  });
  redacted = redacted.replace(/(\bpassword\b\s*[:=]\s*)(["']?)[^\s"'`,;}]+/gi, (_match, prefix) => {
    recordFinding("password");
    return `${prefix}[REDACTED:password]`;
  });
  redacted = redacted.replace(/((?:"|')(?:authorization|proxy-authorization)(?:"|')\s*:\s*)(["'])(.*?)\2/gi, (_match, prefix, quote) => {
    recordFinding("authorization");
    return `${prefix}${quote}[REDACTED:authorization]${quote}`;
  });
  redacted = redacted.replace(/(\b(?:authorization|proxy[_-]authorization)\b\s*[:=]\s*)(["']?)[^\s"'`,;}]+(?:\s+[^\s"'`,;}]+)?/gi, (_match, prefix) => {
    recordFinding("authorization");
    return `${prefix}[REDACTED:authorization]`;
  });
  redacted = redacted.replace(/((?:"|')[A-Za-z0-9_]*(?:secret|token|api[_-]?key|access[_-]?key|database_url|db_url|private[_-]?key)[A-Za-z0-9_]*(?:"|')\s*:\s*)(["'])(.*?)\2/gi, (_match, prefix, quote) => {
    recordFinding("secret_assignment");
    return `${prefix}${quote}[REDACTED:secret_assignment]${quote}`;
  });
  redacted = redacted.replace(/((?:\b(?:const|let|var)\s+)?\b[A-Za-z0-9_]*(?:secret|token|api[_-]?key|access[_-]?key|database_url|db_url|private[_-]?key)[A-Za-z0-9_]*\b\s*[:=]\s*)(["'])(.*?)\2/gi, (_match, prefix, quote) => {
    recordFinding("secret_assignment");
    return `${prefix}${quote}[REDACTED:secret_assignment]${quote}`;
  });
  redacted = redacted.replace(/((?:\b(?:const|let|var)\s+)?\b[A-Za-z0-9_]*(?:secret|token|api[_-]?key|access[_-]?key|database_url|db_url|private[_-]?key)[A-Za-z0-9_]*\b\s*[:=]\s*)[^\s"'`,;}]+/gi, (_match, prefix) => {
    recordFinding("secret_assignment");
    return `${prefix}[REDACTED:secret_assignment]`;
  });
  return { text: redacted, findings };
}

function sourceRecord(file) {
  const stats = fs.statSync(file.fullPath);
  const modifiedAt = stats.mtime.toISOString();
  const sourceType = sourceTypeFor(file.relativePath);
  const refs = linkedRefs(file.fullPath);
  const redactedPath = redactText(file.relativePath);
  return {
    id: `source:${redactedPath.text}`,
    sourceType,
    path: redactedPath.text,
    contentHash: sha256File(file.fullPath),
    modifiedAt,
    freshness: {
      status: "current",
      observedAt: modifiedAt,
      expiresAt: null,
    },
    authority: authorityFor(sourceType, file.relativePath),
    visibility: redactedPath.findings.length > 0 ? "redacted" : "local",
    linkedIssues: refs.linkedIssues,
    linkedPullRequests: refs.linkedPullRequests,
    chunkIds: [],
    auditRef: auditRefFor(redactedPath.text),
    redactions: redactedPath.findings,
  };
}

function externalRecord(descriptor) {
  const uri = String(descriptor.uri || descriptor.path || descriptor.id || "");
  if (!uri) return null;
  const redactedUri = redactText(uri);
  const redactedId = redactText(String(descriptor.id || `external:${uri}`));
  const redactedAuditRef = descriptor.auditRef ? redactText(String(descriptor.auditRef)).text : auditRefFor(redactedUri.text);
  const redacted = redactText(JSON.stringify(descriptor));
  const body = Buffer.from(redacted.text);
  return {
    id: redactedId.text,
    sourceType: "external_descriptor",
    path: redactedUri.text,
    contentHash: sha256Buffer(body),
    modifiedAt: null,
    freshness: {
      status: "unknown",
      observedAt: null,
      expiresAt: null,
    },
    authority: descriptor.authority || "low",
    visibility: redacted.findings.length > 0 || redactedUri.findings.length > 0 || redactedId.findings.length > 0 ? "redacted" : (descriptor.visibility || "metadata-only"),
    linkedIssues: Array.isArray(descriptor.linkedIssues) ? descriptor.linkedIssues : [],
    linkedPullRequests: Array.isArray(descriptor.linkedPullRequests) ? descriptor.linkedPullRequests : [],
    chunkIds: [],
    auditRef: redactedAuditRef,
    redactions: [...redacted.findings, ...redactedUri.findings, ...redactedId.findings],
  };
}

const config = readContextConfig();
const walked = walkFiles(targetDir, config);
const ignored = gitIgnoredSet(walked, config);
const records = [];

for (const file of walked) {
  if (!matchesAny(config.includeGlobs, file.relativePath, false)) continue;
  if (matchesAny(config.excludeGlobs, file.relativePath, false)) continue;
  if (config.secretRedaction.enabled && config.secretRedaction.action === "exclude" && matchesAny(config.secretRedaction.denyGlobs, file.relativePath, false)) continue;
  if (ignored.has(file.relativePath)) continue;

  let stats;
  try {
    stats = fs.statSync(file.fullPath);
  } catch {
    continue;
  }
  if (stats.size > config.maxFileSizeBytes) continue;
  if (config.skipBinary && isBinaryFile(file.fullPath)) continue;

  records.push(sourceRecord(file));
}

for (const descriptor of config.externalSources) {
  const record = externalRecord(descriptor);
  if (record) records.push(record);
}

records.sort((a, b) => a.path.localeCompare(b.path) || a.id.localeCompare(b.id));
console.log(JSON.stringify(records, null, 2));
NODE
}

run_context_index() {
  local target_dir
  parse_target target_dir "$@"

  AGENTRAIL_TARGET_DIR="$target_dir" \
  node <<'NODE'
const fs = require("fs");
const path = require("path");
const crypto = require("crypto");
const childProcess = require("child_process");

const targetDir = path.resolve(process.env.AGENTRAIL_TARGET_DIR);
const configPath = path.join(targetDir, ".agentrail/config.json");
const contextDir = path.join(targetDir, ".agentrail/context");
const indexDir = path.join(contextDir, "index");
const auditDir = path.join(contextDir, "audit");
const auditPath = path.join(auditDir, "events.jsonl");
const embeddingPayloadPath = path.join(indexDir, "embedding-payloads.jsonl");

const defaultContextConfig = {
  includeGlobs: ["**/*"],
  excludeGlobs: [
    ".git/**",
    "node_modules/**",
    "dist/**",
    "build/**",
    ".next/**",
    "target/**",
    "coverage/**",
    ".cache/**",
    ".turbo/**",
    ".agentrail/context/**",
    ".agentrail/source/**",
    ".env",
    ".env.*",
    "**/.env",
    "**/.env.*",
    "**/*.pem",
    "**/*.key",
    "**/*credentials*",
    "**/*secret*",
  ],
  maxFileSizeBytes: 262144,
  skipBinary: true,
  respectGitIgnore: true,
  secretRedaction: {
    enabled: true,
    action: "exclude",
    denyGlobs: [
      ".env",
      ".env.*",
      "**/.env",
      "**/.env.*",
      "**/*.pem",
      "**/*.key",
      "**/*credentials*",
      "**/*secret*",
    ],
  },
  embedding: {
    mode: "disabled",
    provider: null,
    model: null,
  },
  summary: {
    mode: "disabled",
    provider: null,
    model: null,
  },
  externalSources: [],
};

function readContextConfig() {
  try {
    const config = JSON.parse(fs.readFileSync(configPath, "utf8"));
    const context = config.context || {};
    const redaction = {
      ...defaultContextConfig.secretRedaction,
      ...(context.secretRedaction || {}),
    };
    return {
      ...defaultContextConfig,
      ...context,
      includeGlobs: Array.isArray(context.includeGlobs) ? context.includeGlobs : defaultContextConfig.includeGlobs,
      excludeGlobs: Array.isArray(context.excludeGlobs) ? context.excludeGlobs : defaultContextConfig.excludeGlobs,
      maxFileSizeBytes: Number.isFinite(context.maxFileSizeBytes) ? context.maxFileSizeBytes : defaultContextConfig.maxFileSizeBytes,
      skipBinary: context.skipBinary !== undefined ? Boolean(context.skipBinary) : defaultContextConfig.skipBinary,
      respectGitIgnore: context.respectGitIgnore !== undefined ? Boolean(context.respectGitIgnore) : defaultContextConfig.respectGitIgnore,
      secretRedaction: {
        ...redaction,
        denyGlobs: Array.isArray(redaction.denyGlobs) ? redaction.denyGlobs : defaultContextConfig.secretRedaction.denyGlobs,
      },
      embedding: {
        ...defaultContextConfig.embedding,
        ...(context.embedding || {}),
        mode: context.embedding?.mode || defaultContextConfig.embedding.mode,
      },
      summary: {
        ...defaultContextConfig.summary,
        ...(context.summary || {}),
        mode: context.summary?.mode || defaultContextConfig.summary.mode,
      },
      externalSources: Array.isArray(context.externalSources) ? context.externalSources : [],
    };
  } catch (error) {
    if (error.code === "ENOENT") {
      return defaultContextConfig;
    }
    throw new Error(`invalid .agentrail/config.json: ${error.message}`);
  }
}

function toPosix(filePath) {
  return filePath.split(path.sep).join("/");
}

function escapeRegex(value) {
  return value.replace(/[.+^${}()|[\]\\]/g, "\\$&");
}

function globToRegex(glob) {
  let regex = "";
  for (let i = 0; i < glob.length; i += 1) {
    const char = glob[i];
    const next = glob[i + 1];
    if (char === "*" && next === "*" && glob[i + 2] === "/") {
      regex += "(?:.*/)?";
      i += 2;
    } else if (char === "*" && next === "*") {
      regex += ".*";
      i += 1;
    } else if (char === "*") {
      regex += "[^/]*";
    } else if (char === "?") {
      regex += "[^/]";
    } else {
      regex += escapeRegex(char);
    }
  }
  return new RegExp(`^${regex}$`, "i");
}

const globRegexCache = new Map();
function matchesGlob(glob, relativePath, isDirectory = false) {
  if (glob === "**/*") return true;
  if (glob.endsWith("/**")) {
    const prefix = glob.slice(0, -3);
    return relativePath === prefix || relativePath.startsWith(`${prefix}/`);
  }
  if (glob.startsWith("**/")) {
    const suffix = glob.slice(3);
    const suffixRegex = globToRegex(suffix);
    const optionalPrefixRegex = new RegExp(`^(?:.*/)?${suffixRegex.source.slice(1, -1)}$`, "i");
    if (optionalPrefixRegex.test(relativePath)) return true;
  }
  const target = isDirectory ? `${relativePath}/` : relativePath;
  if (!globRegexCache.has(glob)) globRegexCache.set(glob, globToRegex(glob));
  return globRegexCache.get(glob).test(relativePath) || globRegexCache.get(glob).test(target);
}

function matchesAny(globs, relativePath, isDirectory = false) {
  return globs.some((glob) => matchesGlob(glob, relativePath, isDirectory));
}

function walkFiles(root, config) {
  const files = [];
  function walk(dir) {
    let entries = [];
    try {
      entries = fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name));
    } catch {
      return;
    }
    for (const entry of entries) {
      const fullPath = path.join(dir, entry.name);
      const relativePath = toPosix(path.relative(root, fullPath));
      if (entry.isDirectory()) {
        if (matchesAny(config.excludeGlobs, relativePath, true)) {
          files.push({ fullPath, relativePath, directory: true, skipReason: "exclude_glob" });
        } else {
          walk(fullPath);
        }
      } else if (entry.isFile()) {
        files.push({ fullPath, relativePath, directory: false });
      }
    }
  }
  walk(root);
  return files;
}

function gitIgnoredSet(files, config) {
  const fileList = files.filter((file) => !file.directory);
  if (!config.respectGitIgnore || fileList.length === 0) return new Set();
  try {
    childProcess.execFileSync("git", ["-C", targetDir, "rev-parse", "--is-inside-work-tree"], { stdio: "ignore" });
  } catch {
    return new Set();
  }
  const input = fileList.map((file) => file.relativePath).join("\n");
  const result = childProcess.spawnSync("git", ["-C", targetDir, "check-ignore", "--stdin"], {
    input,
    encoding: "utf8",
    maxBuffer: 1024 * 1024 * 10,
  });
  if (!result.stdout) return new Set();
  return new Set(result.stdout.split(/\r?\n/).filter(Boolean));
}

function isBinaryFile(fullPath) {
  const fd = fs.openSync(fullPath, "r");
  try {
    const buffer = Buffer.alloc(8192);
    const bytesRead = fs.readSync(fd, buffer, 0, buffer.length, 0);
    return buffer.subarray(0, bytesRead).includes(0);
  } finally {
    fs.closeSync(fd);
  }
}

function sha256Buffer(buffer) {
  return `sha256:${crypto.createHash("sha256").update(buffer).digest("hex")}`;
}

function sha256Text(text) {
  return sha256Buffer(Buffer.from(text));
}

function sourceTypeFor(relativePath) {
  if (relativePath === "CONTEXT.md" || relativePath.endsWith("/CONTEXT.md")) return "context_doc";
  if (relativePath === "TASTE.md" || relativePath.endsWith("/TASTE.md")) return "taste_doc";
  if (relativePath.startsWith("docs/agents/")) return "agent_doc";
  if (relativePath.startsWith("templates/docs/agents/")) return "agent_doc";
  if (relativePath.startsWith("docs/memory/")) return "memory";
  if (relativePath.startsWith("templates/docs/memory/")) return "memory";
  if (relativePath.startsWith("docs/prd/")) return "prd";
  if (relativePath.startsWith("templates/docs/prd/")) return "prd";
  if (relativePath.startsWith("docs/milestones/")) return "milestone";
  if (relativePath.startsWith("templates/docs/milestones/")) return "milestone";
  if (relativePath === ".agentrail/state.json" || relativePath === ".agentrail/config.json") return "agentrail_state";
  if (relativePath.startsWith(".agentrail/runs/") || relativePath.startsWith(".agentrail/handoffs/")) return "run_artifact";
  if (relativePath.startsWith("skills/")) return "skill";
  return "code";
}

function authorityFor(sourceType, relativePath) {
  if (relativePath === "CONTEXT.md" || relativePath === ".agentrail/state.json") return "critical";
  if (["taste_doc", "agent_doc", "prd", "milestone"].includes(sourceType)) return "high";
  if (sourceType === "agentrail_state") return "high";
  if (sourceType === "run_artifact" || sourceType === "memory") return "normal";
  return "normal";
}

function linkedNumbers(text, regex) {
  const values = new Set();
  let match;
  while ((match = regex.exec(text)) !== null) {
    values.add(Number(match[1]));
  }
  return Array.from(values).sort((a, b) => a - b);
}

function linkedRefsFromText(text) {
  const linkedIssues = new Set([
    ...linkedNumbers(text, /(?:^|[^A-Za-z])#(\d+)/g),
    ...linkedNumbers(text, /\/issues\/(\d+)/g),
  ]);
  const linkedPullRequests = new Set(linkedNumbers(text, /\/pull\/(\d+)/g));
  return {
    linkedIssues: Array.from(linkedIssues).sort((a, b) => a - b),
    linkedPullRequests: Array.from(linkedPullRequests).sort((a, b) => a - b),
  };
}

function auditRefFor(relativePath) {
  const slug = relativePath.replace(/[^A-Za-z0-9]+/g, "-").replace(/^-|-$/g, "").toLowerCase();
  return `audit:source:${slug || "root"}`;
}

function redactText(text) {
  const findings = [];
  let redacted = text;
  function recordFinding(detectorName) {
    const existing = findings.find((finding) => finding.detector === detectorName);
    if (existing) existing.count += 1;
    else findings.push({ detector: detectorName, count: 1 });
  }
  const detectors = [
    { name: "private_key", regex: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g, token: "[REDACTED:private_key]" },
    { name: "api_key", regex: /\bsk-[A-Za-z0-9_-]{10,}\b/g, token: "[REDACTED:api_key]" },
    { name: "token", regex: /\b(?:ghp_[A-Za-z0-9_]{20,}|github_pat_[A-Za-z0-9_]+)\b/g, token: "[REDACTED:token]" },
    { name: "aws_access_key", regex: /\bAKIA[0-9A-Z]{16}\b/g, token: "[REDACTED:aws_access_key]" },
    { name: "database_url", regex: /\b(?:postgres|postgresql|mysql|redis|mongodb):\/\/[^\s"'`<>]+/gi, token: "[REDACTED:database_url]" },
    { name: "bearer_token", regex: /\bBearer\s+[A-Za-z0-9._~+/=-]{8,}\b/gi, token: "Bearer [REDACTED:bearer_token]" },
  ];
  for (const detector of detectors) {
    let count = 0;
    redacted = redacted.replace(detector.regex, () => {
      count += 1;
      return detector.token;
    });
    if (count > 0) findings.push({ detector: detector.name, count });
  }
  redacted = redacted.replace(/((?:"password"|'password')\s*:\s*)(["'])(.*?)\2/gi, (_match, prefix, quote) => {
    recordFinding("password");
    return `${prefix}${quote}[REDACTED:password]${quote}`;
  });
  redacted = redacted.replace(/(\bpassword\b\s*[:=]\s*)(["'])(.*?)\2/gi, (_match, prefix, quote) => {
    recordFinding("password");
    return `${prefix}${quote}[REDACTED:password]${quote}`;
  });
  redacted = redacted.replace(/(\bpassword\b\s*[:=]\s*)(["']?)[^\s"'`,;}]+/gi, (_match, prefix) => {
    recordFinding("password");
    return `${prefix}[REDACTED:password]`;
  });
  redacted = redacted.replace(/((?:"|')(?:authorization|proxy-authorization)(?:"|')\s*:\s*)(["'])(.*?)\2/gi, (_match, prefix, quote) => {
    recordFinding("authorization");
    return `${prefix}${quote}[REDACTED:authorization]${quote}`;
  });
  redacted = redacted.replace(/(\b(?:authorization|proxy[_-]authorization)\b\s*[:=]\s*)(["']?)[^\s"'`,;}]+(?:\s+[^\s"'`,;}]+)?/gi, (_match, prefix) => {
    recordFinding("authorization");
    return `${prefix}[REDACTED:authorization]`;
  });
  redacted = redacted.replace(/((?:"|')[A-Za-z0-9_]*(?:secret|token|api[_-]?key|access[_-]?key|database_url|db_url|private[_-]?key)[A-Za-z0-9_]*(?:"|')\s*:\s*)(["'])(.*?)\2/gi, (_match, prefix, quote) => {
    recordFinding("secret_assignment");
    return `${prefix}${quote}[REDACTED:secret_assignment]${quote}`;
  });
  redacted = redacted.replace(/((?:\b(?:const|let|var)\s+)?\b[A-Za-z0-9_]*(?:secret|token|api[_-]?key|access[_-]?key|database_url|db_url|private[_-]?key)[A-Za-z0-9_]*\b\s*[:=]\s*)(["'])(.*?)\2/gi, (_match, prefix, quote) => {
    recordFinding("secret_assignment");
    return `${prefix}${quote}[REDACTED:secret_assignment]${quote}`;
  });
  redacted = redacted.replace(/((?:\b(?:const|let|var)\s+)?\b[A-Za-z0-9_]*(?:secret|token|api[_-]?key|access[_-]?key|database_url|db_url|private[_-]?key)[A-Za-z0-9_]*\b\s*[:=]\s*)[^\s"'`,;}]+/gi, (_match, prefix) => {
    recordFinding("secret_assignment");
    return `${prefix}[REDACTED:secret_assignment]`;
  });
  return { text: redacted, findings };
}

function appendAudit(event) {
  fs.mkdirSync(auditDir, { recursive: true });
  fs.appendFileSync(auditPath, `${JSON.stringify({ timestamp: new Date().toISOString(), ...event })}\n`);
}

function skipEvent(pathValue, reason) {
  const secretPath = config.secretRedaction.enabled && matchesAny(config.secretRedaction.denyGlobs, pathValue, false);
  const redactedPath = secretPath ? { text: "[REDACTED:secret_path]", findings: [{ detector: "secret_path", count: 1 }] } : redactText(pathValue);
  const event = {
    event: "skipped_file",
    path: redactedPath.text,
    reason,
  };
  if (redactedPath.findings.length > 0) {
    event.redactions = redactedPath.findings;
  }
  skippedRecords.push({ path: redactedPath.text, reason });
  appendAudit({
    ...event,
  });
}

function slugify(value) {
  const slug = String(value)
    .toLowerCase()
    .replace(/[^a-z0-9\s-]/g, "")
    .trim()
    .replace(/\s+/g, "-")
    .replace(/-+/g, "-");
  return slug || "section";
}

function languageFor(relativePath) {
  const extension = path.extname(relativePath).toLowerCase();
  const name = path.basename(relativePath).toLowerCase();
  const byExtension = {
    ".js": "javascript",
    ".mjs": "javascript",
    ".cjs": "javascript",
    ".jsx": "javascript",
    ".ts": "typescript",
    ".tsx": "typescript",
    ".py": "python",
    ".rb": "ruby",
    ".go": "go",
    ".rs": "rust",
    ".java": "java",
    ".kt": "kotlin",
    ".php": "php",
    ".cs": "csharp",
    ".c": "c",
    ".h": "c",
    ".cpp": "cpp",
    ".cc": "cpp",
    ".hpp": "cpp",
    ".sh": "shell",
    ".bash": "shell",
    ".zsh": "shell",
    ".json": "json",
    ".yaml": "yaml",
    ".yml": "yaml",
    ".toml": "toml",
    ".md": "markdown",
  };
  if (byExtension[extension]) return byExtension[extension];
  if (name === "dockerfile") return "dockerfile";
  if (!extension && relativePath.startsWith("scripts/")) return "shell";
  if (!extension && relativePath.startsWith("templates/scripts/")) return "shell";
  return extension ? extension.slice(1) : "text";
}

function cheapImportHints(text) {
  const hints = new Set();
  const patterns = [
    /^\s*import\s+.+?\s+from\s+["'][^"']+["']/gm,
    /^\s*import\s+["'][^"']+["']/gm,
    /require\(\s*["'][^"']+["']\s*\)/g,
    /^\s*from\s+\S+\s+import\s+.+$/gm,
    /^\s*#include\s+[<"].+[>"]/gm,
  ];
  for (const pattern of patterns) {
    let match;
    while ((match = pattern.exec(text)) !== null) {
      hints.add(match[0].trim());
      if (hints.size >= 12) return Array.from(hints);
    }
  }
  return Array.from(hints);
}

function cheapSymbolHints(text) {
  const hints = new Set();
  const patterns = [
    /\b(?:function|class|interface|type|enum)\s+([A-Za-z_$][\w$]*)/g,
    /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s*)?(?:\([^)]*\)|[A-Za-z_$][\w$]*)\s*=>/g,
    /^\s*(?:export\s+)?(?:async\s+)?function\s+([A-Za-z_$][\w$]*)/gm,
    /^\s*def\s+([A-Za-z_][\w]*)\s*\(/gm,
    /^\s*class\s+([A-Za-z_][\w]*)\b/gm,
  ];
  for (const pattern of patterns) {
    let match;
    while ((match = pattern.exec(text)) !== null) {
      hints.add(match[1]);
      if (hints.size >= 20) return Array.from(hints);
    }
  }
  return Array.from(hints);
}

function parseMemoryMetadata(text) {
  const fields = ["kind", "source", "confidence", "created_at", "expires_at"];
  const metadata = {};
  const frontmatter = text.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
  if (frontmatter) {
    for (const line of frontmatter[1].split(/\r?\n/)) {
      const match = line.match(/^([A-Za-z_][A-Za-z0-9_-]*)\s*:\s*(.*?)\s*$/);
      if (match && fields.includes(match[1])) {
        metadata[match[1]] = match[2].replace(/^["']|["']$/g, "");
      }
    }
  }
  try {
    const parsed = JSON.parse(text);
    if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
      for (const field of fields) {
        if (parsed[field] !== undefined && parsed[field] !== null) metadata[field] = String(parsed[field]);
      }
    }
  } catch {
    // Plain Markdown memory is allowed; metadata is best-effort.
  }
  return metadata;
}

function chunkRecord({ idSuffix, source, text, language, citation, startLine, endLine, headingPath = [], parentContext = "", symbolHints = [], importHints = [], memory = null }) {
  const normalizedText = String(text || "").trim();
  return {
    id: `chunk:${source.path}#${idSuffix}`,
    sourceId: source.id,
    sourceType: source.sourceType,
    path: source.path,
    language,
    headingPath,
    parentContext,
    startLine,
    endLine,
    symbolHints,
    importHints,
    textHash: sha256Text(normalizedText),
    summary: null,
    citation,
    content: normalizedText,
    memory,
  };
}

function markdownChunks(source, text, memoryMetadata) {
  const lines = text.split(/\r?\n/);
  const chunks = [];
  const stack = [];
  const slugCounts = new Map();
  let current = null;

  function closeCurrent(endLine) {
    if (!current) return;
    const chunkText = lines.slice(current.startLine - 1, endLine).join("\n");
    chunks.push(chunkRecord({
      idSuffix: current.slug,
      source,
      text: chunkText,
      language: "markdown",
      citation: `${source.path}#${current.slug}`,
      startLine: current.startLine,
      endLine,
      headingPath: [...current.headingPath],
      parentContext: current.headingPath.slice(0, -1).join(" > "),
      memory: memoryMetadata,
    }));
  }

  function addPreambleChunk(endLine) {
    if (chunks.length > 0 || current || endLine <= 0) return;
    const chunkText = lines.slice(0, endLine).join("\n");
    if (!chunkText.trim()) return;
    chunks.push(chunkRecord({
      idSuffix: "preamble",
      source,
      text: chunkText,
      language: "markdown",
      citation: `${source.path}#preamble`,
      startLine: 1,
      endLine,
      headingPath: [],
      parentContext: source.path,
      memory: memoryMetadata,
    }));
  }

  for (let index = 0; index < lines.length; index += 1) {
    const line = lines[index];
    const match = line.match(/^(#{1,6})\s+(.+?)\s*#*\s*$/);
    if (!match) continue;
    const level = match[1].length;
    const title = match[2].trim();
    closeCurrent(index);
    addPreambleChunk(index);
    while (stack.length >= level) stack.pop();
    stack[level - 1] = title;
    const headingPath = stack.filter(Boolean);
    const baseSlug = slugify(title);
    const count = (slugCounts.get(baseSlug) || 0) + 1;
    slugCounts.set(baseSlug, count);
    const slug = count === 1 ? baseSlug : `${baseSlug}-${count}`;
    current = {
      startLine: index + 1,
      slug,
      headingPath,
    };
  }

  if (current) closeCurrent(lines.length);
  if (chunks.length === 0 && text.trim()) {
    chunks.push(chunkRecord({
      idSuffix: "document",
      source,
      text,
      language: "markdown",
      citation: `${source.path}#document`,
      startLine: 1,
      endLine: lines.length,
      headingPath: [],
      parentContext: source.path,
      memory: memoryMetadata,
    }));
  }
  return chunks;
}

function codeChunks(source, text, relativePath) {
  const lines = text.split(/\r?\n/);
  const language = languageFor(relativePath);
  const imports = cheapImportHints(text);
  const symbols = cheapSymbolHints(text);
  const chunks = [];
  const sectionSize = 80;
  for (let start = 1; start <= lines.length; start += sectionSize) {
    const end = Math.min(lines.length, start + sectionSize - 1);
    const chunkText = lines.slice(start - 1, end).join("\n");
    if (!chunkText.trim()) continue;
    chunks.push(chunkRecord({
      idSuffix: `L${start}-L${end}`,
      source,
      text: chunkText,
      language,
      citation: `${source.path}#L${start}-L${end}`,
      startLine: start,
      endLine: end,
      headingPath: [],
      parentContext: source.path,
      symbolHints: symbols,
      importHints: imports,
      memory: null,
    }));
  }
  return chunks;
}

function chunksForSource(source, file, text) {
  const memoryMetadata = source.sourceType === "memory" ? parseMemoryMetadata(text) : null;
  if (memoryMetadata && Object.keys(memoryMetadata).length > 0) {
    source.memory = memoryMetadata;
  }
  if (languageFor(file.relativePath) === "markdown") {
    return markdownChunks(source, text, memoryMetadata);
  }
  return codeChunks(source, text, file.relativePath);
}

function sourceRecord(file, redactedText, redactions) {
  const stats = fs.statSync(file.fullPath);
  const modifiedAt = stats.mtime.toISOString();
  const sourceType = sourceTypeFor(file.relativePath);
  const refs = linkedRefsFromText(redactedText);
  const contentHash = sha256Text(redactedText);
  const redactedPath = redactText(file.relativePath);
  const allRedactions = [...redactions, ...redactedPath.findings];
  return {
    id: `source:${redactedPath.text}`,
    sourceType,
    path: redactedPath.text,
    contentHash,
    modifiedAt,
    freshness: {
      status: "current",
      observedAt: modifiedAt,
      expiresAt: null,
    },
    authority: authorityFor(sourceType, file.relativePath),
    visibility: allRedactions.length > 0 ? "redacted" : "local",
    linkedIssues: refs.linkedIssues,
    linkedPullRequests: refs.linkedPullRequests,
    chunkIds: [],
    auditRef: auditRefFor(redactedPath.text),
    redactions: allRedactions,
    content: redactedText,
  };
}

function externalRecord(descriptor) {
  const uri = String(descriptor.uri || descriptor.path || descriptor.id || "");
  if (!uri) return null;
  const redactedUri = redactText(uri).text;
  const redactedId = redactText(String(descriptor.id || `external:${uri}`)).text;
  const redactedAuditRef = descriptor.auditRef ? redactText(String(descriptor.auditRef)).text : auditRefFor(redactedUri);
  const redacted = redactText(JSON.stringify(descriptor));
  const body = Buffer.from(redacted.text);
  return {
    id: redactedId,
    sourceType: "external_descriptor",
    path: redactedUri,
    contentHash: sha256Buffer(body),
    modifiedAt: null,
    freshness: {
      status: "unknown",
      observedAt: null,
      expiresAt: null,
    },
    authority: descriptor.authority || "low",
    visibility: redacted.findings.length > 0 ? "redacted" : (descriptor.visibility || "metadata-only"),
    linkedIssues: Array.isArray(descriptor.linkedIssues) ? descriptor.linkedIssues : [],
    linkedPullRequests: Array.isArray(descriptor.linkedPullRequests) ? descriptor.linkedPullRequests : [],
    chunkIds: [],
    auditRef: redactedAuditRef,
    redactions: redacted.findings,
    content: redacted.text,
  };
}

const config = readContextConfig();
const providerMode = String(config.embedding?.mode || "disabled");
const summaryMode = String(config.summary?.mode || "disabled");
if (summaryMode !== "disabled" && !config.summary?.provider) {
  throw new Error(`context summary mode '${summaryMode}' requires context.summary.provider`);
}
if (summaryMode !== "disabled") {
  throw new Error(`context summary mode '${summaryMode}' is not implemented; use 'disabled' for local-only indexing`);
}
const walked = walkFiles(targetDir, config);
const ignored = gitIgnoredSet(walked, config);
const records = [];
const chunks = [];
let skipped = 0;
let redactionCount = 0;
const skippedRecords = [];

fs.mkdirSync(indexDir, { recursive: true });
fs.mkdirSync(auditDir, { recursive: true });
fs.writeFileSync(embeddingPayloadPath, "");

for (const file of walked) {
  if (file.directory) {
    skipped += 1;
    skipEvent(file.relativePath, file.skipReason || "directory_skipped");
    continue;
  }
  if (!matchesAny(config.includeGlobs, file.relativePath, false)) {
    skipped += 1;
    skipEvent(file.relativePath, "not_allowed");
    continue;
  }
  if (matchesAny(config.excludeGlobs, file.relativePath, false)) {
    skipped += 1;
    skipEvent(file.relativePath, "denied_path");
    continue;
  }
  if (config.secretRedaction.enabled && config.secretRedaction.action === "exclude" && matchesAny(config.secretRedaction.denyGlobs, file.relativePath, false)) {
    skipped += 1;
    skipEvent(file.relativePath, "secret_path");
    continue;
  }
  if (ignored.has(file.relativePath)) {
    skipped += 1;
    skipEvent(file.relativePath, "gitignored");
    continue;
  }

  let stats;
  try {
    stats = fs.statSync(file.fullPath);
  } catch {
    skipped += 1;
    skipEvent(file.relativePath, "unreadable");
    continue;
  }
  if (stats.size > config.maxFileSizeBytes) {
    skipped += 1;
    skipEvent(file.relativePath, "oversized");
    continue;
  }
  if (config.skipBinary && isBinaryFile(file.fullPath)) {
    skipped += 1;
    skipEvent(file.relativePath, "binary");
    continue;
  }

  let rawText = "";
  try {
    rawText = fs.readFileSync(file.fullPath, "utf8");
  } catch {
    skipped += 1;
    skipEvent(file.relativePath, "unreadable");
    continue;
  }
  const redacted = config.secretRedaction.enabled ? redactText(rawText) : { text: rawText, findings: [] };
  const record = sourceRecord(file, redacted.text, redacted.findings);
  const sourceChunks = chunksForSource(record, file, redacted.text);
  record.chunkIds = sourceChunks.map((chunk) => chunk.id);
  records.push(record);
  chunks.push(...sourceChunks);
  appendAudit({
    event: "indexed_file",
    path: record.path,
    contentHash: record.contentHash,
    chunkCount: sourceChunks.length,
    redactionCount: record.redactions.reduce((sum, finding) => sum + finding.count, 0),
  });
  for (const finding of record.redactions) {
    redactionCount += finding.count;
    appendAudit({
      event: "redaction",
      path: record.path,
      detector: finding.detector,
      action: "replace",
      count: finding.count,
      contentHash: record.contentHash,
    });
  }
}

for (const descriptor of config.externalSources) {
  const record = externalRecord(descriptor);
  if (!record) continue;
  const externalChunk = chunkRecord({
    idSuffix: "descriptor",
    source: record,
    text: record.content,
    language: "external_descriptor",
    citation: record.path,
    startLine: null,
    endLine: null,
    headingPath: [],
    parentContext: record.path,
  });
  record.chunkIds = [externalChunk.id];
  records.push(record);
  chunks.push(externalChunk);
  appendAudit({
    event: "indexed_external_descriptor",
    path: record.path,
    contentHash: record.contentHash,
    chunkCount: 1,
  });
  for (const finding of record.redactions) {
    redactionCount += finding.count;
    appendAudit({
      event: "redaction",
      path: record.path,
      detector: finding.detector,
      action: "replace",
      count: finding.count,
      contentHash: record.contentHash,
    });
  }
}

records.sort((a, b) => a.path.localeCompare(b.path) || a.id.localeCompare(b.id));
chunks.sort((a, b) => a.path.localeCompare(b.path) || a.id.localeCompare(b.id));
const builtAt = new Date().toISOString();
const index = {
  schemaVersion: 1,
  version: "context-index-v1",
  builtAt,
  provider: {
    mode: providerMode,
    summary: {
      mode: summaryMode,
      provider: config.summary?.provider || null,
      model: config.summary?.model || null,
    },
    externalCalls: [],
  },
  records,
  chunks,
  skipped: skippedRecords,
};

fs.writeFileSync(path.join(indexDir, "index.json"), `${JSON.stringify(index, null, 2)}\n`);
fs.writeFileSync(path.join(indexDir, "sources.json"), `${JSON.stringify(records.map(({ content, ...record }) => record), null, 2)}\n`);

appendAudit({
  event: "external_provider_call",
  mode: providerMode,
  provider: config.embedding?.provider || null,
  model: config.embedding?.model || null,
  action: providerMode === "disabled" ? "skipped_local_only" : "deferred_to_context_embed",
  payloadCount: providerMode === "disabled" ? 0 : chunks.length,
});

appendAudit({
  event: "contextual_summary",
  mode: summaryMode,
  provider: config.summary?.provider || null,
  model: config.summary?.model || null,
  action: summaryMode === "disabled" ? "skipped_local_only" : "not_implemented",
  payloadCount: summaryMode === "disabled" ? 0 : chunks.length,
});

for (const chunk of chunks) {
  fs.appendFileSync(embeddingPayloadPath, `${JSON.stringify({
    mode: providerMode,
    path: chunk.path,
    chunkId: chunk.id,
    citation: chunk.citation,
    textHash: chunk.textHash,
    sent: false,
    reason: providerMode === "disabled" ? "local_only" : "deferred_to_context_embed",
  })}\n`);
}

console.log(JSON.stringify({
  indexPath: ".agentrail/context/index/index.json",
  auditPath: ".agentrail/context/audit/events.jsonl",
  embeddingPayloadPath: ".agentrail/context/index/embedding-payloads.jsonl",
  providerMode,
  summaryMode,
  indexed: records.length,
  chunks: chunks.length,
  skipped,
  redactions: redactionCount,
}, null, 2));
NODE
}

run_context_embed() {
  local target_dir
  parse_target target_dir "$@"

  run_context_index --target "$target_dir" >/dev/null

  AGENTRAIL_TARGET_DIR="$target_dir" \
  node <<'NODE'
const fs = require("fs");
const path = require("path");
const childProcess = require("child_process");
const crypto = require("crypto");
const http = require("http");
const https = require("https");

const targetDir = path.resolve(process.env.AGENTRAIL_TARGET_DIR);
const configPath = path.join(targetDir, ".agentrail/config.json");
const contextDir = path.join(targetDir, ".agentrail/context");
const indexDir = path.join(contextDir, "index");
const indexPath = path.join(indexDir, "index.json");
const embeddingsPath = path.join(indexDir, "embeddings.json");
const auditPath = path.join(contextDir, "audit/events.jsonl");

function appendAudit(event) {
  fs.mkdirSync(path.dirname(auditPath), { recursive: true });
  fs.appendFileSync(auditPath, `${JSON.stringify({ timestamp: new Date().toISOString(), ...event })}\n`);
}

function readConfig() {
  try {
    const config = JSON.parse(fs.readFileSync(configPath, "utf8"));
    return config.context?.embedding || {};
  } catch (error) {
    if (error.code === "ENOENT") return {};
    throw new Error(`invalid .agentrail/config.json: ${error.message}`);
  }
}

function readExistingEmbeddings() {
  try {
    const parsed = JSON.parse(fs.readFileSync(embeddingsPath, "utf8"));
    return Array.isArray(parsed.embeddings) ? parsed.embeddings : [];
  } catch (error) {
    if (error.code === "ENOENT") return [];
    throw new Error(`invalid existing embedding metadata: ${error.message}`);
  }
}

function atomicWriteJson(filePath, value) {
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
  const tempPath = `${filePath}.tmp-${process.pid}`;
  fs.writeFileSync(tempPath, `${JSON.stringify(value, null, 2)}\n`);
  fs.renameSync(tempPath, filePath);
}

function providerName(mode, config) {
  if (config.provider) return String(config.provider);
  if (mode === "custom-command") return "custom-command";
  if (mode === "openai-compatible") return "openai-compatible";
  return mode;
}

function configuredModel(mode, config) {
  if (config.model) return String(config.model);
  if (mode === "custom-command") return "custom-command";
  return null;
}

function embeddingConfigHash(mode, config) {
  const fingerprint = {
    mode,
    provider: config.provider || null,
    model: config.model || null,
    command: mode === "custom-command" ? String(config.command || config.customCommand || "") : null,
    baseUrl: mode === "openai-compatible" ? String(config.baseUrl || "https://api.openai.com/v1").replace(/\/+$/, "") : null,
    apiKeyEnv: mode === "openai-compatible" ? String(config.apiKeyEnv || "OPENAI_API_KEY") : null,
  };
  return `sha256:${crypto.createHash("sha256").update(JSON.stringify(fingerprint)).digest("hex")}`;
}

function sourceForChunk(sourcesById, chunk) {
  return sourcesById.get(chunk.sourceId) || {};
}

function isReusable(record, chunk, source, mode, configHash) {
  const providerMatches = Boolean(record?.provider);
  const modelMatches = Boolean(record?.model);
  return record &&
    record.mode === mode &&
    record.configHash === configHash &&
    record.chunkId === chunk.id &&
    record.textHash === chunk.textHash &&
    record.contentHash === source.contentHash &&
    providerMatches &&
    modelMatches &&
    Number.isFinite(record.dimension);
}

function normalizeEmbedding(value) {
  if (!Array.isArray(value) || value.length === 0) {
    throw new Error("provider returned an empty or missing embedding vector");
  }
  const vector = value.map((item) => Number(item));
  if (vector.some((item) => !Number.isFinite(item))) {
    throw new Error("provider returned a non-numeric embedding vector");
  }
  return vector;
}

function runCustomCommand(config, payload) {
  const command = config.command || config.customCommand;
  if (!command) {
    throw new Error("context.embedding.command is required for custom-command mode");
  }
  const result = childProcess.spawnSync(command, {
    input: `${JSON.stringify(payload)}\n`,
    encoding: "utf8",
    shell: true,
    maxBuffer: 1024 * 1024 * 20,
    cwd: targetDir,
    env: process.env,
  });
  if (result.error) throw result.error;
  if (result.status !== 0) {
    const stderr = String(result.stderr || "").trim();
    throw new Error(`custom embedding command failed with exit ${result.status}${stderr ? `: ${stderr}` : ""}`);
  }
  let parsed;
  try {
    parsed = JSON.parse(String(result.stdout || "").trim());
  } catch (error) {
    throw new Error(`custom embedding command returned invalid JSON: ${error.message}`);
  }
  return {
    provider: parsed.provider ? String(parsed.provider) : null,
    model: parsed.model ? String(parsed.model) : null,
    vector: normalizeEmbedding(parsed.embedding || parsed.vector),
  };
}

function requestJson(url, headers, body) {
  return new Promise((resolve, reject) => {
    const parsed = new URL(url);
    const transport = parsed.protocol === "http:" ? http : https;
    const request = transport.request({
      method: "POST",
      hostname: parsed.hostname,
      port: parsed.port || undefined,
      path: `${parsed.pathname}${parsed.search}`,
      headers: {
        "content-type": "application/json",
        "content-length": Buffer.byteLength(body),
        ...headers,
      },
    }, (response) => {
      const chunks = [];
      response.on("data", (chunk) => chunks.push(chunk));
      response.on("end", () => {
        const text = Buffer.concat(chunks).toString("utf8");
        let parsedBody = {};
        try {
          parsedBody = text ? JSON.parse(text) : {};
        } catch (error) {
          reject(new Error(`embedding provider returned invalid JSON: ${error.message}`));
          return;
        }
        if (response.statusCode < 200 || response.statusCode >= 300) {
          reject(new Error(`embedding provider returned HTTP ${response.statusCode}: ${text.slice(0, 500)}`));
          return;
        }
        resolve(parsedBody);
      });
    });
    request.on("error", reject);
    request.write(body);
    request.end();
  });
}

async function runOpenAICompatible(config, payload) {
  const model = config.model;
  if (!model) throw new Error("context.embedding.model is required for openai-compatible mode");
  const apiKeyEnv = config.apiKeyEnv || "OPENAI_API_KEY";
  const apiKey = process.env[apiKeyEnv];
  if (!apiKey) throw new Error(`${apiKeyEnv} is required for openai-compatible embedding mode`);
  const baseUrl = String(config.baseUrl || "https://api.openai.com/v1").replace(/\/+$/, "");
  const body = JSON.stringify({
    model,
    input: payload.content,
  });
  const response = await requestJson(`${baseUrl}/embeddings`, {
    authorization: `Bearer ${apiKey}`,
  }, body);
  return {
    provider: config.provider ? String(config.provider) : "openai-compatible",
    model: response.model ? String(response.model) : String(model),
    vector: normalizeEmbedding(response.data?.[0]?.embedding),
  };
}

(async () => {
  const config = readConfig();
  const mode = String(config.mode || "disabled");
  const provider = providerName(mode, config);
  const model = configuredModel(mode, config);
  const configHash = embeddingConfigHash(mode, config);
  const index = JSON.parse(fs.readFileSync(indexPath, "utf8"));
  const sourcesById = new Map((index.records || []).map((record) => [record.id, record]));
  const chunks = (index.chunks || []).filter((chunk) => String(chunk.content || "").trim());
  const existing = readExistingEmbeddings();
  const existingByChunk = new Map(existing.map((record) => [record.chunkId, record]));

  if (mode === "disabled") {
    const output = {
      schemaVersion: 1,
      provider: {
        mode,
        provider: null,
        model: null,
      },
      builtAt: new Date().toISOString(),
      embeddings: [],
    };
    atomicWriteJson(embeddingsPath, output);
    appendAudit({
      event: "embedding_provider_call",
      mode,
      provider: null,
      model: null,
      action: "skipped_local_only",
      payloadCount: 0,
    });
    console.log(JSON.stringify({
      embeddingPath: ".agentrail/context/index/embeddings.json",
      providerMode: mode,
      provider: null,
      model: null,
      eligible: chunks.length,
      embedded: 0,
      skipped: chunks.length,
      failed: 0,
    }, null, 2));
    return;
  }

  if (!["custom-command", "openai-compatible"].includes(mode)) {
    appendAudit({
      event: "embedding_provider_failure",
      mode,
      provider,
      model,
      action: "unsupported_mode",
      payloadCount: chunks.length,
    });
    throw new Error(`context embedding mode '${mode}' is not supported by this AgentRail version; config is reserved for future provider extension`);
  }

  const nextRecords = [];
  let embedded = 0;
  let skipped = 0;
  for (const chunk of chunks) {
    const source = sourceForChunk(sourcesById, chunk);
    const prior = existingByChunk.get(chunk.id);
    if (isReusable(prior, chunk, source, mode, configHash)) {
      nextRecords.push(prior);
      skipped += 1;
      continue;
    }

    const payload = {
      mode,
      provider,
      model,
      chunkId: chunk.id,
      path: chunk.path,
      citation: chunk.citation,
      contentHash: source.contentHash,
      textHash: chunk.textHash,
      auditRef: source.auditRef,
      content: chunk.content,
    };

    appendAudit({
      event: "embedding_provider_call",
      mode,
      provider,
      model,
      action: "embed_chunk",
      chunkId: chunk.id,
      contentHash: source.contentHash,
      textHash: chunk.textHash,
      auditRef: source.auditRef,
    });

    let result;
    try {
      result = mode === "custom-command"
        ? runCustomCommand(config, payload)
        : await runOpenAICompatible(config, payload);
    } catch (error) {
      appendAudit({
        event: "embedding_provider_failure",
        mode,
        provider,
        model,
        action: "embed_chunk_failed",
        chunkId: chunk.id,
        contentHash: source.contentHash,
        textHash: chunk.textHash,
        auditRef: source.auditRef,
        message: "embedding provider failed",
      });
      throw error;
    }

    const vector = result.vector;
    const timestamp = new Date().toISOString();
    nextRecords.push({
      mode,
      provider: result.provider || provider,
      model: result.model || model,
      configHash,
      dimension: vector.length,
      contentHash: source.contentHash,
      chunkId: chunk.id,
      textHash: chunk.textHash,
      timestamp,
      auditRef: source.auditRef,
      path: chunk.path,
      citation: chunk.citation,
      embedding: vector,
    });
    embedded += 1;
  }

  nextRecords.sort((a, b) => a.chunkId.localeCompare(b.chunkId));
  const output = {
    schemaVersion: 1,
    provider: {
      mode,
      provider,
      model,
    },
    builtAt: new Date().toISOString(),
    embeddings: nextRecords,
  };
  atomicWriteJson(embeddingsPath, output);
  appendAudit({
    event: "embedding_provider_complete",
    mode,
    provider,
    model,
    payloadCount: chunks.length,
    embedded,
    skipped,
    embeddingCount: nextRecords.length,
  });
  console.log(JSON.stringify({
    embeddingPath: ".agentrail/context/index/embeddings.json",
    providerMode: mode,
    provider,
    model,
    eligible: chunks.length,
    embedded,
    skipped,
    failed: 0,
  }, null, 2));
})().catch((error) => {
  console.error(error.message);
  process.exit(1);
});
NODE
}

run_context_query() {
  local query="${1:-}"
  shift || true
  local target_dir=""
  local json_output=0
  local limit=20

  while [[ "$#" -gt 0 ]]; do
    case "$1" in
      --target)
        [[ "${2:-}" != "" && "${2:-}" != --* ]] || { echo "--target requires a directory" >&2; exit 2; }
        target_dir="$2"
        shift 2
        ;;
      --json)
        json_output=1
        shift
        ;;
      --limit)
        [[ "${2:-}" =~ ^[0-9]+$ ]] || { echo "--limit requires a numeric value" >&2; exit 2; }
        limit="$2"
        shift 2
        ;;
      *)
        echo "Unknown context query option: $1" >&2
        exit 2
        ;;
    esac
  done

  if [[ -z "$query" || "$query" == --* ]]; then
    echo 'context query requires a task string' >&2
    exit 2
  fi

  target_dir="${target_dir:-$(pwd)}"
  target_dir="$(cd "$target_dir" && pwd -P)"
  run_context_index --target "$target_dir" >/dev/null

  AGENTRAIL_TARGET_DIR="$target_dir" \
  AGENTRAIL_CONTEXT_QUERY="$query" \
  AGENTRAIL_CONTEXT_QUERY_JSON_OUTPUT="$json_output" \
  AGENTRAIL_CONTEXT_QUERY_LIMIT="$limit" \
  node <<'NODE'
const fs = require("fs");
const path = require("path");
const crypto = require("crypto");
const childProcess = require("child_process");
const http = require("http");
const https = require("https");

const targetDir = path.resolve(process.env.AGENTRAIL_TARGET_DIR);
const query = process.env.AGENTRAIL_CONTEXT_QUERY || "";
const jsonOutput = process.env.AGENTRAIL_CONTEXT_QUERY_JSON_OUTPUT === "1";
const limit = Math.max(1, Math.min(100, Number(process.env.AGENTRAIL_CONTEXT_QUERY_LIMIT || 20)));
const configPath = path.join(targetDir, ".agentrail/config.json");
const contextDir = path.join(targetDir, ".agentrail/context");
const indexPath = path.join(contextDir, "index/index.json");
const embeddingsPath = path.join(contextDir, "index/embeddings.json");
const auditPath = path.join(contextDir, "audit/events.jsonl");

function appendAudit(event) {
  fs.mkdirSync(path.dirname(auditPath), { recursive: true });
  fs.appendFileSync(auditPath, `${JSON.stringify({ timestamp: new Date().toISOString(), ...event })}\n`);
}

function sha256Text(text) {
  return `sha256:${crypto.createHash("sha256").update(String(text)).digest("hex")}`;
}

function readEmbeddingConfig() {
  try {
    const config = JSON.parse(fs.readFileSync(configPath, "utf8"));
    return config.context?.embedding || {};
  } catch (error) {
    if (error.code === "ENOENT") return {};
    throw new Error(`invalid .agentrail/config.json: ${error.message}`);
  }
}

function providerName(mode, config) {
  if (config.provider) return String(config.provider);
  if (mode === "custom-command") return "custom-command";
  if (mode === "openai-compatible") return "openai-compatible";
  return mode;
}

function configuredModel(mode, config) {
  if (config.model) return String(config.model);
  if (mode === "custom-command") return "custom-command";
  return null;
}

function embeddingConfigHash(mode, config) {
  const fingerprint = {
    mode,
    provider: config.provider || null,
    model: config.model || null,
    command: mode === "custom-command" ? String(config.command || config.customCommand || "") : null,
    baseUrl: mode === "openai-compatible" ? String(config.baseUrl || "https://api.openai.com/v1").replace(/\/+$/, "") : null,
    apiKeyEnv: mode === "openai-compatible" ? String(config.apiKeyEnv || "OPENAI_API_KEY") : null,
  };
  return `sha256:${crypto.createHash("sha256").update(JSON.stringify(fingerprint)).digest("hex")}`;
}

function normalizeEmbedding(value) {
  if (!Array.isArray(value) || value.length === 0) {
    throw new Error("provider returned an empty or missing embedding vector");
  }
  const vector = value.map((item) => Number(item));
  if (vector.some((item) => !Number.isFinite(item))) {
    throw new Error("provider returned a non-numeric embedding vector");
  }
  return vector;
}

function runCustomCommand(config, payload) {
  const command = config.command || config.customCommand;
  if (!command) throw new Error("context.embedding.command is required for custom-command mode");
  const result = childProcess.spawnSync(command, {
    input: `${JSON.stringify(payload)}\n`,
    encoding: "utf8",
    shell: true,
    maxBuffer: 1024 * 1024 * 20,
    cwd: targetDir,
    env: process.env,
  });
  if (result.error) throw result.error;
  if (result.status !== 0) {
    const stderr = String(result.stderr || "").trim();
    throw new Error(`custom embedding command failed with exit ${result.status}${stderr ? `: ${stderr}` : ""}`);
  }
  let parsed;
  try {
    parsed = JSON.parse(String(result.stdout || "").trim());
  } catch (error) {
    throw new Error(`custom embedding command returned invalid JSON: ${error.message}`);
  }
  return normalizeEmbedding(parsed.embedding || parsed.vector);
}

function requestJson(url, headers, body) {
  return new Promise((resolve, reject) => {
    const parsed = new URL(url);
    const transport = parsed.protocol === "http:" ? http : https;
    const request = transport.request({
      method: "POST",
      hostname: parsed.hostname,
      port: parsed.port || undefined,
      path: `${parsed.pathname}${parsed.search}`,
      headers: {
        "content-type": "application/json",
        "content-length": Buffer.byteLength(body),
        ...headers,
      },
    }, (response) => {
      const chunks = [];
      response.on("data", (chunk) => chunks.push(chunk));
      response.on("end", () => {
        const text = Buffer.concat(chunks).toString("utf8");
        let parsedBody = {};
        try {
          parsedBody = text ? JSON.parse(text) : {};
        } catch (error) {
          reject(new Error(`embedding provider returned invalid JSON: ${error.message}`));
          return;
        }
        if (response.statusCode < 200 || response.statusCode >= 300) {
          reject(new Error(`embedding provider returned HTTP ${response.statusCode}: ${text.slice(0, 500)}`));
          return;
        }
        resolve(parsedBody);
      });
    });
    request.on("error", reject);
    request.write(body);
    request.end();
  });
}

async function runOpenAICompatible(config, payload) {
  const model = config.model;
  if (!model) throw new Error("context.embedding.model is required for openai-compatible mode");
  const apiKeyEnv = config.apiKeyEnv || "OPENAI_API_KEY";
  const apiKey = process.env[apiKeyEnv];
  if (!apiKey) throw new Error(`${apiKeyEnv} is required for openai-compatible embedding mode`);
  const baseUrl = String(config.baseUrl || "https://api.openai.com/v1").replace(/\/+$/, "");
  const response = await requestJson(`${baseUrl}/embeddings`, {
    authorization: `Bearer ${apiKey}`,
  }, JSON.stringify({ model, input: payload.content }));
  return normalizeEmbedding(response.data?.[0]?.embedding);
}

function cosineSimilarity(a, b) {
  if (!Array.isArray(a) || !Array.isArray(b) || a.length === 0 || a.length !== b.length) return 0;
  let dot = 0;
  let left = 0;
  let right = 0;
  for (let index = 0; index < a.length; index += 1) {
    dot += a[index] * b[index];
    left += a[index] * a[index];
    right += b[index] * b[index];
  }
  if (left === 0 || right === 0) return 0;
  return dot / (Math.sqrt(left) * Math.sqrt(right));
}

function reusableEmbedding(record, chunk, source, mode, configHash) {
  return record &&
    record.mode === mode &&
    record.configHash === configHash &&
    record.chunkId === chunk.id &&
    record.textHash === chunk.textHash &&
    record.contentHash === source.contentHash &&
    Array.isArray(record.embedding) &&
    Number.isFinite(record.dimension);
}

function tokenize(text) {
  return String(text || "")
    .toLowerCase()
    .match(/[a-z0-9_.-]+\/[a-z0-9_.\/-]+|[#]?\d+|[a-z][a-z0-9_-]*|[a-z0-9_.-]+/g) || [];
}

function unique(values) {
  return Array.from(new Set(values));
}

function issueRefs(text) {
  const refs = new Set();
  const value = String(text || "");
  for (const match of value.matchAll(/(?:^|[^A-Za-z])#(\d+)\b/g)) {
    const prefix = value.slice(0, match.index + match[0].length - match[1].length - 1);
    if (/(?:^|\b)(?:pr|pull\s+request)\s*$/i.test(prefix)) continue;
    refs.add(Number(match[1]));
  }
  for (const match of value.matchAll(/\/issues\/(\d+)\b/g)) refs.add(Number(match[1]));
  return Array.from(refs).sort((a, b) => a - b);
}

function prRefs(text) {
  const refs = new Set();
  for (const match of String(text || "").matchAll(/\/pull\/(\d+)\b/g)) refs.add(Number(match[1]));
  for (const match of String(text || "").matchAll(/\bpr\s*#?(\d+)\b/gi)) refs.add(Number(match[1]));
  for (const match of String(text || "").matchAll(/\bpull\s+request\s*#?(\d+)\b/gi)) refs.add(Number(match[1]));
  return Array.from(refs).sort((a, b) => a - b);
}

function scoreAuthority(record) {
  if (record.authority === "critical") return 0.45;
  if (record.authority === "high") return 0.3;
  if (record.authority === "normal") return 0;
  return 0;
}

function authorityDemotion(record) {
  if (record.authority === "low") return 0.45;
  if (record.authority === "denied") return 999;
  return 0;
}

function memoryFreshness(memory) {
  const now = Date.now();
  let demotion = 0;
  const reasons = [];
  if (!memory || typeof memory !== "object") return { demotion, reasons };
  if (memory.expires_at) {
    const expiresAt = Date.parse(memory.expires_at);
    if (Number.isFinite(expiresAt) && expiresAt < now) {
      demotion += 1.5;
      reasons.push("expired memory");
    }
  }
  if (memory.created_at) {
    const createdAt = Date.parse(memory.created_at);
    const staleBefore = now - 180 * 24 * 60 * 60 * 1000;
    if (Number.isFinite(createdAt) && createdAt < staleBefore) {
      demotion += 0.4;
      reasons.push("stale memory");
    }
  }
  if (String(memory.confidence || "").toLowerCase() === "low") {
    demotion += 0.25;
    reasons.push("low-confidence memory");
  }
  return { demotion, reasons };
}

function freshnessDemotion(record, chunk) {
  let demotion = 0;
  const reasons = [];
  const freshnessStatus = String(record.freshness?.status || "current").toLowerCase();
  if (freshnessStatus === "expired") {
    demotion += 1.5;
    reasons.push("expired source");
  } else if (freshnessStatus === "stale") {
    demotion += 0.75;
    reasons.push("stale source");
  } else if (freshnessStatus === "unknown") {
    demotion += 0.15;
    reasons.push("unknown freshness");
  }
  const memory = chunk?.memory || record.memory || null;
  const memoryStatus = memoryFreshness(memory);
  demotion += memoryStatus.demotion;
  reasons.push(...memoryStatus.reasons);
  return { demotion, reasons };
}

function recordText(item) {
  const record = item.source || {};
  const chunk = item.chunk || {};
  return [
    record.path,
    record.id,
    record.sourceType,
    record.authority,
    chunk.content || record.content,
    chunk.citation,
    chunk.parentContext,
    JSON.stringify(chunk.headingPath || []),
    JSON.stringify(chunk.symbolHints || []),
    JSON.stringify(chunk.importHints || []),
    JSON.stringify(record.linkedIssues || []),
    JSON.stringify(record.linkedPullRequests || []),
  ].join("\n");
}

function boundedContent(item) {
  const content = item.chunk ? item.chunk.content : item.source.content;
  if (typeof content !== "string") return content;
  return content.length > 2000 ? `${content.slice(0, 2000)}\n[TRUNCATED]` : content;
}

function reciprocalRank(rank) {
  return rank > 0 ? 1 / (60 + rank) : 0;
}

function buildReason(parts) {
  const ordered = [
    "deterministic required context",
    "active workflow state",
    "same issue prior failure",
    "linked issue",
    "linked pull request",
    "exact identifier",
    "exact path",
    "BM25 keyword match",
    "embedding similarity",
    "high authority source",
    "current memory",
    "stale memory",
    "expired memory",
    "low authority source",
  ];
  return ordered.filter((item) => parts.has(item)).join("; ") || "Included by hybrid retrieval score.";
}

(async () => {
  const index = JSON.parse(fs.readFileSync(indexPath, "utf8"));
  const sourcesById = new Map((index.records || []).map((record) => [record.id, record]));
  const retrievalItems = Array.isArray(index.chunks) && index.chunks.length > 0
    ? index.chunks.map((chunk) => ({ chunk, source: sourcesById.get(chunk.sourceId) || {} }))
    : (index.records || []).map((record) => ({ chunk: null, source: record }));

  const queryTokens = unique(tokenize(query));
  const queryLower = query.toLowerCase();
  const queryIssueRefs = issueRefs(query);
  const queryPrRefs = prRefs(query);
  const activeIssue = (() => {
    try {
      const state = JSON.parse(fs.readFileSync(path.join(targetDir, ".agentrail/state.json"), "utf8"));
      return Number(state.workflow?.activeIssue || state.workflow?.activeRun?.targetIssue || 0) || null;
    } catch {
      return null;
    }
  })();
  const effectiveIssueRefs = queryIssueRefs.length > 0
    ? queryIssueRefs
    : (queryPrRefs.length === 0 && activeIssue ? [activeIssue] : []);

  const corpus = retrievalItems.map((item) => {
    const text = recordText(item);
    const tokens = tokenize(text);
    const termCounts = new Map();
    for (const token of tokens) termCounts.set(token, (termCounts.get(token) || 0) + 1);
    return { item, text, textLower: text.toLowerCase(), tokens, termCounts };
  });
  const docCount = Math.max(1, corpus.length);
  const avgDocLength = corpus.reduce((sum, doc) => sum + doc.tokens.length, 0) / docCount || 1;
  const docFreq = new Map();
  for (const token of queryTokens) {
    docFreq.set(token, corpus.reduce((count, doc) => count + (doc.termCounts.has(token) ? 1 : 0), 0));
  }

  const lexicalRaw = new Map();
  const scored = corpus.map((doc) => {
    const record = doc.item.source || {};
    const chunk = doc.item.chunk || null;
    const reasons = new Set();
    let deterministic = 0;
    let keyword = 0;
    let bm25 = 0;

    const linkedIssue = effectiveIssueRefs.some((number) => Array.isArray(record.linkedIssues) && record.linkedIssues.includes(number));
    const linkedPr = queryPrRefs.some((number) => Array.isArray(record.linkedPullRequests) && record.linkedPullRequests.includes(number));
    const sameIssueFinding = record.sourceType === "run_artifact" &&
      effectiveIssueRefs.some((number) => doc.textLower.includes(`issue-${number}`) || doc.textLower.includes(`"issue": ${number}`) || doc.textLower.includes(`targetissue": ${number}`));

    if (record.path === ".agentrail/state.json" && activeIssue && effectiveIssueRefs.includes(activeIssue)) {
      deterministic += 4;
      reasons.add("active workflow state");
      reasons.add("deterministic required context");
    }
    if (sameIssueFinding) {
      deterministic += 3.5;
      reasons.add("same issue prior failure");
    }
    if (linkedIssue) {
      deterministic += 3;
      reasons.add("linked issue");
    }
    if (linkedPr) {
      deterministic += 2.5;
      reasons.add("linked pull request");
    }
    if (["context_doc", "taste_doc"].includes(record.sourceType) && /context|taste|required/.test(queryLower)) {
      deterministic += 2;
      reasons.add("deterministic required context");
    }

    for (const issue of effectiveIssueRefs) {
      if (doc.textLower.includes(`#${issue}`) || doc.textLower.includes(`/issues/${issue}`)) {
        keyword += 2;
        reasons.add("exact identifier");
      }
    }
    for (const pr of queryPrRefs) {
      if (doc.textLower.includes(`/pull/${pr}`) || doc.textLower.includes(`pr #${pr}`)) {
        keyword += 1.5;
        reasons.add("exact identifier");
      }
    }
    for (const token of queryTokens) {
      if (token.includes("/") && doc.textLower.includes(token)) {
        keyword += 1.5;
        reasons.add("exact path");
      }
    }
    const queryPhrases = queryLower.match(/[a-z0-9_-]+(?:\s+[a-z0-9_-]+){1,4}/g) || [];
    for (const phrase of queryPhrases) {
      if (phrase.length > 8 && doc.textLower.includes(phrase)) {
        keyword += 1;
        reasons.add("exact identifier");
      }
    }

    for (const token of queryTokens) {
      const tf = doc.termCounts.get(token) || 0;
      if (tf === 0) continue;
      const df = docFreq.get(token) || 0;
      const idf = Math.log(1 + (docCount - df + 0.5) / (df + 0.5));
      const k1 = 1.2;
      const b = 0.75;
      bm25 += idf * ((tf * (k1 + 1)) / (tf + k1 * (1 - b + b * (doc.tokens.length / avgDocLength))));
    }
    if (bm25 > 0) reasons.add("BM25 keyword match");

    const authorityBoost = scoreAuthority(record);
    if (authorityBoost > 0) reasons.add("high authority source");
    const authorityPenalty = authorityDemotion(record);
    if (authorityPenalty > 0 && authorityPenalty < 999) reasons.add("low authority source");
    const fresh = freshnessDemotion(record, chunk);
    for (const reason of fresh.reasons) reasons.add(reason);
    if ((chunk?.memory || record.memory) && fresh.demotion === 0) reasons.add("current memory");

    const lexical = deterministic + keyword + bm25;
    lexicalRaw.set(chunk?.id || record.id, lexical);
    return {
      item: doc.item,
      textLower: doc.textLower,
      reasons,
      score: {
        deterministic,
        keyword,
        bm25,
        embedding: null,
        rrf: 0,
        authorityBoost,
        authorityDemotion: authorityPenalty >= 999 ? 0 : authorityPenalty,
        freshnessDemotion: fresh.demotion,
        final: 0,
      },
    };
  });

  const lexicalRank = new Map(scored
    .filter((entry) => lexicalRaw.get(entry.item.chunk?.id || entry.item.source.id) > 0)
    .sort((a, b) => lexicalRaw.get(b.item.chunk?.id || b.item.source.id) - lexicalRaw.get(a.item.chunk?.id || a.item.source.id))
    .map((entry, index) => [entry.item.chunk?.id || entry.item.source.id, index + 1]));

  let queryVector = null;
  let embeddingRecords = [];
  let provider = { mode: "disabled", provider: null, model: null };
  const embeddingConfig = readEmbeddingConfig();
  const embeddingMode = String(embeddingConfig.mode || "disabled");
  const embeddingHash = embeddingConfigHash(embeddingMode, embeddingConfig);
  if (embeddingMode !== "disabled" && !["custom-command", "openai-compatible"].includes(embeddingMode)) {
    throw new Error(`context embedding mode '${embeddingMode}' is not supported by this AgentRail version; config is reserved for future provider extension`);
  }
  if (embeddingMode !== "disabled" && fs.existsSync(embeddingsPath)) {
    const parsed = JSON.parse(fs.readFileSync(embeddingsPath, "utf8"));
    embeddingRecords = Array.isArray(parsed.embeddings) ? parsed.embeddings : [];
    provider = parsed.provider || { mode: embeddingMode, provider: providerName(embeddingMode, embeddingConfig), model: configuredModel(embeddingMode, embeddingConfig) };
    if (embeddingRecords.length > 0) {
      const payload = {
        mode: embeddingMode,
        provider: providerName(embeddingMode, embeddingConfig),
        model: configuredModel(embeddingMode, embeddingConfig),
        chunkId: "query",
        path: "query",
        citation: "query",
        contentHash: sha256Text(query),
        textHash: sha256Text(query),
        auditRef: `audit:query:${sha256Text(query).slice(7, 19)}`,
        content: query,
      };
      appendAudit({
        event: "embedding_provider_call",
        mode: embeddingMode,
        provider: payload.provider,
        model: payload.model,
        action: "embed_query",
        queryHash: payload.textHash,
      });
      try {
        queryVector = embeddingMode === "custom-command"
          ? runCustomCommand(embeddingConfig, payload)
          : await runOpenAICompatible(embeddingConfig, payload);
      } catch (error) {
        appendAudit({
          event: "embedding_provider_failure",
          mode: embeddingMode,
          provider: payload.provider,
          model: payload.model,
          action: "embed_query_failed",
          queryHash: payload.textHash,
          auditRef: payload.auditRef,
          message: "embedding provider failed",
        });
        queryVector = null;
      }
    }
  }
  const embeddingsByChunk = new Map(embeddingRecords.map((record) => [record.chunkId, record]));
  const semanticRanked = [];
  if (queryVector) {
    for (const entry of scored) {
      const chunkId = entry.item.chunk?.id || null;
      const embedding = chunkId ? embeddingsByChunk.get(chunkId) : null;
      if (embedding && !reusableEmbedding(embedding, entry.item.chunk, entry.item.source || {}, embeddingMode, embeddingHash)) {
        continue;
      }
      const similarity = embedding ? Math.max(0, cosineSimilarity(queryVector, embedding.embedding)) : 0;
      if (similarity > 0) {
        entry.score.embedding = similarity;
        entry.reasons.add("embedding similarity");
        semanticRanked.push(entry);
      }
    }
  }
  const semanticRank = new Map(semanticRanked
    .sort((a, b) => (b.score.embedding || 0) - (a.score.embedding || 0))
    .map((entry, index) => [entry.item.chunk?.id || entry.item.source.id, index + 1]));

  const excluded = [];
  for (const item of index.skipped || []) {
    excluded.push({
      sourceType: "path",
      path: item.path,
      reason: item.reason,
      citation: ".agentrail/context/index/index.json",
    });
  }

  const results = scored
    .filter((entry) => {
      const record = entry.item.source || {};
      if (record.authority === "denied" || record.visibility === "denied") {
        excluded.push({
          sourceType: record.sourceType,
          path: record.path,
          reason: "denied_source",
          citation: record.path,
        });
        return false;
      }
      return true;
    })
    .map((entry) => {
      const id = entry.item.chunk?.id || entry.item.source.id;
      const lexicalRrf = reciprocalRank(lexicalRank.get(id) || 0);
      const semanticRrf = reciprocalRank(semanticRank.get(id) || 0);
      entry.score.rrf = lexicalRrf + semanticRrf;
      const lexical = lexicalRaw.get(id) || 0;
      const semantic = entry.score.embedding || 0;
      entry.score.final = lexical + (semantic * 2) + (entry.score.rrf * 10) + entry.score.authorityBoost - entry.score.authorityDemotion - entry.score.freshnessDemotion;
      return entry;
    })
    .filter((entry) => entry.score.final > 0)
    .sort((a, b) => b.score.final - a.score.final || String(a.item.chunk?.citation || a.item.source.path).localeCompare(String(b.item.chunk?.citation || b.item.source.path)))
    .slice(0, limit)
    .map((entry, index) => {
      const record = entry.item.source || {};
      const chunk = entry.item.chunk || null;
      const score = {};
      for (const [key, value] of Object.entries(entry.score)) {
        score[key] = value === null ? null : Number(value.toFixed(6));
      }
      return {
        rank: index + 1,
        kind: "indexed_context",
        sourceType: record.sourceType,
        path: record.path,
        sourceId: record.id,
        chunkId: chunk?.id || null,
        citation: chunk?.citation || record.path,
        reason: buildReason(entry.reasons),
        contentHash: record.contentHash,
        textHash: chunk?.textHash || null,
        headingPath: chunk?.headingPath || [],
        parentContext: chunk?.parentContext || record.path,
        matchContext: chunk ? [record.path, chunk.parentContext, ...(chunk.headingPath || [])].filter(Boolean).join(" > ") : record.path,
        symbolHints: chunk?.symbolHints || [],
        importHints: chunk?.importHints || [],
        memory: chunk?.memory || record.memory || null,
        content: boundedContent(entry.item),
        score,
      };
    });

  const output = {
    schemaVersion: 1,
    query,
    generatedAt: new Date().toISOString(),
    index: {
      version: index.version,
      builtAt: index.builtAt,
    },
    provider,
    results,
    excluded,
  };

  appendAudit({
    event: "context_query",
    queryHash: sha256Text(query),
    resultCount: results.length,
    excludedCount: excluded.length,
    providerMode: provider.mode || embeddingMode,
  });

  if (jsonOutput) {
    console.log(JSON.stringify(output, null, 2));
  } else {
    console.log(`query=${query}`);
    for (const item of results) {
      console.log(`${item.rank}. ${item.citation}`);
      console.log(`   score=${item.score.final} reason=${item.reason}`);
    }
    if (excluded.length > 0) {
      console.log("excluded:");
      for (const item of excluded) console.log(`- ${item.path}: ${item.reason}`);
    }
  }
})().catch((error) => {
  console.error(error.message);
  process.exit(1);
});
NODE
}

run_context_build() {
  local target_dir=""
  local target_kind="${1:-}"
  local target_number="${2:-}"
  shift 2 || true
  local phase=""
  local json_output=0

  while [[ "$#" -gt 0 ]]; do
    case "$1" in
      --target)
        [[ "${2:-}" != "" && "${2:-}" != --* ]] || { echo "--target requires a directory" >&2; exit 2; }
        target_dir="$2"
        shift 2
        ;;
      --phase)
        [[ "${2:-}" != "" && "${2:-}" != --* ]] || { echo "--phase requires a value" >&2; exit 2; }
        phase="$2"
        shift 2
        ;;
      --json)
        json_output=1
        shift
        ;;
      *)
        echo "Unknown context build option: $1" >&2
        exit 2
        ;;
    esac
  done

  target_dir="${target_dir:-$(pwd)}"
  target_dir="$(cd "$target_dir" && pwd -P)"
  if [[ "$target_kind" != "issue" && "$target_kind" != "pr" ]]; then
    echo "context build requires target kind: issue or pr" >&2
    exit 2
  fi
  if [[ ! "$target_number" =~ ^[0-9]+$ ]]; then
    echo "context build requires a numeric target" >&2
    exit 2
  fi
  if [[ "$phase" == "" ]]; then
    echo "context build requires --phase" >&2
    exit 2
  fi
  case "$target_kind:$phase" in
    issue:plan|issue:execute|issue:verify|pr:review)
      ;;
    *)
      echo "context build phase must be one of: issue plan|execute|verify, pr review" >&2
      exit 2
      ;;
  esac

  AGENTRAIL_TARGET_DIR="$target_dir" \
  AGENTRAIL_CONTEXT_TARGET_KIND="$target_kind" \
  AGENTRAIL_CONTEXT_TARGET_NUMBER="$target_number" \
  AGENTRAIL_CONTEXT_PHASE="$phase" \
  AGENTRAIL_CONTEXT_JSON_OUTPUT="$json_output" \
  node <<'NODE'
const fs = require("fs");
const path = require("path");

const targetDir = path.resolve(process.env.AGENTRAIL_TARGET_DIR);
const targetKind = process.env.AGENTRAIL_CONTEXT_TARGET_KIND;
const targetNumber = Number(process.env.AGENTRAIL_CONTEXT_TARGET_NUMBER);
const phase = process.env.AGENTRAIL_CONTEXT_PHASE;
const jsonOutput = process.env.AGENTRAIL_CONTEXT_JSON_OUTPUT === "1";
const contextDir = path.join(targetDir, ".agentrail/context");
const indexPath = path.join(contextDir, "index/index.json");
const packsDir = path.join(contextDir, "packs");
const auditPath = path.join(contextDir, "audit/events.jsonl");

function appendAudit(event) {
  fs.mkdirSync(path.dirname(auditPath), { recursive: true });
  fs.appendFileSync(auditPath, `${JSON.stringify({ timestamp: new Date().toISOString(), ...event })}\n`);
}

function relativePath(filePath) {
  return path.relative(targetDir, filePath).split(path.sep).join("/");
}

if (!fs.existsSync(indexPath)) {
  throw new Error("context index is missing; run `agentrail context index --target <dir>` first");
}

const index = JSON.parse(fs.readFileSync(indexPath, "utf8"));
const generatedAt = new Date().toISOString();
const generatedAtSlug = generatedAt.replace(/[-:.]/g, "");
const packId = `${targetKind}-${targetNumber}-${phase}-${generatedAtSlug}`;
const targetToken = `#${targetNumber}`;
const targetUrlFragment = targetKind === "issue" ? `/issues/${targetNumber}` : `/pull/${targetNumber}`;
const sourcesById = new Map((index.records || []).map((record) => [record.id, record]));
const retrievalItems = Array.isArray(index.chunks) && index.chunks.length > 0
  ? index.chunks.map((chunk) => ({ chunk, source: sourcesById.get(chunk.sourceId) || {} }))
  : (index.records || []).map((record) => ({ chunk: null, source: record }));

function recordText(item) {
  const record = item.source || {};
  const chunk = item.chunk || {};
  return [
    record.path,
    record.sourceType,
    chunk.content || record.content,
    chunk.citation,
    chunk.parentContext,
    JSON.stringify(chunk.headingPath || []),
    JSON.stringify(chunk.symbolHints || []),
    JSON.stringify(chunk.importHints || []),
    JSON.stringify(record.linkedIssues || []),
    JSON.stringify(record.linkedPullRequests || []),
  ].join("\n").toLowerCase();
}

function redactionCount(record) {
  return Array.isArray(record.redactions) ? record.redactions.reduce((sum, finding) => sum + Number(finding.count || 0), 0) : 0;
}

function authorityBoost(record) {
  if (record.authority === "critical") return 0.3;
  if (record.authority === "high") return 0.2;
  return 0;
}

function relevanceScore(item) {
  const record = item.source || {};
  const chunk = item.chunk || null;
  let deterministic = 0;
  let keyword = 0;
  const text = recordText(item);
  const chunkLinked = chunk
    ? text.includes(targetToken.toLowerCase()) || text.includes(targetUrlFragment.toLowerCase())
    : false;
  const sourceLinked = targetKind === "issue"
    ? Array.isArray(record.linkedIssues) && record.linkedIssues.includes(targetNumber)
    : Array.isArray(record.linkedPullRequests) && record.linkedPullRequests.includes(targetNumber);
  const singleChunkSource = chunk && Array.isArray(record.chunkIds) && record.chunkIds.length === 1;
  const linked = chunk ? (chunkLinked || (singleChunkSource && sourceLinked)) : sourceLinked;

  if (linked) deterministic += 4;
  if (record.authority === "critical") deterministic += 2;
  if (text.includes(targetToken.toLowerCase()) || text.includes(targetUrlFragment.toLowerCase())) keyword += 2;
  if (phase && text.includes(String(phase).toLowerCase())) keyword += 1;
  const authority = deterministic > 0 || keyword > 0 ? authorityBoost(record) : 0;

  return {
    deterministic,
    keyword,
    embedding: null,
    authorityBoost: authority,
    redaction: Math.min(redactionCount(record) * 0.01, 0.05),
    final: deterministic + keyword + authority,
  };
}

function boundedContent(item) {
  const content = item.chunk ? item.chunk.content : item.source.content;
  if (typeof content !== "string") return content;
  return content.length > 2000 ? `${content.slice(0, 2000)}\n[TRUNCATED]` : content;
}

const prioritizedRecords = retrievalItems
  .map((item) => ({ item, score: relevanceScore(item) }))
  .filter(({ score }) => score.final > 0)
  .sort((a, b) => b.score.final - a.score.final || String(a.item.chunk?.citation || a.item.source.path).localeCompare(String(b.item.chunk?.citation || b.item.source.path)));
const included = prioritizedRecords.slice(0, 20).map(({ item, score }) => {
  const record = item.source || {};
  const chunk = item.chunk;
  return {
  kind: "indexed_context",
  sourceType: record.sourceType,
  path: record.path,
  sourceId: record.id,
  chunkId: chunk?.id || null,
  reason: chunk
    ? `Included from source-citable chunk ${chunk.citation}; matched ${chunk.parentContext || record.path}.`
    : "Included from the local redacted context index.",
  citation: chunk?.citation || record.path,
  contentHash: record.contentHash,
  textHash: chunk?.textHash || null,
  headingPath: chunk?.headingPath || [],
  parentContext: chunk?.parentContext || record.path,
  matchContext: chunk ? [record.path, chunk.parentContext, ...(chunk.headingPath || [])].filter(Boolean).join(" > ") : record.path,
  symbolHints: chunk?.symbolHints || [],
  importHints: chunk?.importHints || [],
  memory: chunk?.memory || record.memory || null,
  redactions: record.redactions || [],
  content: boundedContent(item),
  score,
  };
});

const excluded = (Array.isArray(index.skipped) ? index.skipped : []).map((item) => ({
  sourceType: "path",
  path: item.path,
  reason: item.reason,
  citation: ".agentrail/context/index/index.json",
}));

const pack = {
  schemaVersion: 1,
  packId,
  target: {
    kind: targetKind,
    number: targetNumber,
    phase,
  },
  generatedAt,
  index: {
    version: index.version,
    builtAt: index.builtAt,
  },
  retrievalBudget: {
    maxItems: 20,
    maxTokens: 6000,
  },
  provider: index.provider || { mode: "disabled", externalCalls: [] },
  goal: {
    summary: `${targetKind} #${targetNumber} ${phase} context pack`,
    citation: `github:${targetKind}/${targetNumber}`,
  },
  included,
  excluded,
  openQuestions: [],
};

fs.mkdirSync(packsDir, { recursive: true });
const baseName = packId;
const jsonPath = path.join(packsDir, `${baseName}.json`);
const mdPath = path.join(packsDir, `${baseName}.md`);
fs.writeFileSync(jsonPath, `${JSON.stringify(pack, null, 2)}\n`);
fs.writeFileSync(mdPath, [
  `# Context Pack: ${targetKind} #${targetNumber} ${phase}`,
  "",
  `Goal: ${pack.goal.summary}`,
  "",
  "## Included Context",
  ...included.map((item) => `- \`${item.path}\`: ${item.reason}`),
  "",
  "## Excluded Context",
  ...(excluded.length > 0 ? excluded.map((item) => `- \`${item.path}\`: ${item.reason}`) : ["None."]),
  "",
  "## Provider",
  `Mode: ${pack.provider.mode}`,
  "",
].join("\n"));

appendAudit({
  event: "generated_context_pack",
  packId,
  target: pack.target,
  jsonPath: relativePath(jsonPath),
  markdownPath: relativePath(mdPath),
  includedCount: included.length,
  excludedCount: excluded.length,
  providerMode: pack.provider.mode,
});

const output = {
  jsonPath: relativePath(jsonPath),
  markdownPath: relativePath(mdPath),
  packId,
};
if (jsonOutput) {
  console.log(JSON.stringify(output, null, 2));
} else {
  console.log(`jsonPath=${output.jsonPath}`);
  console.log(`markdownPath=${output.markdownPath}`);
}
NODE
}

run_context() {
  local kind="${1:-}"
  shift || true

  case "$kind" in
    sources)
      run_context_sources "$@"
      ;;
    index)
      run_context_index "$@"
      ;;
    embed)
      run_context_embed "$@"
      ;;
    query)
      run_context_query "$@"
      ;;
    build)
      run_context_build "$@"
      ;;
    ""|-h|--help)
      usage
      exit 0
      ;;
    *)
      echo "Unknown context command: $kind" >&2
      usage >&2
      exit 2
      ;;
  esac
}

inspect_state() {
  local target_dir="$1"

  AGENTRAIL_REPO_DIR="$repo_dir" \
  AGENTRAIL_TARGET_DIR="$target_dir" \
  node <<'NODE'
const fs = require("fs");
const path = require("path");
const crypto = require("crypto");

const repoDir = process.env.AGENTRAIL_REPO_DIR;
const targetDir = process.env.AGENTRAIL_TARGET_DIR;
const statePath = path.join(targetDir, ".agentrail/state.json");
let currentVersion = "";
try {
  currentVersion = JSON.parse(fs.readFileSync(path.join(repoDir, "package.json"), "utf8")).version;
} catch (error) {
  console.log(`SOURCE=missing:${path.join(repoDir, "package.json")}`);
}

function sha256(file) {
  return `sha256:${crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex")}`;
}

let state;
try {
  state = JSON.parse(fs.readFileSync(statePath, "utf8"));
} catch (error) {
  if (error.code === "ENOENT") {
    console.log("STATE=missing");
    process.exit(0);
  }
  console.log(`STATE=invalid:${error.message}`);
  process.exit(0);
}

console.log("STATE=ok");
if (!Array.isArray(state.managedFiles)) {
  console.log("STATE_SHAPE=invalid:managedFiles must be an array");
  process.exit(0);
}

if (currentVersion) {
  if (state.agentrailVersion === currentVersion) {
    console.log("VERSION=ok");
  } else {
    console.log(`VERSION=outdated:${state.agentrailVersion || ""}`);
  }
}

let hashMismatch = false;
let sourceMismatch = false;
let missingManaged = false;
const optionalManagedPaths = new Set(["TASTE.md"]);

for (const file of state.managedFiles) {
  if (!file || typeof file.path !== "string" || typeof file.contentHash !== "string") {
    console.log("STATE_SHAPE=invalid:managedFiles entries require path and contentHash");
    continue;
  }

  const targetPath = path.join(targetDir, file.path);
  if (!fs.existsSync(targetPath)) {
    if (optionalManagedPaths.has(file.path)) {
      console.log(`OPTIONAL_MISSING=${file.path}`);
      continue;
    }
    missingManaged = true;
    console.log(`MISSING_MANAGED=${file.path}`);
    continue;
  }

  const currentHash = sha256(targetPath);
  if (currentHash !== file.contentHash) {
    if (optionalManagedPaths.has(file.path)) {
      console.log(`OPTIONAL_MODIFIED=${file.path}`);
      continue;
    }
    hashMismatch = true;
    console.log(`HASH_MISMATCH=${file.path}`);
  }

  const userOwnedStatus = file.installStatus === "legacy-adopted" || file.installStatus === "preserved";
  if (!userOwnedStatus && typeof file.source === "string") {
    const sourcePath = path.join(repoDir, file.source);
    if (fs.existsSync(sourcePath) && sha256(sourcePath) !== file.contentHash) {
      sourceMismatch = true;
      console.log(`SOURCE_MISMATCH=${file.path}`);
    }
  }
}

if (!hashMismatch && !missingManaged) console.log("HASHES=ok");
if (!sourceMismatch) console.log("SOURCE_HASHES=ok");
NODE
}

run_doctor() {
  local target_dir
  parse_target target_dir "$@"

  local hash_output
  hash_output="$(inspect_state "$target_dir")"

  local state_status="missing"
  local version_status=""
  local invalid_state=0
  local required_missing=0
  local hash_mismatch=0
  local source_mismatch=0
  local missing_managed=0
  local registry_invalid=0
  local legacy_scripts=0
  local hidden_source_missing=0

  while IFS= read -r line; do
    case "$line" in
      SOURCE=missing:*)
        hidden_source_missing=1
        ;;
      STATE=ok)
        state_status="ok"
        ;;
      STATE=missing)
        state_status="missing"
        ;;
      STATE=invalid:*)
        state_status="invalid"
        invalid_state=1
        ;;
      VERSION=ok)
        version_status="ok"
        ;;
      VERSION=outdated:*)
        version_status="outdated"
        ;;
      HASH_MISMATCH=*)
        hash_mismatch=1
        ;;
      SOURCE_MISMATCH=*)
        source_mismatch=1
        ;;
      MISSING_MANAGED=*)
        missing_managed=1
        ;;
      STATE_SHAPE=*)
        invalid_state=1
        ;;
    esac
  done <<<"$hash_output"
  if [[ "$state_status" != "missing" && ! -f "${target_dir}/.agentrail/source/package.json" ]]; then
    hidden_source_missing=1
  fi

  local registry_output
  if [[ "$state_status" != "missing" ]] && ! registry_output="$(validate_skill_registry "$target_dir")"; then
    registry_invalid=1
  fi

  local status="installed"
  if [[ "$invalid_state" -eq 1 ]]; then
    status="corrupt"
  elif [[ "$state_status" == "missing" ]]; then
    status="missing"
  elif [[ "$registry_invalid" -eq 1 ]]; then
    status="corrupt"
  elif [[ "$hash_mismatch" -eq 1 || "$missing_managed" -eq 1 || "$hidden_source_missing" -eq 1 ]]; then
    status="modified"
  elif [[ "$version_status" == "outdated" || "$source_mismatch" -eq 1 ]]; then
    status="outdated"
  fi

  echo "AgentRail doctor: ${target_dir}"
  echo "status: ${status}"

  echo "core:"
  local -a required_paths=(
    "AGENTS.md|AGENTS.md|file"
    "CONTEXT.md|CONTEXT.md|file"
    "docs/agents/|docs/agents|dir"
    "docs/prd/|docs/prd|dir"
    "docs/milestones/|docs/milestones|dir"
    "skills/|skills|dir"
    ".agentrail/config.json|.agentrail/config.json|file"
    ".agentrail/source/package.json|.agentrail/source/package.json|file"
    ".agentrail/source/agentrail/__init__.py|.agentrail/source/agentrail/__init__.py|file"
    ".agentrail/source/scripts/agentrail|.agentrail/source/scripts/agentrail|executable"
    ".agentrail/source/scripts/agentrail-legacy|.agentrail/source/scripts/agentrail-legacy|executable"
    ".agentrail/source/scripts/install-workflow|.agentrail/source/scripts/install-workflow|executable"
    ".agentrail/source/templates/scripts/memory|.agentrail/source/templates/scripts/memory|executable"
    ".agentrail/source/templates/scripts/ralph-loop|.agentrail/source/templates/scripts/ralph-loop|executable"
    ".agentrail/source/templates/scripts/afk-workflow|.agentrail/source/templates/scripts/afk-workflow|executable"
    ".agentrail/source/templates/scripts/review-pr|.agentrail/source/templates/scripts/review-pr|executable"
    ".agentrail/source/templates/scripts/pr|.agentrail/source/templates/scripts/pr|executable"
    "docs/agents/skill-registry.json|docs/agents/skill-registry.json|file"
  )

  local item label path kind
  for item in "${required_paths[@]}"; do
    IFS="|" read -r label path kind <<<"$item"
    if ! print_path_status "$target_dir" "$label" "$path" "$kind"; then
      required_missing=1
    fi
  done

  if [[ -f "${target_dir}/TASTE.md" ]]; then
    echo "  ok TASTE.md"
  else
    echo "  optional-missing TASTE.md"
  fi

  echo "state:"
  if [[ "$state_status" == "ok" ]]; then
    echo "  ok .agentrail/state.json"
  elif [[ "$state_status" == "invalid" ]]; then
    echo "  error invalid .agentrail/state.json"
  else
    echo "  missing .agentrail/state.json"
  fi

  if [[ "$version_status" == "outdated" ]]; then
    echo "  warn AgentRail version differs from current package"
  elif [[ "$version_status" == "ok" ]]; then
    echo "  ok AgentRail version"
  fi

  while IFS= read -r line; do
    case "$line" in
      HASHES=ok)
        echo "  ok managed hashes match"
        ;;
      SOURCE_HASHES=ok)
        echo "  ok current package hashes match"
        ;;
      HASH_MISMATCH=*)
        echo "  warn hash mismatch: ${line#HASH_MISMATCH=}"
        ;;
      SOURCE_MISMATCH=*)
        echo "  warn current package mismatch: ${line#SOURCE_MISMATCH=}"
        ;;
      SOURCE=missing:*)
        echo "  warn missing AgentRail source package: ${line#SOURCE=missing:}"
        ;;
      MISSING_MANAGED=*)
        echo "  warn missing managed file: ${line#MISSING_MANAGED=}"
        ;;
      STATE=invalid:*)
        echo "  error ${line#STATE=invalid:}"
        ;;
      STATE_SHAPE=invalid:*)
        echo "  error ${line#STATE_SHAPE=invalid:}"
        ;;
    esac
  done <<<"$hash_output"

  echo "legacy scripts:"
  local -a raw_script_paths=(
    "scripts/memory"
    "scripts/afk-workflow"
    "scripts/pr"
    "scripts/ralph-loop"
    "scripts/review-pr"
  )
  local raw_script found_scripts=()
  for raw_script in "${raw_script_paths[@]}"; do
    if [[ -e "${target_dir}/${raw_script}" ]]; then
      found_scripts+=("$raw_script")
    fi
  done
  if [[ "${#found_scripts[@]}" -gt 0 ]]; then
    legacy_scripts=1
    echo "  warn legacy raw workflow scripts present: ${found_scripts[*]}"
  else
    echo "  ok no raw workflow scripts in normal project surface"
  fi

  echo "skills:"
  if [[ "$state_status" == "missing" ]]; then
    echo "  skipped no AgentRail install"
  elif [[ "$registry_invalid" -eq 1 ]]; then
    while IFS= read -r line; do
      case "$line" in
        SKILL_REGISTRY_ERROR=*)
          echo "  error ${line#SKILL_REGISTRY_ERROR=}"
          ;;
      esac
    done <<<"$registry_output"
  else
    echo "  ok skill registry"
  fi

  echo "dashboard:"
  if has_api_key "$target_dir"; then
    echo "  ok AGENTRAIL_API_KEY configured"
  else
    echo "  info AGENTRAIL_API_KEY not configured (local-only mode — dashboard features disabled)"
  fi

  check_github_labels "$target_dir"

  echo "recommendations:"
  if [[ "$invalid_state" -eq 1 ]]; then
    echo "  - repair or remove .agentrail/state.json, then rerun install"
  elif [[ "$registry_invalid" -eq 1 ]]; then
    echo "  - repair docs/agents/skill-registry.json or rerun agentrail upgrade --target ${target_dir}"
  elif [[ "$legacy_scripts" -eq 1 ]]; then
    echo "  - remove legacy raw workflow scripts after confirming local edits; use agentrail commands or .agentrail/source for compatibility"
  elif [[ "$state_status" == "missing" || "$required_missing" -eq 1 || "$hidden_source_missing" -eq 1 ]]; then
    echo "  - run agentrail install --target ${target_dir}"
  elif [[ "$hash_mismatch" -eq 1 || "$source_mismatch" -eq 1 || "$missing_managed" -eq 1 || "$version_status" == "outdated" ]]; then
    echo "  - run agentrail install --target ${target_dir} --force after reviewing local edits"
  else
    echo "  - no blocking action"
  fi

  if [[ "$invalid_state" -eq 1 || "$registry_invalid" -eq 1 ]]; then
    exit 1
  fi
}

parse_prompt_options() {
  local agent_var="$1"
  local target_var="$2"
  local auto_var="$3"
  local explicit_var="$4"
  shift 4

  printf -v "$agent_var" '%s' "codex"
  printf -v "$target_var" '%s' "$(pwd)"
  printf -v "$auto_var" '%s' "1"
  printf -v "$explicit_var" '%s' ""
  while [[ $# -gt 0 ]]; do
    case "$1" in
      --agent)
        local value="${2:-}"
        [[ -n "$value" && "$value" != --* ]] || { echo "--agent requires codex or claude" >&2; exit 2; }
        [[ "$value" == "codex" || "$value" == "claude" ]] || { echo "--agent must be codex or claude" >&2; exit 2; }
        printf -v "$agent_var" '%s' "$value"
        shift 2
        ;;
      --target)
        local value="${2:-}"
        [[ -n "$value" && "$value" != --* ]] || { echo "--target requires a directory" >&2; exit 2; }
        printf -v "$target_var" '%s' "$value"
        shift 2
        ;;
      --skill)
        local value="${2:-}"
        [[ -n "$value" && "$value" != --* ]] || { echo "--skill requires a skill name" >&2; exit 2; }
        if [[ -n "${!explicit_var}" ]]; then
          printf -v "$explicit_var" '%s' "${!explicit_var}"$'\n'"$value"
        else
          printf -v "$explicit_var" '%s' "$value"
        fi
        shift 2
        ;;
      --no-auto-skills)
        printf -v "$auto_var" '%s' "0"
        shift
        ;;
      -h|--help)
        usage
        exit 0
        ;;
      *)
        echo "Unknown option: $1" >&2
        usage >&2
        exit 2
        ;;
    esac
  done
}

print_state_summary() {
  local target_dir="$1"
  local state_file="${target_dir}/.agentrail/state.json"

  [[ -f "$state_file" ]] || return 0
  command -v node >/dev/null 2>&1 || { echo "- AgentRail state exists at .agentrail/state.json, but node is not available to summarize it."; return 0; }

  node - "$state_file" "$target_dir" <<'NODE'
const fs = require("fs");
const path = require("path");
const statePath = process.argv[2];
const targetDir = process.argv[3];

try {
  const state = JSON.parse(fs.readFileSync(statePath, "utf8"));
  const workflow = state.workflow || {};
  const activeRun = workflow.activeRun;
  const completedRuns = Array.isArray(workflow.completedRuns) ? workflow.completedRuns : [];
  const worktrees = Array.isArray(workflow.worktrees) ? workflow.worktrees : [];
  const goals = Array.isArray(workflow.goals) ? workflow.goals : [];
  function runLabel(run) {
    if (!run || typeof run !== "object") return "none";
    const target = run.targetType === "issue" ? `issue #${run.targetIssue}` : (run.targetType || "target");
    return `${target} via ${run.agent || "unknown"} (${run.status || "unknown"})`;
  }
  function attemptSummary(run) {
    if (!run || typeof run !== "object" || !run.maxExecutionAttempts) return null;
    return `attempts: ${Number(run.executionAttempt || 0)}/${run.maxExecutionAttempts}; failed verify attempts: ${Number(run.failedVerificationAttempts || 0)}`;
  }
  function staleSummary(run) {
    if (!run || typeof run !== "object" || !run.runDir) return null;
    const runDir = path.isAbsolute(run.runDir) ? run.runDir : path.join(targetDir, run.runDir);
    return fs.existsSync(runDir) ? null : `run dir missing: ${run.runDir}`;
  }
  function goalLabel(goal) {
    const issue = goal.activeIssue ? ` issue #${goal.activeIssue}` : "";
    const summary = goal.summary || goal.source || goal.id || "goal";
    return `${goal.id || "goal"} ${goal.status || "unknown"}${issue}: ${summary}`;
  }
  console.log("- AgentRail state: present");
  console.log(`- version: ${state.agentrailVersion || "unknown"}`);
  console.log(`- phase: ${workflow.phase || "unknown"}`);
  console.log(`- active phase: ${workflow.activePhase ?? "none"}`);
  console.log(`- active issue: ${workflow.activeIssue ?? "none"}`);
  console.log(`- active pull request: ${workflow.activePullRequest ?? "none"}`);
  console.log(`- active PRD: ${workflow.activePrd ?? "none"}`);
  console.log(`- active milestone: ${workflow.activeMilestone ?? "none"}`);
  console.log(`- active run: ${activeRun ? runLabel(activeRun) : "none"}`);
  const activeGoals = goals.filter((goal) => goal && goal.status === "active");
  if (activeGoals.length > 0) {
    console.log("- active goals:");
    for (const goal of activeGoals.slice(0, 5)) console.log(`  - ${goalLabel(goal)}`);
  }
  const activeAttempts = attemptSummary(activeRun);
  if (activeAttempts) console.log(`- active run ${activeAttempts}`);
  const activeStale = staleSummary(activeRun);
  if (activeStale) console.log(`- active run stale: ${activeStale}`);
  if (completedRuns.length > 0) {
    const lastRun = completedRuns[completedRuns.length - 1];
    console.log(`- last completed run: ${runLabel(lastRun)}`);
    const lastAttempts = attemptSummary(lastRun);
    if (lastAttempts) console.log(`- last completed run ${lastAttempts}`);
    if (lastRun.blockedReason) console.log(`- last completed run blocked reason: ${lastRun.blockedReason}`);
  }
  if (worktrees.length > 0) {
    const activeWorktrees = worktrees.filter((worktree) => worktree && !worktree.removedAt);
    console.log(`- AgentRail worktrees: ${activeWorktrees.length} active / ${worktrees.length} tracked`);
  }
  console.log(`- last completed step: ${workflow.lastCompletedStep ?? "none"}`);
  console.log(`- next suggested action: ${workflow.nextSuggestedAction || "none"}`);
} catch (error) {
  console.log("- AgentRail state: present but unreadable");
  console.log(`- state error: ${error.message}`);
}
NODE
}

issue_resolution_text() {
  local target_dir="$1"
  local issue="$2"

  if command -v gh >/dev/null 2>&1 && remote_is_github "$target_dir"; then
    local issue_text
    if issue_text="$(cd "$target_dir" && gh issue view "$issue" --json title,body --jq '.title + "\n" + (.body // "")' 2>/dev/null)"; then
      printf '%s\n' "$issue_text"
      return 0
    fi
  fi

  printf 'GitHub issue #%s\n' "$issue"
}

parse_resume_args() {
  local target_var="$1"
  local output_var="$2"
  shift 2

  printf -v "$target_var" '%s' "$(pwd)"
  printf -v "$output_var" '%s' ""
  while [[ $# -gt 0 ]]; do
    case "$1" in
      --target)
        local value="${2:-}"
        [[ -n "$value" && "$value" != --* ]] || { echo "--target requires a directory" >&2; exit 2; }
        printf -v "$target_var" '%s' "$value"
        shift 2
        ;;
      --output)
        local value="${2:-}"
        [[ -n "$value" && "$value" != --* ]] || { echo "--output requires a file path" >&2; exit 2; }
        printf -v "$output_var" '%s' "$value"
        shift 2
        ;;
      -h|--help)
        usage
        exit 0
        ;;
      *)
        echo "Unknown option: $1" >&2
        usage >&2
        exit 2
        ;;
    esac
  done
}

state_recommendation() {
  local target_dir="$1"

  cat <<RECOMMENDATION
AgentRail state was not found at .agentrail/state.json.

Recommendation:
- If this repo has not been initialized, run: agentrail init --target ${target_dir}
- If this repo already has AgentRail files but no state, run: agentrail install --target ${target_dir}
- Then rerun: agentrail status --target ${target_dir}
RECOMMENDATION
}

run_status() {
  local target_dir
  parse_target target_dir "$@"

  local state_file="${target_dir}/.agentrail/state.json"
  echo "AgentRail status: ${target_dir}"

  if [[ ! -f "$state_file" ]]; then
    echo "install status: missing-state"
    echo
    state_recommendation "$target_dir"
    return 0
  fi

  command -v node >/dev/null 2>&1 || { echo "install status: unknown"; echo "node is required to read .agentrail/state.json"; return 1; }

  node - "$state_file" "$target_dir" <<'NODE'
const fs = require("fs");
const path = require("path");
const statePath = process.argv[2];
const targetDir = process.argv[3];

try {
  const state = JSON.parse(fs.readFileSync(statePath, "utf8"));
  const workflow = state.workflow || {};
  const activeRun = workflow.activeRun;
  const completedRuns = Array.isArray(workflow.completedRuns) ? workflow.completedRuns : [];
  const worktrees = Array.isArray(workflow.worktrees) ? workflow.worktrees : [];
  const goals = Array.isArray(workflow.goals) ? workflow.goals : [];
  function runLabel(run) {
    if (!run || typeof run !== "object") return "none";
    const target = run.targetType === "issue" ? `issue #${run.targetIssue}` : (run.targetType || "target");
    return `${target} via ${run.agent || "unknown"} (${run.status || "unknown"})`;
  }
  function attemptSummary(run) {
    if (!run || typeof run !== "object" || !run.maxExecutionAttempts) return null;
    return `attempts: ${Number(run.executionAttempt || 0)}/${run.maxExecutionAttempts}; failed verify attempts: ${Number(run.failedVerificationAttempts || 0)}`;
  }
  function staleSummary(run) {
    if (!run || typeof run !== "object" || !run.runDir) return null;
    const runDir = path.isAbsolute(run.runDir) ? run.runDir : path.join(targetDir, run.runDir);
    return fs.existsSync(runDir) ? null : `run dir missing: ${run.runDir}`;
  }
  function goalLabel(goal) {
    const issue = goal.activeIssue ? ` issue #${goal.activeIssue}` : "";
    const summary = goal.summary || goal.source || goal.id || "goal";
    return `${goal.id || "goal"} ${goal.status || "unknown"}${issue}: ${summary}`;
  }
  console.log("install status: state-present");
  console.log(`agentrail version: ${state.agentrailVersion || "unknown"}`);
  console.log(`installed at: ${state.installedAt || "unknown"}`);
  console.log(`updated at: ${state.updatedAt || "unknown"}`);
  console.log(`legacy adopted: ${Boolean(state.legacyAdopted)}`);
  console.log("workflow:");
  console.log(`  phase: ${workflow.phase || "unknown"}`);
  console.log(`  active phase: ${workflow.activePhase ?? "none"}`);
  console.log(`  active issue: ${workflow.activeIssue ?? "none"}`);
  console.log(`  active pull request: ${workflow.activePullRequest ?? "none"}`);
  console.log(`  active PRD: ${workflow.activePrd ?? "none"}`);
  console.log(`  active milestone: ${workflow.activeMilestone ?? "none"}`);
  console.log(`  active run: ${activeRun ? runLabel(activeRun) : "none"}`);
  const activeGoals = goals.filter((goal) => goal && goal.status === "active");
  if (activeGoals.length > 0) {
    console.log("  active goals:");
    for (const goal of activeGoals.slice(0, 5)) console.log(`    ${goalLabel(goal)}`);
  }
  const activeAttempts = attemptSummary(activeRun);
  if (activeAttempts) console.log(`  active run ${activeAttempts}`);
  const activeStale = staleSummary(activeRun);
  if (activeStale) console.log(`  active run stale: ${activeStale}`);
  if (completedRuns.length > 0) {
    const recentRuns = completedRuns.slice(-5);
    for (const run of recentRuns) {
      console.log(`  completed run: ${runLabel(run)}`);
      const attempts = attemptSummary(run);
      if (attempts) console.log(`  completed run ${attempts}`);
      if (run.blockedReason) console.log(`  completed run blocked reason: ${run.blockedReason}`);
    }
  }
  if (worktrees.length > 0) {
    console.log("  worktrees:");
    for (const worktree of worktrees.slice(-5)) {
      if (!worktree || typeof worktree !== "object") continue;
      const target = worktree.issue ? `issue #${worktree.issue}` : "issue";
      const pr = worktree.pr ? ` PR #${worktree.pr}` : "";
      const removed = worktree.removedAt ? ` removed ${worktree.removedAt}` : "";
      console.log(`    ${target}${pr}: ${worktree.status || "unknown"} ${worktree.path || worktree.absolutePath || "unknown"}${removed}`);
    }
  }
  console.log(`  last completed step: ${workflow.lastCompletedStep ?? "none"}`);
  console.log(`  next action: ${workflow.nextSuggestedAction || "none"}`);
} catch (error) {
  console.log("install status: corrupt-state");
  console.log(`state error: ${error.message}`);
  process.exitCode = 1;
}
NODE

  echo "dashboard:"
  if has_api_key "$target_dir"; then
    echo "  connected (AGENTRAIL_API_KEY)"
  else
    echo "  not configured (local-only mode)"
  fi
}

run_console() {
  local target_dir
  parse_target target_dir "$@"

  if has_api_key "$target_dir"; then
    echo "AgentRail Console"
    echo "API key: configured"
    echo ""
    echo "Dashboard: https://agentrail.dev/console"
    echo ""
    echo "Features available:"
    echo "  - Agent Operations Console — visibility across repos, workspaces, teams"
    echo "  - Run event streaming — runs send events to the server during execution"
    echo "  - Cross-repo analytics — failure pattern detection, cost attribution"
    echo "  - Context pack audit trails — what was included/excluded and why"
    echo "  - Review gate management — server-side review gate status"
    echo "  - Team collaboration — teams, API key management, workspace sharing"
  else
    echo "AgentRail Console"
    echo ""
    echo "The console gives you visibility into your agent runs across repos and teams."
    echo ""
    echo "Features:"
    echo "  - Agent Operations Console — visibility across repos, workspaces, teams"
    echo "  - Run event streaming — runs send events to the server during execution"
    echo "  - Cross-repo analytics — failure pattern detection, cost attribution"
    echo "  - Context pack audit trails — what was included/excluded and why"
    echo "  - Review gate management — server-side review gate status"
    echo "  - Team collaboration — teams, API key management, workspace sharing"
    echo ""
    echo "To get started:"
    echo "  1. Sign up at https://agentrail.dev"
    echo "  2. Create a workspace and generate an API key"
    echo "  3. Set it in your environment:"
    echo "     export AGENTRAIL_API_KEY=your-key-here"
    echo "  4. Or add it to .agentrail/config.json:"
    echo '     { "apiKey": "your-key-here" }'
    echo ""
    echo "Your local CLI works fully without an API key."
    echo "The console adds visibility and collaboration on top."
  fi
}

parse_cleanup_args() {
  local target_var="$1"
  local dry_run_var="$2"
  local merged_var="$3"
  local force_var="$4"
  shift 4

  printf -v "$target_var" '%s' "$(pwd)"
  printf -v "$dry_run_var" '%s' "0"
  printf -v "$merged_var" '%s' "0"
  printf -v "$force_var" '%s' "0"
  while [[ $# -gt 0 ]]; do
    case "$1" in
      --target)
        local value="${2:-}"
        [[ -n "$value" && "$value" != --* ]] || { echo "--target requires a directory" >&2; exit 2; }
        printf -v "$target_var" '%s' "$value"
        shift 2
        ;;
      --dry-run)
        printf -v "$dry_run_var" '%s' "1"
        shift
        ;;
      --merged)
        printf -v "$merged_var" '%s' "1"
        shift
        ;;
      --force)
        printf -v "$force_var" '%s' "1"
        shift
        ;;
      -h|--help)
        usage
        exit 0
        ;;
      *)
        echo "Unknown option: $1" >&2
        usage >&2
        exit 2
        ;;
    esac
  done
}

run_cleanup() {
  local target_dir dry_run merged force
  parse_cleanup_args target_dir dry_run merged force "$@"
  target_dir="$(cd "$target_dir" && pwd -P)" || exit 1

  local state_file="${target_dir}/.agentrail/state.json"
  [[ -f "$state_file" ]] || { state_recommendation "$target_dir" >&2; exit 1; }
  command -v node >/dev/null 2>&1 || { echo "node is required to read .agentrail/state.json" >&2; exit 1; }
  command -v git >/dev/null 2>&1 || { echo "git is required for worktree cleanup" >&2; exit 1; }
  [[ "$merged" == "1" || "$dry_run" == "1" ]] || { echo "cleanup requires --dry-run or --merged" >&2; exit 2; }

  git -C "$target_dir" worktree prune

  local candidates_file removed_file
  candidates_file="$(mktemp)"
  removed_file="$(mktemp)"
  cleanup_cleanup_temp() {
    rm -f "$candidates_file" "$removed_file"
  }
  trap cleanup_cleanup_temp RETURN

  AGENTRAIL_TARGET_DIR="$target_dir" \
  AGENTRAIL_CLEANUP_MERGED="$merged" \
  node <<'NODE' >"$candidates_file"
const fs = require("fs");
const path = require("path");

const targetDir = process.env.AGENTRAIL_TARGET_DIR;
const statePath = path.join(targetDir, ".agentrail/state.json");
const mergedOnly = process.env.AGENTRAIL_CLEANUP_MERGED === "1";
const state = JSON.parse(fs.readFileSync(statePath, "utf8"));
const workflow = state.workflow && typeof state.workflow === "object" ? state.workflow : {};
const worktrees = Array.isArray(workflow.worktrees) ? workflow.worktrees : [];

for (const worktree of worktrees) {
  if (!worktree || typeof worktree !== "object") continue;
  if (worktree.removedAt) continue;
  if (mergedOnly && worktree.status !== "merged") continue;
  const storedPath = worktree.path || worktree.worktreePath;
  if (!storedPath) continue;
  const absolutePath = path.isAbsolute(storedPath) ? storedPath : path.join(targetDir, storedPath);
  console.log(JSON.stringify({
    id: worktree.id || "",
    issue: worktree.issue ?? worktree.targetIssue ?? "",
    pr: worktree.pr ?? worktree.pullRequest ?? "",
    status: worktree.status || "unknown",
    path: absolutePath,
  }));
}
NODE

  echo "AgentRail cleanup: ${target_dir}"
  if [[ ! -s "$candidates_file" ]]; then
    echo "No matching AgentRail-owned worktrees found."
    return 0
  fi

  local line path status issue pr dirty
  while IFS= read -r line; do
    [[ -n "$line" ]] || continue
    path="$(node -e 'const item=JSON.parse(process.argv[1]); console.log(item.path)' "$line")"
    status="$(node -e 'const item=JSON.parse(process.argv[1]); console.log(item.status)' "$line")"
    issue="$(node -e 'const item=JSON.parse(process.argv[1]); console.log(item.issue)' "$line")"
    pr="$(node -e 'const item=JSON.parse(process.argv[1]); console.log(item.pr)' "$line")"

    local label="worktree"
    [[ -z "$issue" ]] || label="${label} issue #${issue}"
    [[ -z "$pr" ]] || label="${label} PR #${pr}"
    echo "${label}: ${path} (${status})"

    if [[ "$dry_run" == "1" ]]; then
      if [[ -d "$path" ]]; then
        dirty="$(git -C "$path" status --porcelain 2>/dev/null || true)"
        [[ -z "$dirty" ]] || echo "  dirty: would skip without --force"
      else
        echo "  missing: stale state entry"
      fi
      continue
    fi

    [[ "$status" == "merged" ]] || { echo "  skip: not merged"; continue; }

    if [[ -d "$path" ]]; then
      dirty="$(git -C "$path" status --porcelain 2>/dev/null || true)"
      if [[ -n "$dirty" && "$force" != "1" ]]; then
        echo "  skip: uncommitted changes; rerun with --force to remove"
        continue
      fi
      if [[ "$force" == "1" ]]; then
        git -C "$target_dir" worktree remove --force "$path"
      else
        git -C "$target_dir" worktree remove "$path"
      fi
      echo "  removed"
    else
      echo "  already missing"
    fi

    printf '%s\n' "$path" >>"$removed_file"
  done <"$candidates_file"

  [[ -s "$removed_file" ]] || return 0

  AGENTRAIL_TARGET_DIR="$target_dir" \
  AGENTRAIL_REMOVED_WORKTREES_FILE="$removed_file" \
  node <<'NODE'
const fs = require("fs");
const path = require("path");

const targetDir = process.env.AGENTRAIL_TARGET_DIR;
const statePath = path.join(targetDir, ".agentrail/state.json");
const removed = new Set(
  fs.readFileSync(process.env.AGENTRAIL_REMOVED_WORKTREES_FILE, "utf8")
    .split(/\r?\n/)
    .filter(Boolean)
    .map((entry) => path.resolve(entry))
);
const state = JSON.parse(fs.readFileSync(statePath, "utf8"));
const workflow = state.workflow && typeof state.workflow === "object" ? state.workflow : {};
const now = new Date().toISOString();
workflow.worktrees = (Array.isArray(workflow.worktrees) ? workflow.worktrees : []).map((worktree) => {
  if (!worktree || typeof worktree !== "object") return worktree;
  const storedPath = worktree.path || worktree.worktreePath;
  if (!storedPath) return worktree;
  const absolutePath = path.resolve(path.isAbsolute(storedPath) ? storedPath : path.join(targetDir, storedPath));
  if (!removed.has(absolutePath)) return worktree;
  return {
    ...worktree,
    removedAt: now,
    cleanupStatus: "removed",
  };
});
state.workflow = workflow;
state.updatedAt = now;
fs.writeFileSync(statePath, `${JSON.stringify(state, null, 2)}\n`);
NODE
}

run_memory() {
  local kind="${1:-}"
  [[ -n "$kind" ]] || { usage; exit 1; }
  shift || true

  local target_dir="$(pwd)"
  local args=()
  while [[ $# -gt 0 ]]; do
    case "$1" in
      --target)
        local value="${2:-}"
        [[ -n "$value" && "$value" != --* ]] || { echo "--target requires a directory" >&2; exit 2; }
        target_dir="$value"
        shift 2
        ;;
      -h|--help)
        usage
        exit 0
        ;;
      *)
        args+=("$1")
        shift
        ;;
    esac
  done

  local memory_script="${repo_dir}/templates/scripts/memory"
  [[ -x "$memory_script" ]] || { echo "missing internal memory helper: ${memory_script}" >&2; exit 1; }

  (
    cd "$target_dir"
    "$memory_script" "$kind" "${args[@]}"
  )
}

resume_body() {
  local target_dir="$1"
  local state_file="${target_dir}/.agentrail/state.json"

  if [[ ! -f "$state_file" ]]; then
    cat <<RESUME
# AgentRail Resume

Codex Desktop instruction: do not rely on previous chat context. Recover from durable state and source files only.

$(state_recommendation "$target_dir")

Relevant docs to inspect after initialization:
- CONTEXT.md
- TASTE.md when present
- docs/agents/
- docs/memory/
- docs/prd/
- docs/milestones/

Verification commands:
- agentrail doctor --target ${target_dir}
- npm test
RESUME
    return 0
  fi

  command -v node >/dev/null 2>&1 || { echo "node is required to read .agentrail/state.json" >&2; return 1; }

  node - "$state_file" "$target_dir" <<'NODE'
const fs = require("fs");
const path = require("path");
const statePath = process.argv[2];
const targetDir = process.argv[3];
const state = JSON.parse(fs.readFileSync(statePath, "utf8"));
const workflow = state.workflow || {};
const activeRun = workflow.activeRun;
const completedRuns = Array.isArray(workflow.completedRuns) ? workflow.completedRuns : [];
const goals = Array.isArray(workflow.goals) ? workflow.goals : [];
function runLabel(run) {
  if (!run || typeof run !== "object") return "none";
  const target = run.targetType === "issue" ? `issue #${run.targetIssue}` : (run.targetType || "target");
  return `${target} via ${run.agent || "unknown"} (${run.status || "unknown"})`;
}
function attemptSummary(run) {
  if (!run || typeof run !== "object" || !run.maxExecutionAttempts) return null;
  return `attempts: ${Number(run.executionAttempt || 0)}/${run.maxExecutionAttempts}; failed verify attempts: ${Number(run.failedVerificationAttempts || 0)}`;
}
function staleSummary(run) {
  if (!run || typeof run !== "object" || !run.runDir) return null;
  const runDir = path.isAbsolute(run.runDir) ? run.runDir : path.join(targetDir, run.runDir);
  return fs.existsSync(runDir) ? null : `run dir missing: ${run.runDir}`;
}
function activeContextPack(run) {
  if (!run || typeof run !== "object") return null;
  if (run.contextPackFile) return run.contextPackFile;
  if (!run.metadataFile) return null;
  const metadataPath = path.isAbsolute(run.metadataFile) ? run.metadataFile : path.join(targetDir, run.metadataFile);
  if (!fs.existsSync(metadataPath)) return null;
  try {
    const metadata = JSON.parse(fs.readFileSync(metadataPath, "utf8"));
    return metadata.contextPackFile || null;
  } catch {
    return null;
  }
}
function goalLabel(goal) {
  const issue = goal.activeIssue ? ` issue #${goal.activeIssue}` : "";
  const summary = goal.summary || goal.source || goal.id || "goal";
  return `${goal.id || "goal"} ${goal.status || "unknown"}${issue}: ${summary}`;
}

console.log("# AgentRail Resume");
console.log("");
console.log("Codex Desktop instruction: do not rely on previous chat context. Recover from durable state and source files only.");
console.log("");
console.log("Current task:");
console.log(`- workflow phase: ${workflow.phase || "unknown"}`);
console.log(`- active phase: ${workflow.activePhase ?? "none"}`);
console.log(`- active issue: ${workflow.activeIssue ?? "none"}`);
console.log(`- active pull request: ${workflow.activePullRequest ?? "none"}`);
console.log(`- active PRD: ${workflow.activePrd ?? "none"}`);
console.log(`- active milestone: ${workflow.activeMilestone ?? "none"}`);
console.log(`- active run: ${activeRun ? runLabel(activeRun) : "none"}`);
const contextPackFile = activeContextPack(activeRun);
if (contextPackFile) console.log(`- active context pack: ${contextPackFile}`);
const activeGoals = goals.filter((goal) => goal && goal.status === "active");
for (const goal of activeGoals.slice(0, 5)) {
  const successCount = Array.isArray(goal.successCriteria) ? goal.successCriteria.length : 0;
  console.log(`- active goal: ${goalLabel(goal)}`);
  console.log(`- active goal success criteria: ${successCount}`);
}
const activeAttempts = attemptSummary(activeRun);
if (activeAttempts) console.log(`- active run ${activeAttempts}`);
const activeStale = staleSummary(activeRun);
if (activeStale) console.log(`- active run stale: ${activeStale}`);
if (completedRuns.length > 0) {
  for (const run of completedRuns.slice(-5)) {
    console.log(`- completed run: ${runLabel(run)}`);
    const attempts = attemptSummary(run);
    if (attempts) console.log(`- completed run ${attempts}`);
    if (run.blockedReason) console.log(`- completed run blocked reason: ${run.blockedReason}`);
  }
}
console.log(`- last completed step: ${workflow.lastCompletedStep ?? "none"}`);
console.log(`- next action: ${workflow.nextSuggestedAction || "none"}`);
console.log("");
console.log("Relevant docs:");
console.log("- CONTEXT.md");
console.log("- TASTE.md when present");
console.log("- docs/agents/agentrail-state.md");
console.log("- docs/agents/issue-tracker.md");
console.log("- docs/agents/ralph-loop.md");
console.log("- docs/agents/pr-review.md");
console.log("- docs/memory/");
console.log("- docs/prd/");
console.log("- docs/milestones/");
console.log("");
console.log("Verification commands:");
console.log(`- agentrail status --target ${targetDir}`);
console.log(`- agentrail doctor --target ${targetDir}`);
console.log("- npm test");
console.log("");
console.log("Resume rules:");
console.log("- Read source files and GitHub issue or PR state before acting.");
console.log("- Treat this handoff as a pointer to durable state, not as hidden truth.");
console.log("- Continue only the active issue or PR unless the durable state says otherwise.");
NODE
}

run_resume() {
  local target_dir output_file
  parse_resume_args target_dir output_file "$@"

  local handoff_dir default_output
  handoff_dir="${target_dir}/.agentrail/handoffs"
  default_output="${handoff_dir}/$(date -u +"%Y%m%d-%H%M%S")-resume.md"
  if [[ -z "$output_file" ]]; then
    output_file="$default_output"
  fi

  mkdir -p "$(dirname "$output_file")"
  resume_body "$target_dir" | tee "$output_file"
  echo
  echo "handoff: ${output_file}"
}

prompt_common_header() {
  local agent="$1"
  local target_dir="$2"

  cat <<PROMPT
You are working in an AgentRail-managed repository.

Agent target: ${agent}

Read these before acting:
- CONTEXT.md
- TASTE.md when present
- relevant docs under docs/agents/
- relevant project memory from agentrail memory recall

Start with AgentRail CLI state:
- agentrail status
- agentrail resume

AgentRail state summary:
PROMPT
  if [[ -f "${target_dir}/.agentrail/state.json" ]]; then
    print_state_summary "$target_dir"
  else
    echo "- AgentRail state: not found at .agentrail/state.json"
  fi
  echo
}

build_context_pack_file() {
  local target_dir="$1"
  local target_kind="$2"
  local target_number="$3"
  local phase="$4"
  local python_bin="python3"
  local output

  if ! command -v "$python_bin" >/dev/null 2>&1; then
    python_bin="python"
  fi

  local tmpfile
  tmpfile="$(mktemp)"
  PYTHONPATH="${repo_dir}${PYTHONPATH:+:${PYTHONPATH}}" \
    "$python_bin" -m agentrail.cli.main context build "$target_kind" "$target_number" --phase "$phase" --target "$target_dir" --json >"$tmpfile"
  node - "$tmpfile" <<'NODE'
const fs = require("fs");
const parsed = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
console.log(parsed.jsonPath || "");
NODE
  rm -f "$tmpfile"
}

context_pack_summary() {
  local target_dir="$1"
  local pack_file="$2"

  if [[ -z "$pack_file" || ! -f "${target_dir}/${pack_file}" ]]; then
    cat <<SUMMARY
Context pack:
- Pack file: none
- Summary unavailable.
SUMMARY
    return 0
  fi

  node - "$target_dir" "$pack_file" <<'NODE'
const fs = require("fs");
const path = require("path");
const targetDir = process.argv[2];
const packFile = process.argv[3];
const pack = JSON.parse(fs.readFileSync(path.join(targetDir, packFile), "utf8"));
function count(key) {
  return Array.isArray(pack[key]) ? pack[key].length : 0;
}
function firstPaths(key, limit = 2) {
  const values = Array.isArray(pack[key]) ? pack[key] : [];
  const paths = values.map((item) => item.path || item.citation).filter(Boolean).slice(0, limit);
  return paths.length > 0 ? ` (${paths.join(", ")})` : "";
}
const target = pack.target || {};
console.log("Context pack:");
console.log(`- Pack file: ${packFile}`);
console.log(`- Target: ${target.kind || "target"} #${target.number ?? "unknown"} ${target.phase || ""}`.trim());
console.log(`- Goal: ${pack.goal?.summary || "No goal recorded."}`);
console.log(`- Required context: ${count("requiredContext")}${firstPaths("requiredContext")}`);
console.log(`- Likely files: ${count("likelyFiles")}${firstPaths("likelyFiles")}`);
console.log(`- Likely docs: ${count("likelyDocs")}${firstPaths("likelyDocs")}`);
console.log(`- Relevant memory: ${count("relevantMemory")}${firstPaths("relevantMemory")}`);
console.log(`- Prior mistakes: ${count("priorMistakes")}${firstPaths("priorMistakes")}`);
console.log(`- Active state: ${count("activeState")}${firstPaths("activeState")}`);
console.log(`- Goals: ${count("goals")}${firstPaths("goals")}`);
console.log(`- Open questions: ${count("openQuestions")}${firstPaths("openQuestions")}`);
console.log("- Use the selected context above before broad repo discovery; keep memory recall as an advisory check.");
NODE
}

context_pack_file_from_prompt() {
  awk -F': ' '/^- Pack file: / { print $2; exit }'
}

prompt_grill() {
  local agent="$1"
  local target_dir="$2"
  local idea="$3"

  prompt_common_header "$agent" "$target_dir"
  if [[ "$agent" == "codex" ]]; then
    cat <<PROMPT
Use the repo-local skill 'grill-with-docs'.

Goal:
Stress-test this idea before any PRD or implementation work:

${idea}

Instructions:
- Read CONTEXT.md first.
- Read TASTE.md if present.
- Run agentrail memory recall for the idea and key terms when available.
- Challenge vague users, outcomes, non-goals, constraints, domain terms, and risky assumptions.
- If a question can be answered from the repo, inspect the repo instead of asking.
- Ask one direct question at a time and include your recommended answer.
- Do not write implementation code.
PROMPT
  else
    cat <<PROMPT
Use Claude Code to run a grill-with-docs style planning pass. If skills/grill-with-docs/SKILL.md exists, read it and follow its workflow as local project instructions.

Goal:
Stress-test this idea before any PRD or implementation work:

${idea}

Instructions:
- Read CONTEXT.md first.
- Read TASTE.md if present.
- Run agentrail memory recall for the idea and key terms when available.
- Challenge vague users, outcomes, non-goals, constraints, domain terms, and risky assumptions.
- If a question can be answered from the repo, inspect the repo instead of asking.
- Ask one direct question at a time and include your recommended answer.
- Do not write implementation code.
PROMPT
  fi
}

prompt_issue() {
  local agent="$1"
  local target_dir="$2"
  local issue="$3"
  local auto_skills="${4:-1}"
  local explicit_skills="${5:-}"
  local resolution_text context_pack_file context_summary
  resolution_text="$(issue_resolution_text "$target_dir" "$issue")"

  resolve_skills_json "$target_dir" "$resolution_text" "$auto_skills" "$explicit_skills" >/dev/null
  context_pack_file="$(build_context_pack_file "$target_dir" "issue" "$issue" "plan")"
  context_summary="$(context_pack_summary "$target_dir" "$context_pack_file")"

  prompt_common_header "$agent" "$target_dir"
  print_skill_resolution "$target_dir" "$resolution_text" "$auto_skills" "$explicit_skills" "prompt"
  printf '%s\n\n' "$context_summary"
  if [[ "$agent" == "codex" ]]; then
    cat <<PROMPT
Run one bounded AgentRail issue execution for exactly one GitHub issue: #${issue}.

Use these local instructions:
- templates/docs/agents/ralph-loop.md when running from the AgentRail source repo
- docs/agents/ralph-loop.md when running from an installed target repo
- repo-local implementation skills such as tdd when they match the work

Hard limits:
- Handle only issue #${issue}.
- Read the issue body, comments, labels, and linked PRD or milestone before editing.
- Read CONTEXT.md, TASTE.md if present, and relevant project memory.
- Run agentrail memory recall for the issue title and key terms when available.
- If starting or resuming execution yourself, use agentrail run issue ${issue}; AgentRail invokes Ralph internally during the execute phase.
- Implement the smallest coherent change that satisfies the issue acceptance criteria.
- Run relevant verification.
- Open or update one PR linked to #${issue}.
- Include summary, acceptance criteria coverage, verification, visual evidence, memory updates, and risks in the PR body.
- Stop when the PR is ready or when blocked.
PROMPT
  else
    cat <<PROMPT
Use Claude Code through AgentRail to run one bounded implementation loop for exactly one GitHub issue: #${issue}.

Use these local instructions when present:
- templates/docs/agents/ralph-loop.md or docs/agents/ralph-loop.md
- repo-local TDD and workflow docs under skills/ and docs/agents/

Hard limits:
- Handle only issue #${issue}.
- Read the issue body, comments, labels, and linked PRD or milestone before editing.
- Read CONTEXT.md, TASTE.md if present, and relevant project memory.
- Run agentrail memory recall for the issue title and key terms when available.
- If starting or resuming execution yourself, use agentrail run issue ${issue}; AgentRail invokes Ralph internally during the execute phase.
- Implement the smallest coherent change that satisfies the issue acceptance criteria.
- Run relevant verification.
- Open or update one PR linked to #${issue}.
- Include summary, acceptance criteria coverage, verification, visual evidence, memory updates, and risks in the PR body.
- Stop when the PR is ready or when blocked.
PROMPT
  fi
}

prompt_review() {
  local agent="$1"
  local target_dir="$2"
  local pr="$3"
  local context_pack_file context_summary
  context_pack_file="$(build_context_pack_file "$target_dir" "pr" "$pr" "review")"
  context_summary="$(context_pack_summary "$target_dir" "$context_pack_file")"

  prompt_common_header "$agent" "$target_dir"
  printf '%s\n\n' "$context_summary"
  if [[ "$agent" == "codex" ]]; then
    cat <<PROMPT
Review exactly one pull request: #${pr}.

Use these local instructions:
- templates/docs/agents/pr-review.md when running from the AgentRail source repo
- docs/agents/pr-review.md when running from an installed target repo

Hard limits:
- Review only PR #${pr}.
- Compare the PR head branch against its base branch.
- Read the PR body, linked issue, milestone, PRD, CONTEXT.md, TASTE.md if present, and relevant project memory.
- Run agentrail memory recall for the PR title, linked issue, and key terms when available.
- If generating this review prompt outside the current session, use agentrail prompt review ${pr}.
- Inspect resolved skill evidence when available in the PR body or AgentRail run logs, including resolved-skills metadata; absence of this evidence does not mean the implementation is invalid.
- Do not edit files, commit, push, close, or merge anything.
- Return findings first, ordered by severity with concrete file and line references.
- Call out missing acceptance criteria coverage, missing verification, and missing visual evidence when relevant.
PROMPT
  else
    cat <<PROMPT
Use Claude Code to review exactly one pull request: #${pr}.

Use these local instructions when present:
- templates/docs/agents/pr-review.md or docs/agents/pr-review.md
- repo-local review and visual evidence docs under docs/agents/

Hard limits:
- Review only PR #${pr}.
- Compare the PR head branch against its base branch.
- Read the PR body, linked issue, milestone, PRD, CONTEXT.md, TASTE.md if present, and relevant project memory.
- Run agentrail memory recall for the PR title, linked issue, and key terms when available.
- If generating this review prompt outside the current session, use agentrail prompt review ${pr}.
- Inspect resolved skill evidence when available in the PR body or AgentRail run logs, including resolved-skills metadata; absence of this evidence does not mean the implementation is invalid.
- Do not edit files, commit, push, close, or merge anything.
- Return findings first, ordered by severity with concrete file and line references.
- Call out missing acceptance criteria coverage, missing verification, and missing visual evidence when relevant.
PROMPT
  fi
}

run_prompt() {
  local kind="${1:-}"
  [[ -n "$kind" ]] || { usage; exit 1; }
  shift || true

  local subject=""
  case "$kind" in
    grill|issue|review)
      subject="${1:-}"
      [[ -n "$subject" && "$subject" != --* ]] || { echo "prompt ${kind} requires an argument" >&2; exit 2; }
      shift
      ;;
    -h|--help)
      usage
      exit 0
      ;;
    *)
      echo "Unknown prompt type: $kind" >&2
      usage >&2
      exit 2
      ;;
  esac

  local agent target_dir auto_skills explicit_skills
  parse_prompt_options agent target_dir auto_skills explicit_skills "$@"

  case "$kind" in
    issue|review)
      [[ "$subject" =~ ^[0-9]+$ ]] || { echo "prompt ${kind} argument must be numeric" >&2; exit 2; }
      ;;
  esac

  case "$kind" in
    grill)
      prompt_grill "$agent" "$target_dir" "$subject"
      ;;
    issue)
      prompt_issue "$agent" "$target_dir" "$subject" "$auto_skills" "$explicit_skills"
      ;;
    review)
      prompt_review "$agent" "$target_dir" "$subject"
      ;;
  esac
}

run_afk() {
  case "${1:-}" in
    -h|--help|help)
      cat <<'USAGE'
Usage:
  agentrail afk [--concurrency 2] [--max-waves 20] [--base main] [--engine codex] [--afk-label afk] [--dry-run]

Runs the AFK queue/worktree loop through the AgentRail CLI:

1. Pick queued GitHub issues.
2. Claim each issue so another worker does not pick it.
3. Run one AgentRail issue execution per issue in an isolated git worktree.
4. Find the PR opened for the issue.
5. Review the PR in a fresh context.
6. Convert machine-readable review findings into new review-fix issues.
7. When review has no fix issues, prepare and merge the PR.
8. Repeat until the queue is empty or max waves is reached.

Defaults:
  --concurrency 2
  --max-waves 20
  --base main
  --engine codex
  --afk-label afk
  --queue-labels review-fix,ready-for-agent
USAGE
      exit 0
      ;;
  esac

  if is_agentrail_source_checkout "$(pwd -P)" && [[ "${AGENTRAIL_ALLOW_SOURCE_RUN:-0}" != "1" ]]; then
    local arg
    for arg in "$@"; do
      if [[ "$arg" == "--dry-run" ]]; then
        local afk_script="${repo_dir}/templates/scripts/afk-workflow"
        [[ -x "$afk_script" ]] || { echo "missing AFK queue/worktree loop: ${afk_script}" >&2; exit 2; }
        "$afk_script" run "$@"
        return
      fi
    done
    ensure_source_run_allowed "$(pwd -P)" "run AFK"
  fi

  local afk_script="${repo_dir}/templates/scripts/afk-workflow"
  [[ -x "$afk_script" ]] || { echo "missing AFK queue/worktree loop: ${afk_script}" >&2; exit 2; }
  "$afk_script" run "$@"
}

run_internal_review_pr() {
  local review_script="${repo_dir}/templates/scripts/review-pr"
  [[ -x "$review_script" ]] || { echo "missing internal review helper: ${review_script}" >&2; exit 2; }

  "$review_script" "$@"
}

run_internal_worktree() {
  local action="${1:-}"
  [[ -n "$action" ]] || { echo "internal worktree requires an action" >&2; exit 2; }
  shift || true

  local target_dir="$(pwd)"
  local worktree_path=""
  local status=""
  local issue=""
  local pr=""
  local run_dir=""
  local base=""
  local slot=""

  while [[ $# -gt 0 ]]; do
    case "$1" in
      --target)
        target_dir="${2:-}"
        [[ -n "$target_dir" && "$target_dir" != --* ]] || { echo "--target requires a directory" >&2; exit 2; }
        shift 2
        ;;
      --path)
        worktree_path="${2:-}"
        [[ -n "$worktree_path" && "$worktree_path" != --* ]] || { echo "--path requires a directory" >&2; exit 2; }
        shift 2
        ;;
      --status)
        status="${2:-}"
        [[ -n "$status" && "$status" != --* ]] || { echo "--status requires a lifecycle value" >&2; exit 2; }
        shift 2
        ;;
      --issue)
        issue="${2:-}"
        [[ -n "$issue" && "$issue" != --* ]] || { echo "--issue requires a number" >&2; exit 2; }
        shift 2
        ;;
      --pr)
        pr="${2:-}"
        [[ -n "$pr" && "$pr" != --* ]] || { echo "--pr requires a number" >&2; exit 2; }
        shift 2
        ;;
      --run-dir)
        run_dir="${2:-}"
        [[ -n "$run_dir" && "$run_dir" != --* ]] || { echo "--run-dir requires a directory" >&2; exit 2; }
        shift 2
        ;;
      --base)
        base="${2:-}"
        [[ -n "$base" && "$base" != --* ]] || { echo "--base requires a branch" >&2; exit 2; }
        shift 2
        ;;
      --slot)
        slot="${2:-}"
        [[ -n "$slot" && "$slot" != --* ]] || { echo "--slot requires a number" >&2; exit 2; }
        shift 2
        ;;
      *)
        echo "Unknown internal worktree option: $1" >&2
        exit 2
        ;;
    esac
  done

  [[ "$action" == "mark" ]] || { echo "unknown internal worktree action: $action" >&2; exit 2; }
  [[ -n "$worktree_path" ]] || { echo "internal worktree mark requires --path" >&2; exit 2; }
  [[ -n "$status" ]] || { echo "internal worktree mark requires --status" >&2; exit 2; }
  target_dir="$(cd "$target_dir" && pwd -P)" || exit 1
  if [[ "$worktree_path" != /* ]]; then
    worktree_path="${target_dir}/${worktree_path}"
  fi
  update_worktree_state "$target_dir" "$worktree_path" "$status" "$issue" "$pr" "$run_dir" "$base" "$slot"
}

run_internal() {
  local command="${1:-}"
  [[ -n "$command" ]] || { usage; exit 1; }
  shift || true

  case "$command" in
    review-pr)
      run_internal_review_pr "$@"
      ;;
    worktree)
      run_internal_worktree "$@"
      ;;
    -h|--help)
      usage
      exit 0
      ;;
    *)
      echo "Unknown internal command: $command" >&2
      usage >&2
      exit 2
      ;;
  esac
}

agent_command_env_name() {
  local agent="$1"
  case "$agent" in
    codex)
      echo "AGENTRAIL_CODEX_COMMAND"
      ;;
    claude)
      echo "AGENTRAIL_CLAUDE_COMMAND"
      ;;
    cursor)
      echo "AGENTRAIL_CURSOR_COMMAND"
      ;;
    hermes)
      echo "AGENTRAIL_HERMES_COMMAND"
      ;;
    custom)
      echo "AGENTRAIL_CUSTOM_COMMAND"
      ;;
  esac
}

portable_timeout() {
  local seconds="$1"
  shift
  if command -v timeout >/dev/null 2>&1; then
    timeout "$seconds" "$@"
    return $?
  fi
  "$@" &
  local pid=$!
  ( sleep "$seconds" && kill -TERM "$pid" 2>/dev/null ) &
  local watcher=$!
  wait "$pid" 2>/dev/null
  local exit_code=$?
  kill "$watcher" 2>/dev/null
  wait "$watcher" 2>/dev/null
  if [[ "$exit_code" -eq 143 ]]; then
    return 124
  fi
  return "$exit_code"
}

sanitized_agent_exec() {
  env \
    -u CLAUDECODE -u CLAUDE_CODE_SESSION_ID -u CLAUDE_CODE_ENTRYPOINT \
    -u CLAUDE_AGENT_SDK_VERSION -u CLAUDE_CODE_EXECPATH -u CLAUDE_EFFORT \
    -u AI_AGENT \
    -u CODEX_SESSION -u CODEX_SANDBOX \
    -u CURSOR_SESSION -u CURSOR_AGENT \
    "$@"
}

default_agent_command() {
  local agent="$1"
  case "$agent" in
    codex)
      echo "codex exec --sandbox danger-full-access -"
      ;;
    claude)
      echo "claude -p --dangerously-skip-permissions"
      ;;
    cursor)
      echo "cursor-agent -p"
      ;;
    hermes)
      echo "hermes -p"
      ;;
    custom)
      echo ""
      ;;
  esac
}

configured_agent_command() {
  local agent="$1"
  local explicit_command="$2"
  local target_dir="$3"

  if [[ -n "$explicit_command" ]]; then
    echo "$explicit_command"
    return
  fi

  if [[ -f "${target_dir}/.agentrail/config.json" && "$agent" == "__config__" ]]; then
    local configured_command
    configured_command="$(node - "${target_dir}/.agentrail/config.json" <<'NODE'
const fs = require("fs");
const config = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
console.log(config.runner?.command || "");
NODE
)"
    echo "$configured_command"
    return
  fi

  if [[ -f "${target_dir}/.agentrail/config.json" ]]; then
    local runners_command
    runners_command="$(AGENTRAIL_AGENT="$agent" node - "${target_dir}/.agentrail/config.json" <<'NODE'
const fs = require("fs");
const config = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
const agent = process.env.AGENTRAIL_AGENT;
const cmd = config.runners && config.runners[agent] && config.runners[agent].command;
console.log(cmd || "");
NODE
)"
    if [[ -n "$runners_command" ]]; then
      echo "$runners_command"
      return
    fi
  fi

  local env_name
  env_name="$(agent_command_env_name "$agent")"
  local agent_specific="${!env_name:-}"
  if [[ -n "$agent_specific" ]]; then
    echo "$agent_specific"
    return
  fi

  if [[ -n "${AGENTRAIL_AGENT_COMMAND:-}" ]]; then
    echo "$AGENTRAIL_AGENT_COMMAND"
    return
  fi

  default_agent_command "$agent"
}

configured_agent_name() {
  local target_dir="$1"
  local fallback="$2"

  if [[ "$fallback" != "__config__" ]]; then
    echo "$fallback"
    return
  fi

  if [[ -f "${target_dir}/.agentrail/config.json" ]]; then
    node - "${target_dir}/.agentrail/config.json" <<'NODE'
const fs = require("fs");
const config = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
console.log(config.runner?.name || "codex");
NODE
    return
  fi

  echo "codex"
}

ensure_command_available() {
  local command_line="$1"
  local binary

  # shellcheck disable=SC2086
  set -- $command_line
  binary="${1:-}"
  [[ -n "$binary" ]] || { echo "runner command is empty" >&2; exit 2; }
  command -v "$binary" >/dev/null 2>&1 || { echo "missing required command: $binary" >&2; exit 1; }
}

ralph_executor_path() {
  local target_dir="$1"
  local absolute_target_dir

  absolute_target_dir="$(cd "$target_dir" && pwd -P)" || return 1

  if [[ -x "${absolute_target_dir}/.agentrail/source/templates/scripts/ralph-loop" ]]; then
    printf '%s\n' "${absolute_target_dir}/.agentrail/source/templates/scripts/ralph-loop"
    return 0
  fi

  if [[ -x "${repo_dir}/templates/scripts/ralph-loop" ]]; then
    printf '%s\n' "${repo_dir}/templates/scripts/ralph-loop"
    return 0
  fi

  if [[ -x "${absolute_target_dir}/scripts/ralph-loop" ]]; then
    printf '%s\n' "${absolute_target_dir}/scripts/ralph-loop"
    return 0
  fi

  echo "missing Ralph executor: expected .agentrail/source/templates/scripts/ralph-loop in target or templates/scripts/ralph-loop in AgentRail source" >&2
  return 1
}

parse_run_options() {
  local agent_var="$1"
  local target_var="$2"
  local command_var="$3"
  local log_dir_var="$4"
  shift 4

  printf -v "$agent_var" '%s' "__config__"
  printf -v "$target_var" '%s' "$(pwd)"
  printf -v "$command_var" '%s' ""
  printf -v "$log_dir_var" '%s' ""
  while [[ $# -gt 0 ]]; do
    case "$1" in
      --agent)
        local value="${2:-}"
        [[ -n "$value" && "$value" != --* ]] || { echo "--agent requires codex, claude, cursor, hermes, or custom" >&2; exit 2; }
        [[ "$value" == "codex" || "$value" == "claude" || "$value" == "cursor" || "$value" == "hermes" || "$value" == "custom" ]] || { echo "--agent must be codex, claude, cursor, hermes, or custom" >&2; exit 2; }
        printf -v "$agent_var" '%s' "$value"
        shift 2
        ;;
      --target)
        local value="${2:-}"
        [[ -n "$value" && "$value" != --* ]] || { echo "--target requires a directory" >&2; exit 2; }
        printf -v "$target_var" '%s' "$value"
        shift 2
        ;;
      --command)
        local value="${2:-}"
        [[ -n "$value" && "$value" != --* ]] || { echo "--command requires a command" >&2; exit 2; }
        printf -v "$command_var" '%s' "$value"
        shift 2
        ;;
      --log-dir)
        local value="${2:-}"
        [[ -n "$value" && "$value" != --* ]] || { echo "--log-dir requires a directory" >&2; exit 2; }
        printf -v "$log_dir_var" '%s' "$value"
        shift 2
        ;;
      -h|--help)
        usage
        exit 0
        ;;
      *)
        echo "Unknown option: $1" >&2
        usage >&2
        exit 2
        ;;
    esac
  done
}

write_run_metadata() {
  local metadata_file="$1"
  local started_at="$2"
  local issue="$3"
  local agent="$4"
  local command_line="$5"
  local prompt_file="$6"
  local resolved_skills_file="$7"
  local resolved_skills_json="$8"
  local max_execution_attempts="${9:-$agentrail_default_max_execution_attempts}"
  local context_pack_file="${10:-}"

  command -v node >/dev/null 2>&1 || {
    cat >"$metadata_file" <<METADATA
{
  "startedAt": "${started_at}",
  "targetType": "issue",
  "targetIssue": ${issue},
  "agent": "${agent}",
  "command": "${command_line}",
  "executionAttempt": 1,
  "maxExecutionAttempts": ${max_execution_attempts},
  "failedVerificationAttempts": 0,
  "promptFile": "${prompt_file}",
  "contextPackFile": "${context_pack_file}",
  "resolvedSkillsFile": "${resolved_skills_file}",
  "resolvedSkills": []
}
METADATA
    return
  }

  RUN_STARTED_AT="$started_at" \
  RUN_ISSUE="$issue" \
  RUN_AGENT="$agent" \
  RUN_COMMAND="$command_line" \
  RUN_MAX_EXECUTION_ATTEMPTS="$max_execution_attempts" \
  RUN_PROMPT_FILE="$prompt_file" \
  RUN_CONTEXT_PACK_FILE="$context_pack_file" \
  RUN_RESOLVED_SKILLS_FILE="$resolved_skills_file" \
  RUN_RESOLVED_SKILLS_JSON="$resolved_skills_json" \
  node >"$metadata_file" <<'NODE'
const resolution = JSON.parse(process.env.RUN_RESOLVED_SKILLS_JSON || "{}");
const metadata = {
  startedAt: process.env.RUN_STARTED_AT,
  targetType: "issue",
  targetIssue: Number(process.env.RUN_ISSUE),
  agent: process.env.RUN_AGENT,
  command: process.env.RUN_COMMAND,
  executionAttempt: 1,
  maxExecutionAttempts: Number(process.env.RUN_MAX_EXECUTION_ATTEMPTS || 5),
  failedVerificationAttempts: 0,
  promptFile: process.env.RUN_PROMPT_FILE,
  contextPackFile: process.env.RUN_CONTEXT_PACK_FILE || null,
  resolvedSkillsFile: process.env.RUN_RESOLVED_SKILLS_FILE,
  resolvedSkills: Array.isArray(resolution.resolved) ? resolution.resolved : [],
};
console.log(JSON.stringify(metadata, null, 2));
NODE
}

update_run_metadata_attempts() {
  local metadata_file="$1"
  local execution_attempt="$2"
  local max_execution_attempts="$3"
  local failed_verification_attempts="$4"
  local verifier_findings_file="${5:-}"
  local blocked_reason="${6:-}"

  [[ -f "$metadata_file" ]] || return 0
  command -v node >/dev/null 2>&1 || return 0

  RUN_METADATA_FILE="$metadata_file" \
  RUN_EXECUTION_ATTEMPT="$execution_attempt" \
  RUN_MAX_EXECUTION_ATTEMPTS="$max_execution_attempts" \
  RUN_FAILED_VERIFICATION_ATTEMPTS="$failed_verification_attempts" \
  RUN_VERIFIER_FINDINGS_FILE="$verifier_findings_file" \
  RUN_BLOCKED_REASON="$blocked_reason" \
  node <<'NODE'
const fs = require("fs");
const metadataPath = process.env.RUN_METADATA_FILE;
const metadata = JSON.parse(fs.readFileSync(metadataPath, "utf8"));
metadata.executionAttempt = Number(process.env.RUN_EXECUTION_ATTEMPT || 1);
metadata.maxExecutionAttempts = Number(process.env.RUN_MAX_EXECUTION_ATTEMPTS || 5);
metadata.failedVerificationAttempts = Number(process.env.RUN_FAILED_VERIFICATION_ATTEMPTS || 0);
if (process.env.RUN_VERIFIER_FINDINGS_FILE) {
  metadata.verifierFindingsFile = process.env.RUN_VERIFIER_FINDINGS_FILE;
}
if (process.env.RUN_BLOCKED_REASON) {
  metadata.blockedReason = process.env.RUN_BLOCKED_REASON;
}
fs.writeFileSync(metadataPath, `${JSON.stringify(metadata, null, 2)}\n`);
NODE
}

write_phase_status() {
  local status_file="$1"
  local phase="$2"
  local status="$3"
  local started_at="$4"
  local finished_at="$5"
  local exit_status="$6"
  local metadata_file="$7"
  local output_file="$8"
  local execution_attempt="${9:-}"
  local max_execution_attempts="${10:-}"
  local verifier_findings_file="${11:-}"

  PHASE_NAME="$phase" \
  PHASE_STATUS="$status" \
  PHASE_STARTED_AT="$started_at" \
  PHASE_FINISHED_AT="$finished_at" \
  PHASE_EXIT_STATUS="$exit_status" \
  PHASE_METADATA_FILE="$metadata_file" \
  PHASE_OUTPUT_FILE="$output_file" \
  PHASE_EXECUTION_ATTEMPT="$execution_attempt" \
  PHASE_MAX_EXECUTION_ATTEMPTS="$max_execution_attempts" \
  PHASE_VERIFIER_FINDINGS_FILE="$verifier_findings_file" \
  node >"$status_file" <<'NODE'
const status = {
  phase: process.env.PHASE_NAME,
  status: process.env.PHASE_STATUS,
  startedAt: process.env.PHASE_STARTED_AT,
  finishedAt: process.env.PHASE_FINISHED_AT || null,
  exitStatus: Number(process.env.PHASE_EXIT_STATUS || 0),
  metadataFile: process.env.PHASE_METADATA_FILE,
  outputFile: process.env.PHASE_OUTPUT_FILE,
};
if (process.env.PHASE_EXECUTION_ATTEMPT) status.executionAttempt = Number(process.env.PHASE_EXECUTION_ATTEMPT);
if (process.env.PHASE_MAX_EXECUTION_ATTEMPTS) status.maxExecutionAttempts = Number(process.env.PHASE_MAX_EXECUTION_ATTEMPTS);
if (process.env.PHASE_VERIFIER_FINDINGS_FILE) status.verifierFindingsFile = process.env.PHASE_VERIFIER_FINDINGS_FILE;
console.log(JSON.stringify(status, null, 2));
NODE
}

write_phase_metadata() {
  local metadata_file="$1"
  local phase="$2"
  local started_at="$3"
  local finished_at="$4"
  local status="$5"
  local exit_status="$6"
  local issue="$7"
  local agent="$8"
  local command_line="$9"
  local prompt_file="${10}"
  local output_file="${11}"
  local status_file="${12}"
  local run_id="${13}"
  local run_dir="${14}"
  local execution_attempt="${15:-}"
  local max_execution_attempts="${16:-}"
  local verifier_findings_file="${17:-}"
  local context_pack_file="${18:-}"

  PHASE_NAME="$phase" \
  PHASE_STARTED_AT="$started_at" \
  PHASE_FINISHED_AT="$finished_at" \
  PHASE_STATUS="$status" \
  PHASE_EXIT_STATUS="$exit_status" \
  PHASE_ISSUE="$issue" \
  PHASE_AGENT="$agent" \
  PHASE_COMMAND="$command_line" \
  PHASE_PROMPT_FILE="$prompt_file" \
  PHASE_CONTEXT_PACK_FILE="$context_pack_file" \
  PHASE_OUTPUT_FILE="$output_file" \
  PHASE_STATUS_FILE="$status_file" \
  PHASE_RUN_ID="$run_id" \
  PHASE_RUN_DIR="$run_dir" \
  PHASE_EXECUTION_ATTEMPT="$execution_attempt" \
  PHASE_MAX_EXECUTION_ATTEMPTS="$max_execution_attempts" \
  PHASE_VERIFIER_FINDINGS_FILE="$verifier_findings_file" \
  node >"$metadata_file" <<'NODE'
const metadata = {
  phase: process.env.PHASE_NAME,
  startedAt: process.env.PHASE_STARTED_AT,
  finishedAt: process.env.PHASE_FINISHED_AT || null,
  status: process.env.PHASE_STATUS,
  exitStatus: Number(process.env.PHASE_EXIT_STATUS || 0),
  targetType: "issue",
  targetIssue: Number(process.env.PHASE_ISSUE),
  agent: process.env.PHASE_AGENT,
  command: process.env.PHASE_COMMAND,
  promptFile: process.env.PHASE_PROMPT_FILE,
  contextPackFile: process.env.PHASE_CONTEXT_PACK_FILE || null,
  outputFile: process.env.PHASE_OUTPUT_FILE,
  statusFile: process.env.PHASE_STATUS_FILE,
  runId: process.env.PHASE_RUN_ID,
  runDir: process.env.PHASE_RUN_DIR,
};
if (process.env.PHASE_EXECUTION_ATTEMPT) metadata.executionAttempt = Number(process.env.PHASE_EXECUTION_ATTEMPT);
if (process.env.PHASE_MAX_EXECUTION_ATTEMPTS) metadata.maxExecutionAttempts = Number(process.env.PHASE_MAX_EXECUTION_ATTEMPTS);
if (process.env.PHASE_VERIFIER_FINDINGS_FILE) metadata.verifierFindingsFile = process.env.PHASE_VERIFIER_FINDINGS_FILE;
console.log(JSON.stringify(metadata, null, 2));
NODE
}

write_verifier_findings() {
  local output_file="$1"
  local findings_file="$2"
  local issue="$3"
  local execution_attempt="$4"
  local max_execution_attempts="$5"
  local exit_status="$6"

  VERIFIER_OUTPUT_FILE="$output_file" \
  VERIFIER_FINDINGS_FILE="$findings_file" \
  VERIFIER_ISSUE="$issue" \
  VERIFIER_EXECUTION_ATTEMPT="$execution_attempt" \
  VERIFIER_MAX_EXECUTION_ATTEMPTS="$max_execution_attempts" \
  VERIFIER_EXIT_STATUS="$exit_status" \
  node <<'NODE'
const fs = require("fs");
const outputPath = process.env.VERIFIER_OUTPUT_FILE;
const findingsPath = process.env.VERIFIER_FINDINGS_FILE;
const output = fs.existsSync(outputPath) ? fs.readFileSync(outputPath, "utf8").trim() : "";

function extractJsonObject(text) {
  let parsed = null;
  for (let start = text.indexOf("{"); start !== -1; start = text.indexOf("{", start + 1)) {
    let depth = 0;
    let inString = false;
    let escaped = false;
    for (let i = start; i < text.length; i += 1) {
      const char = text[i];
      if (inString) {
        if (escaped) {
          escaped = false;
        } else if (char === "\\") {
          escaped = true;
        } else if (char === '"') {
          inString = false;
        }
        continue;
      }
      if (char === '"') {
        inString = true;
      } else if (char === "{") {
        depth += 1;
      } else if (char === "}") {
        depth -= 1;
        if (depth === 0) {
          try {
            const candidate = JSON.parse(text.slice(start, i + 1));
            if (candidate && typeof candidate === "object") {
              if (Array.isArray(candidate.findings) || typeof candidate.summary === "string") {
                return candidate;
              }
              parsed = candidate;
            }
          } catch {
            // Keep scanning; verifier output may include non-JSON braces before findings.
          }
          break;
        }
      }
    }
  }
  return parsed;
}

const parsed = output ? extractJsonObject(output) : null;
const fallbackSummary = "Verifier failed but did not provide structured JSON findings.";
const findings = {
  issue: Number(process.env.VERIFIER_ISSUE),
  executionAttempt: Number(process.env.VERIFIER_EXECUTION_ATTEMPT),
  maxExecutionAttempts: Number(process.env.VERIFIER_MAX_EXECUTION_ATTEMPTS),
  failedVerificationAttempt: Number(process.env.VERIFIER_EXECUTION_ATTEMPT),
  exitStatus: Number(process.env.VERIFIER_EXIT_STATUS),
  status: "failed",
  source: parsed ? "verifier-json" : "fallback",
  summary: parsed && typeof parsed.summary === "string" && parsed.summary.trim()
    ? parsed.summary.trim()
    : fallbackSummary,
  findings: parsed && Array.isArray(parsed.findings) && parsed.findings.length > 0
    ? parsed.findings
    : [{
        criterion: "verification",
        evidence: output || "Verifier produced no output.",
        requestedAction: fallbackSummary,
      }],
  rawOutputFile: outputPath,
};
if (parsed) findings.parsedOutput = parsed;
fs.writeFileSync(findingsPath, `${JSON.stringify(findings, null, 2)}\n`);
NODE
}

implementation_evidence() {
  local target_dir="$1"

  (
    cd "$target_dir"
    if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
      echo "target is not a git worktree"
      return 0
    fi
    echo "git status --short:"
    git status --short || true
    echo
    echo "git diff --stat:"
    git diff --stat || true
    echo
    echo "git diff --name-only:"
    git diff --name-only || true
  )
}

pr_reference_text() {
  local target_dir="$1"
  local issue="$2"

  if command -v gh >/dev/null 2>&1 && remote_is_github "$target_dir"; then
    (
      cd "$target_dir"
      gh pr list \
        --state open \
        --limit 20 \
        --json number,title,url,body,headRefName \
        --jq "map(select(((.body // \"\") | contains(\"#${issue}\")) or ((.title // \"\") | contains(\"#${issue}\")))) | map(\"#\\(.number) \\(.title) \\(.url) head=\\(.headRefName)\") | .[]" 2>/dev/null \
        || true
    )
    return
  fi

  echo "No GitHub PR reference available."
}

active_goal_success_criteria_text() {
  local target_dir="$1"
  local issue="$2"
  local state_file="${target_dir}/.agentrail/state.json"

  if [[ ! -f "$state_file" ]] || ! command -v node >/dev/null 2>&1; then
    echo "No active goal found for issue #${issue}."
    return 0
  fi

  node - "$state_file" "$issue" <<'NODE'
const fs = require("fs");
const state = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
const issue = Number(process.argv[3]);
const goals = Array.isArray(state.workflow?.goals) ? state.workflow.goals : [];
const relevant = goals.filter((goal) => goal && goal.status === "active" && Number(goal.activeIssue) === issue);
if (relevant.length === 0) {
  console.log(`No active goal found for issue #${issue}.`);
  process.exit(0);
}
for (const goal of relevant) {
  const summary = goal.summary || goal.source || goal.id || `issue #${issue}`;
  console.log(`- ${goal.id || "goal"}: ${summary}`);
  const criteria = Array.isArray(goal.successCriteria) ? goal.successCriteria : [];
  if (criteria.length > 0) {
    console.log("  Success criteria:");
    for (const criterion of criteria) console.log(`  - ${criterion}`);
  } else {
    console.log("  Success criteria: none recorded.");
  }
  const nonGoals = Array.isArray(goal.nonGoals) ? goal.nonGoals : [];
  if (nonGoals.length > 0) {
    console.log("  Non-goals:");
    for (const nonGoal of nonGoals) console.log(`  - ${nonGoal}`);
  }
}
NODE
}

bounded_phase_text() {
  local text="${1:-}"
  local label="${2:-phase text}"
  local max_chars="${AGENTRAIL_PHASE_INLINE_MAX_CHARS:-12000}"

  if [[ -z "$text" ]]; then
    return 0
  fi

  if ! [[ "$max_chars" =~ ^[1-9][0-9]*$ ]]; then
    max_chars="24000"
  fi

  local text_chars="${#text}"
  if (( text_chars <= max_chars )); then
    printf '%s\n' "$text"
    return 0
  fi

  printf '%s\n' "${text:0:max_chars}"
  printf '\n[AgentRail truncated %s: shown first %s of %s characters. See the phase output artifact for the full text.]\n' "$label" "$max_chars" "$text_chars"
}

issue_run_phase_prompt() {
  local phase="$1"
  local issue="$2"
  local target_dir="$3"
  local issue_context="$4"
  local base_prompt="$5"
  local plan_output="${6:-}"
  local verifier_findings_file="${7:-}"
  local execution_attempt="${8:-1}"
  local max_execution_attempts="${9:-$agentrail_default_max_execution_attempts}"
  local context_summary="${10:-}"
  local evidence pr_refs verifier_findings_text active_goal_text bounded_plan_output

  case "$phase" in
    plan)
      cat <<PROMPT
This is phase 1 of 3: plan.

Issue context:
${issue_context}

Phase context pack:
${context_summary}

Base Ralph instructions:
${base_prompt}

Produce a durable implementation plan before code changes. Include these headings exactly:
- Goal
- Non-goals
- Acceptance criteria mapping
- Expected files/areas
- Required skills
- Verification commands
- Risks

Do not edit files in this phase.
PROMPT
      ;;
    execute)
      bounded_plan_output="$(bounded_phase_text "$plan_output" "approved plan output")"
      if [[ -n "$verifier_findings_file" && -f "$verifier_findings_file" ]]; then
        verifier_findings_text="$(bounded_phase_text "$(cat "$verifier_findings_file")" "verifier findings")"
      else
        verifier_findings_text=""
      fi
      cat <<PROMPT
This is phase 2 of 3: execute.
Execution attempt: ${execution_attempt} of ${max_execution_attempts}.

Issue context:
${issue_context}

Phase context pack:
${context_summary}

Approved plan from the plan phase:
${bounded_plan_output}

Base Ralph instructions:
${base_prompt}

$(if [[ -n "$verifier_findings_text" ]]; then cat <<FINDINGS
Verifier findings from previous failed verify attempt:
${verifier_findings_text}

Use these findings as focused input for this execute attempt. Address only the issue-scoped gaps needed to make verification pass.
FINDINGS
fi)

AgentRail will invoke the Ralph one-issue executor for this phase and capture its output under this run directory.
Ralph must implement the approved plan only, keep the work scoped to issue #${issue}, and run relevant verification when implementation is ready.
PROMPT
      ;;
    verify)
      bounded_plan_output="$(bounded_phase_text "$plan_output" "approved plan output")"
      evidence="$(implementation_evidence "$target_dir")"
      pr_refs="$(pr_reference_text "$target_dir" "$issue")"
      active_goal_text="$(active_goal_success_criteria_text "$target_dir" "$issue")"
      cat <<PROMPT
This is phase 3 of 3: verify.

Issue context:
${issue_context}

Phase context pack:
${context_summary}

Approved plan from the plan phase:
${bounded_plan_output}

Implementation evidence from the working tree:
${evidence}

PR/diff references:
${pr_refs}

Active goal success criteria:
${active_goal_text}

Base Ralph instructions:
${base_prompt}

Compare the issue, plan, implementation evidence, PR or diff references, every acceptance criterion, and active goal success criteria.
Confirm whether the PR body evidence is sufficient or state exactly what is missing. Do not continue into unrelated issues.
PROMPT
      ;;
    *)
      echo "unknown issue run phase: $phase" >&2
      return 2
      ;;
  esac
}

update_run_state() {
  local target_dir="$1"
  local event="$2"
  local run_id="$3"
  local issue="$4"
  local agent="$5"
  local phase="$6"
  local picked_at="$7"
  local finished_at="$8"
  local exit_status="$9"
  local prompt_file="${10}"
  local metadata_file="${11}"
  local run_dir="${12}"
  local execution_attempt="${13:-1}"
  local max_execution_attempts="${14:-$agentrail_default_max_execution_attempts}"
  local failed_verification_attempts="${15:-0}"
  local verifier_findings_file="${16:-}"
  local blocked_reason="${17:-}"
  local issue_context="${18:-}"
  local context_pack_file="${19:-}"

  local state_file="${target_dir}/.agentrail/state.json"
  [[ -f "$state_file" ]] || return 0
  command -v node >/dev/null 2>&1 || return 0

  AGENTRAIL_TARGET_DIR="$target_dir" \
  AGENTRAIL_STATE_EVENT="$event" \
  AGENTRAIL_RUN_ID="$run_id" \
  AGENTRAIL_RUN_ISSUE="$issue" \
  AGENTRAIL_RUN_AGENT="$agent" \
  AGENTRAIL_RUN_PHASE="$phase" \
  AGENTRAIL_RUN_PICKED_AT="$picked_at" \
  AGENTRAIL_RUN_FINISHED_AT="$finished_at" \
  AGENTRAIL_RUN_EXIT_STATUS="$exit_status" \
  AGENTRAIL_RUN_PROMPT_FILE="$prompt_file" \
  AGENTRAIL_RUN_METADATA_FILE="$metadata_file" \
  AGENTRAIL_RUN_DIR="$run_dir" \
  AGENTRAIL_RUN_EXECUTION_ATTEMPT="$execution_attempt" \
  AGENTRAIL_RUN_MAX_EXECUTION_ATTEMPTS="$max_execution_attempts" \
  AGENTRAIL_RUN_FAILED_VERIFICATION_ATTEMPTS="$failed_verification_attempts" \
  AGENTRAIL_RUN_VERIFIER_FINDINGS_FILE="$verifier_findings_file" \
  AGENTRAIL_RUN_BLOCKED_REASON="$blocked_reason" \
  AGENTRAIL_RUN_ISSUE_CONTEXT="$issue_context" \
  AGENTRAIL_RUN_CONTEXT_PACK_FILE="$context_pack_file" \
  node <<'NODE'
const fs = require("fs");
const path = require("path");

const targetDir = process.env.AGENTRAIL_TARGET_DIR;
const statePath = path.join(targetDir, ".agentrail/state.json");
const event = process.env.AGENTRAIL_STATE_EVENT;
const issue = Number(process.env.AGENTRAIL_RUN_ISSUE);
const exitStatus = Number(process.env.AGENTRAIL_RUN_EXIT_STATUS || 0);
const activePhase = process.env.AGENTRAIL_RUN_PHASE || null;
const executionAttempt = Number(process.env.AGENTRAIL_RUN_EXECUTION_ATTEMPT || 1);
const maxExecutionAttempts = Number(process.env.AGENTRAIL_RUN_MAX_EXECUTION_ATTEMPTS || 5);
const failedVerificationAttempts = Number(process.env.AGENTRAIL_RUN_FAILED_VERIFICATION_ATTEMPTS || 0);
const verifierFindingsFile = process.env.AGENTRAIL_RUN_VERIFIER_FINDINGS_FILE || "";
const blockedReason = process.env.AGENTRAIL_RUN_BLOCKED_REASON || "";
const issueContext = process.env.AGENTRAIL_RUN_ISSUE_CONTEXT || "";
const contextPackFile = process.env.AGENTRAIL_RUN_CONTEXT_PACK_FILE || "";

function relative(file) {
  return path.relative(targetDir, file).split(path.sep).join("/");
}

function firstLine(text) {
  return String(text || "").split(/\r?\n/).map((line) => line.trim()).find(Boolean) || "";
}

function sectionItems(text, headingPattern) {
  const lines = String(text || "").split(/\r?\n/);
  const items = [];
  let inSection = false;
  for (const line of lines) {
    if (/^##\s+/.test(line)) {
      inSection = headingPattern.test(line);
      continue;
    }
    if (!inSection) continue;
    const trimmed = line.trim();
    if (!trimmed) continue;
    const item = trimmed.replace(/^[-*]\s+\[[ xX]\]\s+/, "").replace(/^[-*]\s+/, "").trim();
    if (item) items.push(item);
  }
  return items;
}

function issueGoalDefaults(previous, workflow, now) {
  const successCriteria = sectionItems(issueContext, /^##\s+Acceptance criteria\s*$/i);
  const nonGoals = sectionItems(issueContext, /^##\s+Non-goals\s*$/i);
  const summary = firstLine(issueContext) || `Issue #${issue}`;
  return {
    ...previous,
    id: `issue-${issue}`,
    kind: "issue",
    source: `github:issue/${issue}`,
    summary,
    successCriteria: successCriteria.length > 0 ? successCriteria : (Array.isArray(previous?.successCriteria) && previous.successCriteria.length > 0 ? previous.successCriteria : [`Complete issue #${issue}.`]),
    nonGoals: nonGoals.length > 0 ? nonGoals : (Array.isArray(previous?.nonGoals) ? previous.nonGoals : []),
    activeIssue: issue,
    activePullRequest: workflow.activePullRequest ?? previous?.activePullRequest ?? null,
    activeMilestone: workflow.activeMilestone ?? previous?.activeMilestone ?? null,
    createdAt: previous?.createdAt || now,
    updatedAt: now,
  };
}

function upsertIssueGoal(workflow, status, now, reason) {
  const goals = Array.isArray(workflow.goals) ? workflow.goals : [];
  const index = goals.findIndex((goal) => goal && goal.id === `issue-${issue}`);
  const previous = index >= 0 ? goals[index] : {};
  const goal = issueGoalDefaults(previous, workflow, now);
  goal.status = status;
  if (status === "active") {
    delete goal.completedAt;
    delete goal.blockedAt;
    delete goal.blockedReason;
  } else if (status === "completed") {
    goal.completedAt = now;
    delete goal.blockedAt;
    delete goal.blockedReason;
  } else if (status === "blocked") {
    goal.blockedAt = now;
    goal.blockedReason = reason || `Agent run for issue #${issue} failed during ${activePhase || "execution"} phase.`;
    delete goal.completedAt;
  }
  if (index >= 0) goals[index] = goal;
  else goals.push(goal);
  workflow.goals = goals;
}

const state = JSON.parse(fs.readFileSync(statePath, "utf8"));
const workflow = state.workflow && typeof state.workflow === "object" ? state.workflow : {};
const completedRuns = Array.isArray(workflow.completedRuns) ? workflow.completedRuns : [];
const now = new Date().toISOString();
const run = {
  runId: process.env.AGENTRAIL_RUN_ID,
  targetType: "issue",
  targetIssue: issue,
  agent: process.env.AGENTRAIL_RUN_AGENT,
  status: event === "start" ? "running" : (exitStatus === 0 ? "completed" : "failed"),
  activePhase,
  executionAttempt,
  maxExecutionAttempts,
  failedVerificationAttempts,
  pickedAt: process.env.AGENTRAIL_RUN_PICKED_AT,
  promptFile: relative(process.env.AGENTRAIL_RUN_PROMPT_FILE),
  metadataFile: relative(process.env.AGENTRAIL_RUN_METADATA_FILE),
  runDir: relative(process.env.AGENTRAIL_RUN_DIR),
};
if (verifierFindingsFile) run.verifierFindingsFile = relative(verifierFindingsFile);
if (blockedReason) run.blockedReason = blockedReason;
if (contextPackFile) run.contextPackFile = contextPackFile;

if (event !== "start") {
  run.completedAt = process.env.AGENTRAIL_RUN_FINISHED_AT;
  run.exitStatus = exitStatus;
}

if (event === "start") {
  workflow.phase = activePhase || "implementation";
  workflow.activePhase = activePhase;
  workflow.activeIssue = issue;
  upsertIssueGoal(workflow, "active", now, "");
  const previousRun = workflow.activeRun && workflow.activeRun.runId === run.runId ? workflow.activeRun : {};
  workflow.activeRun = {
    ...previousRun,
    ...run,
    phases: Array.isArray(previousRun.phases) ? previousRun.phases : [],
  };
  workflow.nextSuggestedAction = `Continue issue #${issue}${activePhase ? ` ${activePhase} phase;` : ";"} active run metadata is ${run.metadataFile}.`;
} else {
  workflow.activeRun = null;
  workflow.activePhase = null;
  if (workflow.activeIssue === issue) workflow.activeIssue = null;
  workflow.phase = exitStatus === 0 ? "completed" : "blocked";
  const lifecycleReason = blockedReason || (exitStatus === 0 ? "" : `Agent run for issue #${issue}${activePhase ? ` failed during ${activePhase} phase` : " failed"}.`);
  upsertIssueGoal(workflow, exitStatus === 0 ? "completed" : "blocked", process.env.AGENTRAIL_RUN_FINISHED_AT || now, lifecycleReason);
  workflow.lastCompletedStep = activePhase ? `issue-${issue}-${activePhase}-${run.status}` : `issue-${issue}-${run.status}`;
  workflow.nextSuggestedAction = exitStatus === 0
    ? `Review or merge the PR for issue #${issue}, then pick the next ready issue.`
    : (blockedReason
      ? `Agent run for issue #${issue} blocked: ${blockedReason}; inspect ${run.metadataFile}${run.verifierFindingsFile ? ` and ${run.verifierFindingsFile}` : ""}.`
      : `Agent run for issue #${issue}${activePhase ? ` failed during ${activePhase} phase` : " failed"}; inspect ${run.metadataFile} and rerun or mark blocked.`);
  completedRuns.push(run);
  workflow.completedRuns = completedRuns.slice(-20);
}

state.workflow = {
  ...workflow,
  completedRuns: event === "start" ? completedRuns.slice(-20) : workflow.completedRuns,
};
state.updatedAt = new Date().toISOString();
fs.writeFileSync(statePath, `${JSON.stringify(state, null, 2)}\n`);
NODE
}

update_worktree_state() {
  local target_dir="$1"
  local worktree_path="$2"
  local status="$3"
  local issue="${4:-}"
  local pr="${5:-}"
  local run_dir="${6:-}"
  local base="${7:-}"
  local slot="${8:-}"

  local state_file="${target_dir}/.agentrail/state.json"
  [[ -f "$state_file" ]] || return 0
  command -v node >/dev/null 2>&1 || return 0

  AGENTRAIL_TARGET_DIR="$target_dir" \
  AGENTRAIL_WORKTREE_PATH="$worktree_path" \
  AGENTRAIL_WORKTREE_STATUS="$status" \
  AGENTRAIL_WORKTREE_ISSUE="$issue" \
  AGENTRAIL_WORKTREE_PR="$pr" \
  AGENTRAIL_WORKTREE_RUN_DIR="$run_dir" \
  AGENTRAIL_WORKTREE_BASE="$base" \
  AGENTRAIL_WORKTREE_SLOT="$slot" \
  node <<'NODE'
const fs = require("fs");
const path = require("path");

const targetDir = process.env.AGENTRAIL_TARGET_DIR;
const statePath = path.join(targetDir, ".agentrail/state.json");
const absolutePath = path.resolve(process.env.AGENTRAIL_WORKTREE_PATH);
const status = process.env.AGENTRAIL_WORKTREE_STATUS;
const issueText = process.env.AGENTRAIL_WORKTREE_ISSUE || "";
const prText = process.env.AGENTRAIL_WORKTREE_PR || "";
const runDir = process.env.AGENTRAIL_WORKTREE_RUN_DIR || "";
const base = process.env.AGENTRAIL_WORKTREE_BASE || "";
const slotText = process.env.AGENTRAIL_WORKTREE_SLOT || "";
const now = new Date().toISOString();
const issue = issueText ? Number(issueText) : null;
const pr = prText ? Number(prText) : null;
const slot = slotText ? Number(slotText) : null;

const allowed = new Set(["running", "completed", "merged", "abandoned", "failed"]);
if (!allowed.has(status)) {
  throw new Error(`invalid worktree lifecycle status: ${status}`);
}

function relativeToTarget(file) {
  return path.relative(targetDir, file).split(path.sep).join("/");
}

const state = JSON.parse(fs.readFileSync(statePath, "utf8"));
const workflow = state.workflow && typeof state.workflow === "object" ? state.workflow : {};
const worktrees = Array.isArray(workflow.worktrees) ? workflow.worktrees : [];
const existingIndex = worktrees.findIndex((worktree) => {
  if (!worktree || typeof worktree !== "object") return false;
  const storedPath = worktree.path || worktree.worktreePath;
  if (!storedPath) return false;
  return path.resolve(path.isAbsolute(storedPath) ? storedPath : path.join(targetDir, storedPath)) === absolutePath;
});
const previous = existingIndex >= 0 ? worktrees[existingIndex] : {};
const record = {
  ...previous,
  id: previous.id || `issue-${issue ?? "unknown"}-${path.basename(absolutePath)}`,
  type: previous.type || "issue",
  status,
  path: relativeToTarget(absolutePath),
  absolutePath,
  updatedAt: now,
};
if (!previous.createdAt) record.createdAt = now;
if (issue !== null && Number.isFinite(issue)) record.issue = issue;
if (pr !== null && Number.isFinite(pr)) record.pr = pr;
if (runDir) record.runDir = path.isAbsolute(runDir) ? relativeToTarget(runDir) : runDir;
if (base) record.base = base;
if (slot !== null && Number.isFinite(slot)) record.slot = slot;
if (status === "running") {
  delete record.completedAt;
  delete record.mergedAt;
  delete record.failedAt;
  delete record.abandonedAt;
  delete record.removedAt;
  delete record.cleanupStatus;
}
if (status === "completed" && !record.completedAt) record.completedAt = now;
if (status === "merged") record.mergedAt = now;
if (status === "failed") record.failedAt = now;
if (status === "abandoned") record.abandonedAt = now;

if (existingIndex >= 0) {
  worktrees[existingIndex] = record;
} else {
  worktrees.push(record);
}
workflow.worktrees = worktrees;
state.workflow = workflow;
state.updatedAt = now;
fs.writeFileSync(statePath, `${JSON.stringify(state, null, 2)}\n`);
NODE
}

active_run_summary() {
  local target_dir="$1"
  local state_file="${target_dir}/.agentrail/state.json"

  [[ -f "$state_file" ]] || return 1
  command -v node >/dev/null 2>&1 || return 1

  node - "$state_file" <<'NODE'
const fs = require("fs");
const state = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
const workflow = state.workflow || {};
const run = workflow.activeRun;
if (!run || typeof run !== "object") process.exit(1);
const target = run.targetType === "issue" ? `issue #${run.targetIssue}` : (run.targetType || "target");
console.log(`active run exists: ${target} via ${run.agent || "unknown"} (${run.status || "unknown"})`);
console.log(`issue: ${run.targetIssue ?? workflow.activeIssue ?? "none"}`);
console.log(`run id: ${run.runId || "unknown"}`);
console.log(`run dir: ${run.runDir || "none"}`);
console.log(`prompt: ${run.promptFile || "none"}`);
console.log(`metadata: ${run.metadataFile || "none"}`);
if (run.maxExecutionAttempts) {
  console.log(`attempts: ${Number(run.executionAttempt || 0)}/${run.maxExecutionAttempts}`);
  console.log(`failed verify attempts: ${Number(run.failedVerificationAttempts || 0)}`);
}
console.log(`next action: ${workflow.nextSuggestedAction || "Inspect the run metadata and continue or clear the active run."}`);
NODE
}

active_run_issue() {
  local target_dir="$1"
  local state_file="${target_dir}/.agentrail/state.json"

  [[ -f "$state_file" ]] || return 1
  command -v node >/dev/null 2>&1 || return 1

  node - "$state_file" <<'NODE'
const fs = require("fs");
const state = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
const workflow = state.workflow || {};
const run = workflow.activeRun;
if (!run || typeof run !== "object") process.exit(1);
console.log(run.targetIssue ?? workflow.activeIssue ?? "");
NODE
}

ensure_no_conflicting_active_run() {
  local target_dir="$1"
  local issue="$2"

  local active_issue
  active_issue="$(active_run_issue "$target_dir" 2>/dev/null || true)"
  [[ -z "$active_issue" ]] && return 0
  if [[ "$active_issue" == "$issue" ]]; then
    echo "active run already exists for issue #${issue}; resume or inspect it with: agentrail run --target ${target_dir}" >&2
  else
    echo "active run already exists for issue #${active_issue}; refusing to start issue #${issue}" >&2
  fi
  active_run_summary "$target_dir" >&2 || true
  exit 1
}

next_pickable_issue() {
  local target_dir="$1"

  if ! command -v gh >/dev/null 2>&1; then
    echo "GitHub issue selection requires gh CLI." >&2
    return 1
  fi

  (
    cd "$target_dir"
    local issues_json
    issues_json="$(gh issue list \
      --state open \
      --label afk \
      --label ready-for-agent \
      --search "sort:created-asc -label:afk-in-progress" \
      --limit 20 \
      --json number,title,url)"
    AGENTRAIL_ISSUES_JSON="$issues_json" node <<'NODE'
const issues = JSON.parse(process.env.AGENTRAIL_ISSUES_JSON || "[]");
const issue = issues.sort((a, b) => Number(a.number) - Number(b.number))[0];
if (!issue) process.exit(0);
console.log([issue.number, issue.title || "", issue.url || ""].join("\t"));
NODE
  )
}

run_issue() {
  local issue="$1"
  shift
  [[ "$issue" =~ ^[0-9]+$ ]] || { echo "run issue argument must be numeric" >&2; exit 2; }

  local agent target_dir explicit_command log_dir
  parse_run_options agent target_dir explicit_command log_dir "$@"
  local invocation_dir
  invocation_dir="$(pwd -P)"
  target_dir="$(cd "$target_dir" && pwd -P)" || exit 1
  if [[ -n "$log_dir" && "$log_dir" != /* ]]; then
    log_dir="${invocation_dir}/${log_dir}"
  fi
  local requested_agent="$agent"
  agent="$(configured_agent_name "$target_dir" "$agent")"
  ensure_source_run_allowed "$target_dir" "run issue #${issue}"
  ensure_no_conflicting_active_run "$target_dir" "$issue"

  local agent_command
  agent_command="$(configured_agent_command "$requested_agent" "$explicit_command" "$target_dir")"
  ensure_command_available "$agent_command"

  local resolution_text skill_resolution base_prompt run_context_pack_file
  resolution_text="$(issue_resolution_text "$target_dir" "$issue")"
  if ! skill_resolution="$(resolve_skills_json "$target_dir" "$resolution_text" "1" "")"; then
    echo "failed to resolve skills for issue #${issue}" >&2
    exit 1
  fi

  if ! base_prompt="$(prompt_issue "$agent" "$target_dir" "$issue")"; then
    echo "failed to generate issue prompt for #${issue}" >&2
    exit 1
  fi
  run_context_pack_file="$(printf '%s\n' "$base_prompt" | context_pack_file_from_prompt)"

  local max_execution_attempts="${AGENTRAIL_MAX_EXECUTION_ATTEMPTS:-$agentrail_default_max_execution_attempts}"
  [[ "$max_execution_attempts" =~ ^[1-9][0-9]*$ ]] || { echo "AGENTRAIL_MAX_EXECUTION_ATTEMPTS must be a positive integer" >&2; exit 2; }

  local started_at run_id run_dir prompt_file resolved_skills_file metadata_file
  started_at="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
  if [[ -z "$log_dir" ]]; then
    log_dir="${target_dir}/.agentrail/runs"
  fi
  run_id="$(date -u +"%Y%m%d-%H%M%S")-issue-${issue}-${agent}-$$"
  run_dir="${log_dir}/${run_id}"
  mkdir -p "$run_dir"

  prompt_file="${run_dir}/prompt.md"
  resolved_skills_file="${run_dir}/resolved-skills.json"
  metadata_file="${run_dir}/run.json"
  printf '%s\n' "$base_prompt" >"$prompt_file"
  printf '%s\n' "$skill_resolution" >"$resolved_skills_file"
  write_run_metadata "$metadata_file" "$started_at" "$issue" "$agent" "$agent_command" "$prompt_file" "$resolved_skills_file" "$skill_resolution" "$max_execution_attempts" "$run_context_pack_file"

  echo "AgentRail run: issue #${issue}"
  echo "agent: ${agent}"
  echo "prompt: ${prompt_file}"
  echo "resolved skills: ${resolved_skills_file}"
  echo "metadata: ${metadata_file}"
  print_dashboard_hint "$target_dir"

  local status=0 phase phase_dir phase_prompt phase_prompt_file phase_output_file phase_status_file phase_metadata_file phase_started_at phase_finished_at plan_output ralph_executor phase_command phase_context_pack_file phase_context_summary
  local execution_attempt=1 failed_verification_attempts=0 verifier_findings_file="" blocked_reason=""
  phase="verify"
  plan_output=""
  ralph_executor="$(ralph_executor_path "$target_dir")" || exit 1

  run_issue_phase() {
    phase="$1"
    execution_attempt="$2"
    verifier_findings_file="${3:-}"
    local phase_dir_name="$phase"
    if [[ "$phase" != "plan" && "$execution_attempt" -gt 1 ]]; then
      phase_dir_name="${phase}-${execution_attempt}"
    fi
    phase_dir="${run_dir}/${phase_dir_name}"
    mkdir -p "$phase_dir"
    phase_prompt_file="${phase_dir}/prompt.md"
    phase_output_file="${phase_dir}/output.md"
    phase_status_file="${phase_dir}/status.json"
    phase_metadata_file="${phase_dir}/metadata.json"
    phase_started_at="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
    if [[ "$phase" == "plan" && -n "$run_context_pack_file" ]]; then
      phase_context_pack_file="$run_context_pack_file"
    elif [[ "$phase" != "plan" && -n "$run_context_pack_file" && -f "${target_dir}/${run_context_pack_file}" ]]; then
      phase_context_pack_file="$run_context_pack_file"
    else
      phase_context_pack_file="$(build_context_pack_file "$target_dir" "issue" "$issue" "$phase")"
    fi
    phase_context_summary="$(context_pack_summary "$target_dir" "$phase_context_pack_file")"
    phase_prompt="$(issue_run_phase_prompt "$phase" "$issue" "$target_dir" "$resolution_text" "$base_prompt" "$plan_output" "$verifier_findings_file" "$execution_attempt" "$max_execution_attempts" "$phase_context_summary")"
    printf '%s\n' "$phase_prompt" >"$phase_prompt_file"
    write_phase_status "$phase_status_file" "$phase" "running" "$phase_started_at" "" "0" "$phase_metadata_file" "$phase_output_file" "$execution_attempt" "$max_execution_attempts" "$verifier_findings_file"
    if [[ "$phase" == "execute" ]]; then
      phase_command="${ralph_executor} --issue ${issue} --agent-command ${agent_command} --prefix-prompt-file ${phase_prompt_file}"
    else
      phase_command="$agent_command"
    fi
    write_phase_metadata "$phase_metadata_file" "$phase" "$phase_started_at" "" "running" "0" "$issue" "$agent" "$phase_command" "$phase_prompt_file" "$phase_output_file" "$phase_status_file" "$run_id" "$run_dir" "$execution_attempt" "$max_execution_attempts" "$verifier_findings_file" "$phase_context_pack_file"
    update_run_state "$target_dir" "start" "$run_id" "$issue" "$agent" "$phase" "$started_at" "" "0" "$phase_prompt_file" "$metadata_file" "$run_dir" "$execution_attempt" "$max_execution_attempts" "$failed_verification_attempts" "$verifier_findings_file" "" "$resolution_text" "$phase_context_pack_file"

    echo "phase: ${phase}"
    if [[ "$phase" != "plan" ]]; then
      echo "execution attempt: ${execution_attempt}/${max_execution_attempts}"
    fi
    echo "phase prompt: ${phase_prompt_file}"
    echo "phase output: ${phase_output_file}"
    echo "phase metadata: ${phase_metadata_file}"

    local agent_timeout="${AGENTRAIL_AGENT_TIMEOUT:-1800}"
    set +e
    if [[ "$phase" == "execute" ]]; then
      (
        cd "$target_dir" &&
          portable_timeout "$agent_timeout" sanitized_agent_exec "$ralph_executor" --issue "$issue" --agent-command "$agent_command" --prefix-prompt-file "$phase_prompt_file"
      ) 2>&1 | tee "$phase_output_file"
    else
      printf '%s\n' "$phase_prompt" | portable_timeout "$agent_timeout" sanitized_agent_exec bash -lc "$agent_command" 2>&1 | tee "$phase_output_file"
    fi
    status=$?
    if [[ "$status" -eq 124 ]]; then
      echo "agent timed out after ${agent_timeout}s in ${phase} phase" >&2
    fi
    set -e

    phase_finished_at="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
    if [[ "$status" -eq 0 ]]; then
      write_phase_status "$phase_status_file" "$phase" "completed" "$phase_started_at" "$phase_finished_at" "$status" "$phase_metadata_file" "$phase_output_file" "$execution_attempt" "$max_execution_attempts" "$verifier_findings_file"
      write_phase_metadata "$phase_metadata_file" "$phase" "$phase_started_at" "$phase_finished_at" "completed" "$status" "$issue" "$agent" "$phase_command" "$phase_prompt_file" "$phase_output_file" "$phase_status_file" "$run_id" "$run_dir" "$execution_attempt" "$max_execution_attempts" "$verifier_findings_file" "$phase_context_pack_file"
      if [[ "$phase" == "plan" ]]; then
        plan_output="$(cat "$phase_output_file")"
      fi
    else
      write_phase_status "$phase_status_file" "$phase" "failed" "$phase_started_at" "$phase_finished_at" "$status" "$phase_metadata_file" "$phase_output_file" "$execution_attempt" "$max_execution_attempts" "$verifier_findings_file"
      write_phase_metadata "$phase_metadata_file" "$phase" "$phase_started_at" "$phase_finished_at" "failed" "$status" "$issue" "$agent" "$phase_command" "$phase_prompt_file" "$phase_output_file" "$phase_status_file" "$run_id" "$run_dir" "$execution_attempt" "$max_execution_attempts" "$verifier_findings_file" "$phase_context_pack_file"
    fi
    return "$status"
  }

  # Phase resume: check if a prior run for this issue has a completed plan
  local prior_plan_dir="" prior_plan_status_file=""
  if [[ "${AGENTRAIL_RESUME:-0}" == "1" ]]; then
    local prior_run
    for prior_run in "${log_dir}/"*"-issue-${issue}-"*; do
      [[ -d "$prior_run" ]] || continue
      [[ "$prior_run" == "$run_dir" ]] && continue
      prior_plan_status_file="${prior_run}/plan/status.json"
      if [[ -f "$prior_plan_status_file" ]]; then
        local prior_status_val
        prior_status_val="$(node -e "console.log(JSON.parse(require('fs').readFileSync(process.argv[1],'utf8')).status)" "$prior_plan_status_file" 2>/dev/null || echo "")"
        if [[ "$prior_status_val" == "completed" && -f "${prior_run}/plan/output.md" ]]; then
          prior_plan_dir="${prior_run}/plan"
          echo "resuming from prior completed plan: ${prior_plan_dir}" >&2
          break
        fi
      fi
    done
  fi

  if [[ -n "$prior_plan_dir" ]]; then
    plan_output="$(cat "${prior_plan_dir}/output.md")"
    status=0
    echo "skipped plan phase (resumed from prior run)" >&2
  else
    run_issue_phase "plan" "1" "" || true
  fi
  if [[ "$status" -eq 0 ]]; then
    while [[ "$execution_attempt" -le "$max_execution_attempts" ]]; do
      run_issue_phase "execute" "$execution_attempt" "$verifier_findings_file" || true
      if [[ "$status" -ne 0 ]]; then
        break
      fi

      run_issue_phase "verify" "$execution_attempt" "" || true
      if [[ "$status" -eq 0 ]]; then
        break
      fi

      failed_verification_attempts="$execution_attempt"
      verifier_findings_file="${phase_dir}/findings.json"
      write_verifier_findings "$phase_output_file" "$verifier_findings_file" "$issue" "$execution_attempt" "$max_execution_attempts" "$status"
      write_phase_status "$phase_status_file" "$phase" "failed" "$phase_started_at" "$phase_finished_at" "$status" "$phase_metadata_file" "$phase_output_file" "$execution_attempt" "$max_execution_attempts" "$verifier_findings_file"
      write_phase_metadata "$phase_metadata_file" "$phase" "$phase_started_at" "$phase_finished_at" "failed" "$status" "$issue" "$agent" "$phase_command" "$phase_prompt_file" "$phase_output_file" "$phase_status_file" "$run_id" "$run_dir" "$execution_attempt" "$max_execution_attempts" "$verifier_findings_file" "$phase_context_pack_file"
      update_run_metadata_attempts "$metadata_file" "$execution_attempt" "$max_execution_attempts" "$failed_verification_attempts" "$verifier_findings_file" ""

      if [[ "$execution_attempt" -ge "$max_execution_attempts" ]]; then
        blocked_reason="maximum verifier retry attempts reached after ${max_execution_attempts} execution attempts; latest findings are ${verifier_findings_file}"
        update_run_metadata_attempts "$metadata_file" "$execution_attempt" "$max_execution_attempts" "$failed_verification_attempts" "$verifier_findings_file" "$blocked_reason"
        echo "$blocked_reason" >&2
        break
      fi

      echo "verify failed for issue #${issue}; retrying execute attempt $((execution_attempt + 1))/${max_execution_attempts} with findings ${verifier_findings_file}" >&2
      execution_attempt=$((execution_attempt + 1))
    done
  fi

  local finished_at
  finished_at="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
  update_run_metadata_attempts "$metadata_file" "$execution_attempt" "$max_execution_attempts" "$failed_verification_attempts" "$verifier_findings_file" "$blocked_reason"
  update_run_state "$target_dir" "finish" "$run_id" "$issue" "$agent" "$phase" "$started_at" "$finished_at" "$status" "$prompt_file" "$metadata_file" "$run_dir" "$execution_attempt" "$max_execution_attempts" "$failed_verification_attempts" "$verifier_findings_file" "$blocked_reason" "$resolution_text" "$phase_context_pack_file"
  return "$status"
}

run_batch() {
  local concurrency="${AGENTRAIL_BATCH_CONCURRENCY:-2}"
  local agent="claude"
  local target_dir=""
  local explicit_command=""
  local base="main"
  local issues=()

  while [[ $# -gt 0 ]]; do
    case "$1" in
      --concurrency)
        concurrency="${2:-2}"
        shift 2
        ;;
      --agent)
        agent="${2:-claude}"
        shift 2
        ;;
      --target)
        target_dir="${2:-}"
        shift 2
        ;;
      --command)
        explicit_command="${2:-}"
        shift 2
        ;;
      --base)
        base="${2:-main}"
        shift 2
        ;;
      --)
        shift
        issues+=("$@")
        break
        ;;
      -*)
        echo "run batch: unknown option $1" >&2
        exit 2
        ;;
      *)
        issues+=("$1")
        shift
        ;;
    esac
  done

  [[ ${#issues[@]} -ge 1 ]] || { echo "run batch requires at least one issue number" >&2; exit 2; }
  [[ "$concurrency" =~ ^[1-9][0-9]*$ ]] || { echo "--concurrency must be a positive integer" >&2; exit 2; }

  if [[ -z "$target_dir" ]]; then
    target_dir="$(pwd -P)"
  fi
  target_dir="$(cd "$target_dir" && pwd -P)" || exit 1
  ensure_source_run_allowed "$target_dir" "run batch"

  local agent_command
  agent_command="$(configured_agent_command "$agent" "$explicit_command" "$target_dir")"
  ensure_command_available "$agent_command"

  local batch_dir="${target_dir}/.agentrail/batch/$(date -u +"%Y%m%d-%H%M%S")"
  mkdir -p "${batch_dir}/logs" "${batch_dir}/worktrees"

  echo "batch: ${#issues[@]} issues, concurrency ${concurrency}, agent ${agent}"
  echo "batch dir: ${batch_dir}"

  local active=0
  local pids=()
  local pid_issues=()
  local worktrees=()
  local failed=0

  git -C "$target_dir" fetch origin "$base" 2>/dev/null || true

  for issue in "${issues[@]}"; do
    [[ "$issue" =~ ^[0-9]+$ ]] || { echo "skipping non-numeric issue: $issue" >&2; continue; }

    while [[ "$active" -ge "$concurrency" ]]; do
      local done_pid=""
      for i in "${!pids[@]}"; do
        if ! kill -0 "${pids[$i]}" 2>/dev/null; then
          wait "${pids[$i]}" 2>/dev/null || { echo "batch: issue #${pid_issues[$i]} failed" >&2; failed=1; }
          done_pid="$i"
          break
        fi
      done
      if [[ -n "$done_pid" ]]; then
        unset 'pids[done_pid]'
        unset 'pid_issues[done_pid]'
        pids=("${pids[@]}")
        pid_issues=("${pid_issues[@]}")
        active=$((active - 1))
      else
        sleep 5
      fi
    done

    local slot=$((${#pids[@]} + 1))
    local worktree="${batch_dir}/worktrees/slot-${slot}-issue-${issue}"
    local log_file="${batch_dir}/logs/issue-${issue}.log"
    echo "batch: starting issue #${issue} in slot ${slot}"

    git -C "$target_dir" worktree add --detach "$worktree" "origin/${base}" >/dev/null 2>&1
    worktrees+=("$worktree")

    if [[ -d "${target_dir}/.agentrail" ]]; then
      cp -r "${target_dir}/.agentrail" "${worktree}/.agentrail" 2>/dev/null || true
    fi

    (
      cd "$worktree"
      run_args=("--target" "$worktree")
      run_args+=("--agent" "$agent")
      if [[ -n "$explicit_command" ]]; then
        run_args+=("--command" "$explicit_command")
      fi
      AGENTRAIL_ALLOW_SOURCE_RUN=1 run_issue "$issue" "${run_args[@]}"
    ) >"$log_file" 2>&1 &
    pids+=("$!")
    pid_issues+=("$issue")
    active=$((active + 1))
  done

  for i in "${!pids[@]}"; do
    if ! wait "${pids[$i]}"; then
      echo "batch: issue #${pid_issues[$i]} failed" >&2
      failed=1
    else
      echo "batch: issue #${pid_issues[$i]} completed"
    fi
  done

  for wt in "${worktrees[@]}"; do
    git -C "$target_dir" worktree remove --force "$wt" 2>/dev/null || true
  done

  if [[ "$failed" -ne 0 ]]; then
    echo "batch: some issues failed; check logs in ${batch_dir}/logs/" >&2
    return 1
  fi
  echo "batch: all ${#issues[@]} issues completed successfully"
  return 0
}

run_agentrail_run() {
  local kind="${1:-}"
  if [[ -z "$kind" || "$kind" == --* ]]; then
    local agent target_dir explicit_command log_dir
    parse_run_options agent target_dir explicit_command log_dir "$@"

    if [[ ! -f "${target_dir}/.agentrail/state.json" ]]; then
      state_recommendation "$target_dir" >&2
      exit 1
    fi

    ensure_source_run_allowed "$target_dir" "select queued issues"

    if active_run_summary "$target_dir"; then
      return 0
    fi

    local picked number title url
    picked="$(next_pickable_issue "$target_dir")"
    if [[ -z "$picked" ]]; then
      echo "No pickable GitHub issues found."
      echo "Required labels: afk, ready-for-agent"
      echo "Excluded label: afk-in-progress"
      return 0
    fi

    IFS=$'\t' read -r number title url <<<"$picked"
    echo "selected issue #${number}: ${title}"
    [[ -z "$url" ]] || echo "$url"
    local run_args=("--target" "$target_dir")
    if [[ "$agent" != "__config__" ]]; then
      run_args+=("--agent" "$agent")
    fi
    if [[ -n "$explicit_command" ]]; then
      run_args+=("--command" "$explicit_command")
    fi
    if [[ -n "$log_dir" ]]; then
      run_args+=("--log-dir" "$log_dir")
    fi
    run_issue "$number" "${run_args[@]}"
    return $?
  fi

  shift || true

  case "$kind" in
    issue)
      local issue="${1:-}"
      [[ -n "$issue" && "$issue" != --* ]] || { echo "run issue requires a number" >&2; exit 2; }
      shift
      run_issue "$issue" "$@"
      ;;
    batch)
      shift
      run_batch "$@"
      ;;
    -h|--help)
      usage
      exit 0
      ;;
    *)
      echo "Unknown run type: $kind" >&2
      usage >&2
      exit 2
      ;;
  esac
}

case "${1:-}" in
  init)
    shift
    run_install "$@"
    ;;
  install)
    shift
    run_install "$@"
    ;;
  upgrade)
    shift
    run_upgrade "$@"
    ;;
  doctor)
    shift
    run_doctor "$@"
    ;;
  status)
    shift
    run_status "$@"
    ;;
  context)
    shift
    run_context "$@"
    ;;
  memory)
    shift
    run_memory "$@"
    ;;
  skills)
    shift
    run_skills "$@"
    ;;
  resume)
    shift
    run_resume "$@"
    ;;
  labels)
    shift
    case "${1:-}" in
      sync)
        shift
        sync_github_labels "$@"
        ;;
      ""|-h|--help)
        usage
        exit 0
        ;;
      *)
        echo "Unknown labels command: $1" >&2
        usage >&2
        exit 2
        ;;
    esac
    ;;
  prompt)
    shift
    run_prompt "$@"
    ;;
  afk)
    shift
    run_afk "$@"
    ;;
  cleanup)
    shift
    run_cleanup "$@"
    ;;
  internal)
    shift
    run_internal "$@"
    ;;
  console)
    shift
    run_console "$@"
    ;;
  run)
    shift
    run_agentrail_run "$@"
    ;;
  ""|-h|--help)
    usage
    exit 0
    ;;
  *)
    echo "Unknown command: $1" >&2
    usage >&2
    exit 2
    ;;
esac
