name: "vigiles"
description: "Verify the references in your agent instruction files (CLAUDE.md/AGENTS.md) and compile typed specs — in CI."
author: "zernie"
branding:
  icon: "check-circle"
  color: "orange"

inputs:
  command:
    description: >
      Which vigiles command to run: 'lint' (verify references + integrity +
      coverage), 'compile' (specs → markdown), or 'eval-check' (verify committed
      eval locks against current inputs — the staleness gate; runs NO model, so
      it is the CI-safe half of evals you produce locally with `vigiles eval --update`).
    required: false
    default: "lint"
  paths:
    description: "Comma- or space-separated paths to operate on (.md files for lint, .spec.ts files for compile). Auto-discovers when empty."
    required: false
    default: ""
  version:
    description: >
      Version of the vigiles npm package to run (e.g. '34', '34.1.2', 'latest').
      Empty (the default) runs the vigiles THIS repo declares in package.json:
      the installed copy when node_modules has it, else the version in
      package-lock.json, else the declared range — and 'latest' only when the
      repo does not declare vigiles. Use 'local' to run the action's own
      checkout — for dogfooding via `uses: ./` in this repo's CI.
    required: false
    default: ""
  max-rules:
    description: "Maximum number of rules per spec file (maps to the CLI's --max-rules)."
    required: false
    default: ""
  catalog-only:
    description: "Only check that linter rules exist; skip config-enabled checks (maps to the CLI's --catalog-only). 'true' or 'false'."
    required: false
    default: "false"
  preset:
    description: >
      Add a preset for this run, on top of the repo's .vigilesrc.json (maps to
      the CLI's --preset=; lint only). The names are the ones `extends` takes,
      with or without the `vigiles:` prefix: 'experimental' turns on the checks
      not yet measured reliable enough to grade on, at warn — so they report
      and never fail the run. Comma-separate several. Empty (the default) adds
      none.
    required: false
    default: ""
  working-directory:
    description: "Directory to run vigiles in."
    required: false
    default: "."
  comment:
    description: "On pull_request events, post/update a sticky PR comment with the result ('true' or 'false'). Requires `pull-requests: write`."
    required: false
    default: "true"
  capability-diff:
    description: >
      On pull_request events, compare the agent's capability surface (the
      subagents' tool/effect blast radius) against the PR base and fold the diff
      into the sticky comment (maps to `audit --capability-diff`). Requires
      `fetch-depth: 0` on checkout so the base commit is present. 'true' or 'false'.
    required: false
    default: "false"
  fail-on-widen:
    description: "With capability-diff, fail the run when the PR WIDENS the blast radius (maps to the CLI's --fail-on-widen). 'true' or 'false'."
    required: false
    default: "false"
  github-token:
    description: "Token used to post the PR comment. Defaults to the workflow token."
    required: false
    default: ${{ github.token }}

outputs:
  valid:
    description: "'true' if vigiles passed (exit 0), 'false' otherwise."
    value: ${{ steps.vigiles.outputs.valid }}

