diff --git a/.gitattributes b/.gitattributes index 3342e8f9..18cfb2ab 100644 --- a/.gitattributes +++ b/.gitattributes @@ -73,7 +73,7 @@ benchmarks/data/** linguist-vendored *.whl binary # --- Source archive hygiene (excluded from `git archive`) ----------------- -.github export-ignore +# Exclude specific top-level config files that are only used by tooling .shared-templates export-ignore .gitattributes export-ignore .gitignore export-ignore diff --git a/.github/actions/hawk/action.yml b/.github/actions/hawk/action.yml new file mode 100644 index 00000000..42f5c803 --- /dev/null +++ b/.github/actions/hawk/action.yml @@ -0,0 +1,302 @@ +name: Hawk + +description: >- + Run the Hawk coding agent headlessly in a GitHub workflow. Hawk is + model- and provider-agnostic: you supply the provider and API key as inputs. + +author: GrayCodeAI + +branding: + icon: terminal + color: blue + +inputs: + prompt: + description: The instruction for Hawk to execute. Mutually exclusive with prompt-file. + required: false + default: "" + prompt-file: + description: Path (relative to working-directory) to a file whose contents are the prompt. + required: false + default: "" + provider: + description: >- + Provider id to activate (for example openai, anthropic, gemini, ollama, or + any configured OpenAI-/Anthropic-compatible endpoint). Optional when the + repository already carries a .zero/config.json with an active provider. + required: false + default: "" + api-key: + description: >- + The provider API key. Pass it from a repository or organization secret; + it is exported only for the Hawk step and never written to the log. + required: false + default: "" + api-key-env: + description: >- + Name of the environment variable the chosen provider reads its key from + (for example OPENAI_API_KEY or ANTHROPIC_API_KEY). When set together with + api-key, that variable is exported only for the Hawk step. Leave the action + provider-agnostic by passing the env name your provider expects rather than + hardcoding one here. + required: false + default: "" + model: + description: Model id to use for the run. Passed to `hawk exec --model`. + required: false + default: "" + auto: + description: >- + Autonomy ceiling: supervised, basic, semi, full, or yolo. + Defaults to `supervised` so an unattended CI run does not take + high-impact actions implicitly. + required: false + default: supervised + agent: + description: >- + Agent persona to use (from Hawk user state). Maps to `hawk exec --agent`. + required: false + default: "" + cwd: + description: >- + Working directory override. Maps to `hawk exec --cwd`. + Defaults to the action's working-directory input. + required: false + default: "" + ephemeral: + description: >- + When true, skip session persistence. Ideal for CI runs. + Maps to `hawk exec --ephemeral`. + required: false + default: "false" + worktree: + description: When true, run inside an isolated git worktree. + required: false + default: "false" + output-format: + description: >- + Output format: text or json. Defaults to text. + required: false + default: text + post-to: + description: >- + Optional destination for a run summary after Hawk finishes: `pr-comment` + posts to the triggering pull request, `slack` posts to slack-webhook-url, + or `none` (default) posts nowhere. + required: false + default: none + slack-webhook-url: + description: >- + Slack incoming-webhook URL used when post-to is slack. + Pass it from a secret; it is never written to the log. + required: false + default: "" + github-token: + description: >- + Token used to post a PR comment when post-to is pr-comment. + Defaults to ${{ github.token }}. + required: false + default: ${{ github.token }} + working-directory: + description: Directory to run Hawk in. Defaults to ${{ github.workspace }}. + required: false + default: ${{ github.workspace }} + hawk-version: + description: >- + Hawk release version/tag to install (e.g. v1.2.3 or latest). + Defaults to the ref this action was resolved at. + required: false + default: "" + hawk-repo: + description: Repository to install Hawk from (owner/repo). + required: false + default: GrayCodeAI/hawk + +outputs: + exit-code: + description: The exit code Hawk returned (0 success, 2 usage, 3 provider, non-zero otherwise). + value: ${{ steps.run.outputs.exit-code }} + output-file: + description: Path to the captured Hawk stdout (the raw output-format stream). + value: ${{ steps.run.outputs.output-file }} + summary: + description: A short human-readable summary line parsed from the run, when available. + value: ${{ steps.run.outputs.summary }} + +runs: + using: composite + steps: + - name: Install Hawk + id: install + shell: bash + env: + HAWK_REPO: ${{ inputs.hawk-repo }} + HAWK_VERSION_INPUT: ${{ inputs.hawk-version }} + ACTION_REF: ${{ github.action_ref }} + ACTION_PATH: ${{ github.action_path }} + RUNNER_TEMP: ${{ runner.temp }} + run: | + set -euo pipefail + if [ "${RUNNER_OS}" = "Windows" ]; then + echo "::error::The Hawk action currently supports Linux and macOS runners only." >&2 + exit 1 + fi + # Resolve the version to install: explicit input wins; otherwise the ref + # this action was checked out at (a tag); otherwise the latest release. + version="${HAWK_VERSION_INPUT}" + if [ -z "${version}" ]; then + version="${ACTION_REF}" + fi + # A commit-SHA pin (github.action_ref is the 40-hex SHA) is not a release + # tag, so fall back to latest rather than fetching releases/download/v. + case "${version}" in + ""|refs/*|*/*) version="latest" ;; + esac + if printf '%s' "${version}" | grep -qiE '^[0-9a-f]{40}$'; then + version="latest" + fi + install_dir="${RUNNER_TEMP}/hawk-bin" + src_dir="${RUNNER_TEMP}/hawk-src" + mkdir -p "${install_dir}" "${src_dir}" + # Download the Hawk source archive and extract it + if [ "${version}" = "latest" ]; then + tarball_url=$(curl -sL "https://api.github.com/repos/${HAWK_REPO}/releases/latest" \ + | grep '"tarball_url":' | sed 's/.*"\([^"]*\)".*/\1/') + else + tarball_url="https://github.com/${HAWK_REPO}/archive/refs/tags/v${version}.tar.gz" + fi + curl -sL "${tarball_url}" | tar -xz -C "${src_dir}" --strip-components=1 + # Build the Hawk binary from source (Go must be available on the runner) + if ! command -v go &>/dev/null; then + echo "::error::Go is required but not found on the runner PATH. Add actions/setup-go before this action, or install Go manually." >&2 + exit 1 + fi + go build -o "${install_dir}/hawk" "${src_dir}/cmd/hawk" + chmod +x "${install_dir}/hawk" + echo "${install_dir}" >> "${GITHUB_PATH}" + echo "Installed Hawk from ${HAWK_REPO}@${version}" + + - name: Run Hawk + id: run + shell: bash + working-directory: ${{ inputs.working-directory }} + env: + # Secrets are passed via the environment and never echoed. `set +x` and + # the absence of any `echo` of these values keep them out of the log. + HAWK_INPUT_API_KEY: ${{ inputs.api-key }} + HAWK_INPUT_API_KEY_ENV: ${{ inputs.api-key-env }} + HAWK_INPUT_PROVIDER: ${{ inputs.provider }} + HAWK_INPUT_PROMPT: ${{ inputs.prompt }} + HAWK_INPUT_PROMPT_FILE: ${{ inputs.prompt-file }} + HAWK_INPUT_MODEL: ${{ inputs.model }} + HAWK_INPUT_AUTO: ${{ inputs.auto }} + HAWK_INPUT_AGENT: ${{ inputs.agent }} + HAWK_INPUT_CWD: ${{ inputs.cwd }} + HAWK_INPUT_EPHEMERAL: ${{ inputs.ephemeral }} + HAWK_INPUT_WORKTREE: ${{ inputs.worktree }} + HAWK_INPUT_OUTPUT_FORMAT: ${{ inputs.output-format }} + ACTION_PATH: ${{ github.action_path }} + RUNNER_TEMP: ${{ runner.temp }} + run: | + set -euo pipefail + + # Export the provider key under the env name the provider expects. This + # keeps the action provider-agnostic: no provider->env mapping is baked + # in. The value is never printed. + if [ -n "${HAWK_INPUT_API_KEY}" ] && [ -n "${HAWK_INPUT_API_KEY_ENV}" ]; then + if ! printf '%s' "${HAWK_INPUT_API_KEY_ENV}" | grep -qE '^[A-Za-z_][A-Za-z0-9_]*$'; then + echo "::error::api-key-env must be a valid environment variable name." >&2 + exit 2 + fi + export "${HAWK_INPUT_API_KEY_ENV}=${HAWK_INPUT_API_KEY}" + fi + if [ -n "${HAWK_INPUT_PROVIDER}" ]; then + export HAWK_PROVIDER="${HAWK_INPUT_PROVIDER}" + fi + + args=(exec) + + if [ -n "${HAWK_INPUT_PROMPT_FILE}" ] && [ -n "${HAWK_INPUT_PROMPT}" ]; then + echo "::error::The 'prompt' and 'prompt-file' inputs are mutually exclusive." >&2 + exit 2 + fi + if [ -n "${HAWK_INPUT_PROMPT_FILE}" ]; then + args+=(--file "${HAWK_INPUT_PROMPT_FILE}") + elif [ -n "${HAWK_INPUT_PROMPT}" ]; then + args+=("${HAWK_INPUT_PROMPT}") + else + echo "::error::Provide either the 'prompt' or 'prompt-file' input." >&2 + exit 2 + fi + + [ -n "${HAWK_INPUT_MODEL}" ] && args+=(--model "${HAWK_INPUT_MODEL}") + [ -n "${HAWK_INPUT_AUTO}" ] && args+=(--auto "${HAWK_INPUT_AUTO}") + [ -n "${HAWK_INPUT_AGENT}" ] && args+=(--agent "${HAWK_INPUT_AGENT}") + [ -n "${HAWK_INPUT_CWD}" ] && args+=(--cwd "${HAWK_INPUT_CWD}") + if [ "${HAWK_INPUT_EPHEMERAL}" = "true" ]; then + args+=(--ephemeral) + fi + if [ "${HAWK_INPUT_WORKTREE}" = "true" ]; then + args+=(--worktree) + fi + [ -n "${HAWK_INPUT_OUTPUT_FORMAT}" ] && args+=(--output-format "${HAWK_INPUT_OUTPUT_FORMAT}") + + output_file="${RUNNER_TEMP}/hawk-output.txt" + + # Run Hawk. Capture stdout to a file AND echo it to the log. Surface the + # real exit code as the step's status. + set +e + hawk "${args[@]}" | tee "${output_file}" + code=${PIPESTATUS[0]} + set -e + + echo "exit-code=${code}" >> "${GITHUB_OUTPUT}" + echo "output-file=${output_file}" >> "${GITHUB_OUTPUT}" + + # Best-effort one-line summary + if [ "${code}" = "0" ]; then + summary="Hawk run succeeded" + else + summary="Hawk run failed (exit ${code})" + fi + { + echo "summary<> "${GITHUB_OUTPUT}" + + exit "${code}" + + - name: Post run summary to pull request + if: ${{ always() && inputs.post-to == 'pr-comment' && github.event.pull_request.number != '' }} + shell: bash + env: + GH_TOKEN: ${{ inputs.github-token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + HAWK_EXIT: ${{ steps.run.outputs.exit-code }} + HAWK_SUMMARY: ${{ steps.run.outputs.summary }} + run: | + set -euo pipefail + status="succeeded" + [ "${HAWK_EXIT}" != "0" ] && status="failed (exit ${HAWK_EXIT})" + body="$(printf 'Hawk run %s.\n\n%s' "${status}" "${HAWK_SUMMARY}")" + gh api "repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" \ + -f body="${body}" >/dev/null + + - name: Post run summary to Slack + if: ${{ always() && inputs.post-to == 'slack' && inputs.slack-webhook-url != '' }} + shell: bash + env: + SLACK_WEBHOOK_URL: ${{ inputs.slack-webhook-url }} + HAWK_EXIT: ${{ steps.run.outputs.exit-code }} + HAWK_SUMMARY: ${{ steps.run.outputs.summary }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + set -euo pipefail + status="succeeded" + [ "${HAWK_EXIT}" != "0" ] && status="failed (exit ${HAWK_EXIT})" + text="Hawk run ${status} for ${GITHUB_REPOSITORY}@${GITHUB_REF_NAME}. ${HAWK_SUMMARY} ${RUN_URL}" + payload="$(jq -n --arg text "${text}" '{text: $text}')" + curl --fail --silent --show-error -X POST -H 'Content-Type: application/json' \ + -d "${payload}" "${SLACK_WEBHOOK_URL}" >/dev/null || \ + echo "::warning::Slack webhook delivery failed (run not affected)." diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 95b64ce5..13a49cf0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,7 +19,7 @@ concurrency: cancel-in-progress: true env: - GO_VERSION: "1.26.4" + GO_VERSION: "1.26.5" # GrayCodeAI sibling modules are resolved from the local external/ submodules via # go.work; their go.mod require versions (v0.1.0) intentionally do not match the # frozen public proxy/sumdb snapshot, so bypass the proxy + checksum DB for them. @@ -238,7 +238,7 @@ jobs: # Full-strength scan: no rule exclusions. The repo reached a # 0-findings baseline (2026-07 full-repo audit); keep it that way. # Suppressions must be inline `#nosec -- `. - gosec -quiet ./... + gosec -quiet -exclude-dir external ./... # ------------------------------------------------------------------------- # 7. Secret scan — detect leaked API keys, tokens, credentials. @@ -275,7 +275,7 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c with: - go-version: "1.26.4" + go-version: "${{ env.GO_VERSION }}" cache: true - name: deadcode run: | diff --git a/.gitignore b/.gitignore index 4a4db1fc..3dc5d456 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ # Binaries -hawk +/hawk hawk_bin *.exe *.dll diff --git a/.golangci.yml b/.golangci.yml index 95522a01..88c6bb78 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -79,6 +79,8 @@ linters: paths: - .gomodcache - vendor + - .github/actions + - external rules: - path: _test\.go linters: diff --git a/external/eyrie b/external/eyrie index eaeb247e..a4ff5615 160000 --- a/external/eyrie +++ b/external/eyrie @@ -1 +1 @@ -Subproject commit eaeb247e2fd15e02f40c0024e93937165ea978d8 +Subproject commit a4ff561552e2d069ce4edbe6641ef937dd81808f diff --git a/external/inspect b/external/inspect index aaa91a69..6302596e 160000 --- a/external/inspect +++ b/external/inspect @@ -1 +1 @@ -Subproject commit aaa91a698b98da96e43ed5c630636a8357b0d2be +Subproject commit 6302596e13e5973cb6c5c9da92c78bad5e3b8457 diff --git a/external/sight b/external/sight index ef34a9bf..faddabcd 160000 --- a/external/sight +++ b/external/sight @@ -1 +1 @@ -Subproject commit ef34a9bffa4442026eff719bfd20fa5befbb6586 +Subproject commit faddabcd01a3b38ccfd252f5f4d9164e613007af diff --git a/external/tok b/external/tok index aa164c99..adeaa854 160000 --- a/external/tok +++ b/external/tok @@ -1 +1 @@ -Subproject commit aa164c99d178ca56fb20b1cf612c1e010cc2c117 +Subproject commit adeaa854f482490631d10d6765336713b43ba3c7 diff --git a/external/trace b/external/trace index 54560edd..7006b21c 160000 --- a/external/trace +++ b/external/trace @@ -1 +1 @@ -Subproject commit 54560eddf267ab3741ea545f9e9c0ad422fec352 +Subproject commit 7006b21c183892e20a9f97d0568ed9b1436b6250 diff --git a/external/yaad b/external/yaad index 6ad40135..0add8654 160000 --- a/external/yaad +++ b/external/yaad @@ -1 +1 @@ -Subproject commit 6ad40135f0b3c8e49d6af2d81f59deac8c069dab +Subproject commit 0add8654d8bf21ef1b0fba25481c6d865c59c4c8