runs:
  using: "composite"
  steps:
    - id: vigiles
      shell: bash
      working-directory: ${{ inputs.working-directory }}
      env:
        # Inputs are passed through the environment (never interpolated into
        # the script body) so a value can't break out of the command line.
        VIGILES_COMMAND: ${{ inputs.command }}
        VIGILES_PATHS: ${{ inputs.paths }}
        VIGILES_VERSION: ${{ inputs.version }}
        VIGILES_MAX_RULES: ${{ inputs.max-rules }}
        VIGILES_CATALOG_ONLY: ${{ inputs.catalog-only }}
        VIGILES_PRESET: ${{ inputs.preset }}
        VIGILES_ACTION_PATH: ${{ github.action_path }}
        VIGILES_COMMENT: ${{ inputs.comment }}
        VIGILES_CAPABILITY_DIFF: ${{ inputs.capability-diff }}
        VIGILES_FAIL_ON_WIDEN: ${{ inputs.fail-on-widen }}
        GH_TOKEN: ${{ inputs.github-token }}
      run: |
        set -euo pipefail

        cmd="${VIGILES_COMMAND:-lint}"
        # 'eval-check' is the CI staleness gate — it maps to the real verb
        # `eval --check` (verify committed locks vs current inputs, NO model).
        # Real evals run locally on a subscription (`eval --update`), never in CI.
        if [[ "$cmd" == "eval-check" ]]; then
          args=("eval" "--check")
        else
          args=("$cmd")
        fi

        # paths: split on commas and whitespace into positional args.
        paths="${VIGILES_PATHS:-}"
        if [[ -n "${paths// /}" ]]; then
          IFS=', ' read -r -a _paths <<< "$paths"
          args+=("${_paths[@]}")
        fi

        max_rules="${VIGILES_MAX_RULES:-}"
        if [[ -n "${max_rules// /}" ]]; then
          args+=("--max-rules=${max_rules}")
        fi

        if [[ "${VIGILES_CATALOG_ONLY:-false}" == "true" ]]; then
          args+=("--catalog-only")
        fi

        # The CLI validates the name (an unknown preset exits 2 naming the
        # valid ones), so the value is passed through as written.
        preset="${VIGILES_PRESET:-}"
        if [[ -n "${preset// /}" ]]; then
          args+=("--preset=${preset// /}")
        fi

        # Resolve the runner. 'local' runs the action's own build (CI
        # dogfood via `uses: ./`); an explicit version runs the published npm
        # package at that version. EMPTY (the default) runs the vigiles this
        # repo declares, so CI and a local `npx vigiles` are the same version
        # rather than two pins that drift: the installed copy, else the
        # lockfile's version, else the declared range; `latest` only when the
        # repo does not declare vigiles at all.
        version="${VIGILES_VERSION:-}"
        if [[ "$version" == "local" ]]; then
          runner=(node "${VIGILES_ACTION_PATH}/dist/cli.js")
          from="the action's own checkout"
        elif [[ -n "$version" ]]; then
          runner=(npx --yes "vigiles@${version}")
          from="the version input"
        else
          declared="$(node -e '
            const fs = require("fs");
            const read = (f) => { try { return JSON.parse(fs.readFileSync(f, "utf8")); } catch { return null; } };
            const pkg = read("package.json") ?? {};
            const spec = pkg.devDependencies?.vigiles ?? pkg.dependencies?.vigiles;
            if (typeof spec !== "string") { console.log("none"); process.exit(0); }
            if (fs.existsSync("node_modules/.bin/vigiles")) { console.log("installed"); process.exit(0); }
            const lock = read("package-lock.json") ?? {};
            const locked = lock.packages?.["node_modules/vigiles"]?.version ?? lock.dependencies?.vigiles?.version;
            console.log(typeof locked === "string" ? "lock " + locked : "range " + spec);
          ' 2>/dev/null || echo none)"
          case "$declared" in
            installed)
              runner=(npx --no-install vigiles)
              from="this repo's installed vigiles" ;;
            "lock "*)
              runner=(npx --yes "vigiles@${declared#lock }")
              from="package-lock.json" ;;
            "range "*)
              runner=(npx --yes "vigiles@${declared#range }")
              from="the range package.json declares" ;;
            *)
              runner=(npx --yes "vigiles@latest")
              from="latest (this repo does not declare vigiles)" ;;
          esac
        fi

        echo "vigiles: ${runner[*]} ${args[*]}  (version from ${from})"

        # Run, streaming to the log AND capturing for the summary/comment.
        out="$(mktemp)"
        set +e
        GITHUB_ACTIONS=true "${runner[@]}" "${args[@]}" 2>&1 | tee "$out"
        status="${PIPESTATUS[0]}"
        set -e

        # Capability diff (moat #2): did this PR WIDEN the agent's blast radius?
        # Runs orthogonally to the main command — it's an additive PR-comment
        # section, so it works whatever `command` gated above. The BEFORE tree is
        # a detached worktree of the PR base; the AFTER tree is this checkout.
        capdiff=""       # markdown appended to the sticky comment
        capstatus=0
        if [[ "${VIGILES_CAPABILITY_DIFF:-false}" == "true" \
              && "${GITHUB_EVENT_NAME:-}" == "pull_request" ]]; then
          base_sha="$(jq -r '.pull_request.base.sha // empty' "${GITHUB_EVENT_PATH:-/dev/null}")"
          if [[ -z "$base_sha" ]]; then
            echo "::warning::capability-diff: no PR base sha in the event payload"
          else
            # A shallow checkout may not carry the base commit — fetch it first.
            git fetch --no-tags --depth=1 origin "$base_sha" >/dev/null 2>&1 \
              || git fetch --no-tags origin "$base_sha" >/dev/null 2>&1 || true
            base_dir="$(mktemp -d)"
            # Materialize the base tree with `git archive` (NOT a worktree): it
            # exports stored blobs WITHOUT running checkout/smudge filters, so it's
            # light and can't trip a required filter (e.g. git-crypt) the runner
            # lacks. The agent surface (unencrypted) lands as plaintext to diff.
            if git archive "$base_sha" 2>/dev/null | tar -x -C "$base_dir" 2>/dev/null; then
              # Diff the SAME relative dir in both trees (support a subdir working-directory).
              rel="$(git rev-parse --show-prefix 2>/dev/null || echo '')"
              before="${base_dir%/}/${rel}"; before="${before%/}"
              capout="$(mktemp)"
              fail_flag=(); [[ "${VIGILES_FAIL_ON_WIDEN:-false}" == "true" ]] && fail_flag=(--fail-on-widen)
              # --no-html --no-json: this run is for the diff only, so skip the
              # report artifacts. The full audit still streams to the CI log; only
              # the capability-diff paragraph is extracted for the sticky comment.
              set +e
              "${runner[@]}" audit . --capability-diff="$before" --no-html --no-json "${fail_flag[@]}" 2>&1 | tee "$capout"
              capstatus="${PIPESTATUS[0]}"
              set -e
              # Pull out just the "Capability surface …" paragraph (a self-contained
              # block with no internal blank lines), then drop the no-change case
              # so a PR that touches nothing capability-relevant gets no section.
              capdiff="$(awk '/Capability surface/{f=1} f&&NF{print} f&&!NF{exit}' "$capout")"
              case "$capdiff" in *"unchanged"*) capdiff="" ;; esac
            else
              echo "::warning::capability-diff: could not materialize PR base ${base_sha} (need fetch-depth: 0?)"
            fi
            rm -rf "$base_dir" 2>/dev/null || true
          fi
        fi

        # A capability WIDENING (fail-on-widen → exit 1) flips an otherwise-green
        # run to failed, so the gate is visible in the output + PR comment.
        if [[ "$capstatus" -ne 0 && "$status" -eq 0 ]]; then
          status="$capstatus"
        fi

        if [[ "$status" -eq 0 ]]; then
          echo "valid=true" >> "$GITHUB_OUTPUT"
          headline="✅ \`vigiles ${cmd}\` passed"
        else
          echo "valid=false" >> "$GITHUB_OUTPUT"
          headline="❌ \`vigiles ${cmd}\` failed (exit ${status})"
          echo "::error::vigiles ${cmd} failed (exit ${status})"
        fi

        # Build a markdown report (cap the log tail so a huge run stays sane).
        report="$(mktemp)"
        {
          echo "## vigiles"
          echo
          echo "$headline"
          echo
          if [[ -n "$capdiff" ]]; then
            echo "### Capability diff"
            echo
            echo '```'
            echo "$capdiff"
            echo '```'
            echo
          fi
          echo '<details><summary>Output</summary>'
          echo
          echo '```'
          tail -c 60000 "$out"
          echo '```'
          echo
          echo '</details>'
        } > "$report"

        # Job summary (always, when available) — the modern run-page output.
        if [[ -n "${GITHUB_STEP_SUMMARY:-}" ]]; then
          cat "$report" >> "$GITHUB_STEP_SUMMARY"
        fi

        # Sticky PR comment (pull_request events only, best-effort): find the
        # prior vigiles comment by marker and update it in place, else create.
        marker="<!-- vigiles-action -->"
        if [[ "${VIGILES_COMMENT:-true}" == "true" \
              && "${GITHUB_EVENT_NAME:-}" == "pull_request" \
              && -n "${GH_TOKEN:-}" ]] && command -v gh >/dev/null 2>&1; then
          pr="$(jq -r '.pull_request.number // empty' "${GITHUB_EVENT_PATH:-/dev/null}")"
          if [[ -n "$pr" ]]; then
            body="$(printf '%s\n%s\n' "$marker" "$(cat "$report")")"
            repo="${GITHUB_REPOSITORY}"
            cid="$(gh api "repos/${repo}/issues/${pr}/comments" --paginate \
              --jq "map(select(.body | startswith(\"${marker}\"))) | .[0].id // empty" \
              2>/dev/null || true)"
            if [[ -n "$cid" ]]; then
              gh api -X PATCH "repos/${repo}/issues/comments/${cid}" -f body="$body" >/dev/null \
                && echo "Updated PR comment #${cid}" \
                || echo "::warning::could not update PR comment (need pull-requests: write?)"
            else
              gh api -X POST "repos/${repo}/issues/${pr}/comments" -f body="$body" >/dev/null \
                && echo "Posted PR comment" \
                || echo "::warning::could not post PR comment (need pull-requests: write?)"
            fi
          fi
        fi

        exit "$status"
