#!/bin/sh set -eu repo=$(git rev-parse --show-toplevel) checker="/home/wait4x/.pi/agent/skills/spec/scripts/check_all_spec_packages.py" # Checker resolution: # 1. SPEC_CHECK_ALL_SCRIPT environment override — the explicit operator # escape hatch; the path in effect is echoed to stderr so a swap is # never silent. # 2. The absolute path rendered by install_git_hooks.py, already held in # $checker when the placeholder above was replaced. Repository content # can never replace the gate checker; an unrendered template fails # closed below. if [ -n "${SPEC_CHECK_ALL_SCRIPT:-}" ]; then checker="$SPEC_CHECK_ALL_SCRIPT" printf '%s\n' "spec gate: using SPEC_CHECK_ALL_SCRIPT override: $checker" >&2 fi if [ ! -f "$checker" ]; then printf '%s\n' \ "error: Spec gate checker not found at: $checker" \ " The checker script is expected at the path above." \ " Fix:" \ " 1. Re-run: python3 scripts/install_git_hooks.py --root \"$repo\"" \ " 2. The checker lives in the spec skill package under scripts/" >&2 exit 1 fi python="/home/wait4x/miniconda3/bin/python3.13" python_placeholder_prefix=__SPEC_PYTHON_ python_placeholder="${python_placeholder_prefix}PLACEHOLDER__" if [ "$python" = "$python_placeholder" ]; then python=${SPEC_PYTHON:-$(command -v python3 || true)} fi if [ -z "$python" ]; then printf '%s\n' "error: Spec gate requires a trusted Python interpreter" >&2 exit 1 fi if [ "$python" != "${python##*/}" ] && [ ! -x "$python" ]; then printf '%s\n' "error: Spec gate requires a trusted Python interpreter" >&2 exit 1 fi mode=${SPEC_PUSH_GATE:-touched} case "$mode" in touched|all) ;; *) printf '%s\n' "error: SPEC_PUSH_GATE must be touched or all" >&2; exit 1 ;; esac zero=0000000000000000000000000000000000000000 tmp_dir=$(mktemp -d "${TMPDIR:-/tmp}/spec-pre-push.XXXXXX") baseline_ref="refs/spec-gate/legacy-baseline-$$" cleanup() { git update-ref -d "$baseline_ref" >/dev/null 2>&1 || true rm -rf "$tmp_dir" } trap cleanup EXIT HUP INT TERM remote_name=${1:-} legacy_baseline=$zero if [ -n "$remote_name" ]; then main_branch=${SPEC_MAIN_BRANCH:-} if [ -z "$main_branch" ]; then symref=$(git ls-remote --symref "$remote_name" HEAD) \ || { printf '%s\n' "error: cannot resolve authoritative remote HEAD for $remote_name" >&2; exit 1; } main_branch=$(printf '%s\n' "$symref" | sed -n 's#^ref: refs/heads/\([^[:space:]]*\)[[:space:]]*HEAD$#\1#p' | sed -n '1p') main_branch=${main_branch:-main} fi remote_main=$(git ls-remote --heads "$remote_name" "refs/heads/$main_branch") \ || { printf '%s\n' "error: cannot resolve authoritative $remote_name/$main_branch" >&2; exit 1; } legacy_baseline=$(printf '%s\n' "$remote_main" | sed -n 's/[[:space:]].*$//p' | sed -n '1p') legacy_baseline=${legacy_baseline:-$zero} if [ "$legacy_baseline" != "$zero" ]; then git fetch --no-tags "$remote_name" "+refs/heads/$main_branch:$baseline_ref" >/dev/null 2>&1 \ || { printf '%s\n' "error: cannot fetch authoritative $remote_name/$main_branch baseline" >&2; exit 1; } fetched_baseline=$(git rev-parse --verify "$baseline_ref^{commit}") \ || { printf '%s\n' "error: authoritative remote-main baseline object is unavailable" >&2; exit 1; } [ "$fetched_baseline" = "$legacy_baseline" ] \ || { printf '%s\n' "error: authoritative remote-main baseline changed during pre-push" >&2; exit 1; } fi fi specs_args="" if [ -n "${SPEC_SPECS_DIRS:-}" ]; then old_ifs=$IFS IFS=, for specs_dir in $SPEC_SPECS_DIRS; do case "$specs_dir" in ''|*[!A-Za-z0-9._/-]*) printf '%s\n' "error: SPEC_SPECS_DIRS must be comma-separated trusted relative paths" >&2 exit 1 ;; esac specs_args="$specs_args --specs-dir $specs_dir" done IFS=$old_ifs fi input_path=$tmp_dir/refs cat >"$input_path" index=0 while read -r local_ref local_sha remote_ref remote_sha; do [ -n "${local_sha:-}" ] || continue [ "$local_sha" = "$zero" ] && continue case "$remote_ref" in refs/heads/spec/*) # AUD-07: in-flight handoff. Integration branches carry work in # progress (WIP commits, handoff.md) through the remote; the # irreversible path is the main-branch merge, which keeps the full # gate below. printf '%s\n' \ "spec gate: releasing in-flight push of $remote_ref (integration-branch handoff);" \ "the full gate applies when this work merges to the main branch" >&2 continue ;; esac index=$((index + 1)) files_path=$tmp_dir/files-$index commits_path=$tmp_dir/commits-$index footer_slugs=$tmp_dir/footer-slugs-$index footer_window_base="" : >"$files_path" : >"$commits_path" : >"$footer_slugs" if [ "$remote_sha" = "$zero" ]; then git log --name-only --pretty=format: "$local_sha" --not --remotes >"$files_path" \ || { printf '%s\n' "error: cannot enumerate files for pushed ref $local_ref" >&2; exit 1; } git rev-list --no-merges "$local_sha" --not --remotes >"$commits_path" \ || { printf '%s\n' "error: cannot enumerate commits for pushed ref $local_ref" >&2; exit 1; } else git diff --name-only "$remote_sha" "$local_sha" >"$files_path" \ || { printf '%s\n' "error: cannot diff pushed ref $local_ref" >&2; exit 1; } git rev-list --no-merges "$remote_sha..$local_sha" >"$commits_path" \ || { printf '%s\n' "error: cannot enumerate commits for pushed ref $local_ref" >&2; exit 1; } fi # shellcheck disable=SC2086 scope=$($python "$checker" --root "$repo" --scope-paths-file "$files_path" $specs_args) \ || { printf '%s\n' "error: cannot derive trusted Spec scope for $local_ref" >&2; exit 1; } touched_slugs=$(printf '%s\n' "$scope" | sed -n 's/^SPEC //p') if [ -z "$touched_slugs" ]; then while read -r commit; do [ -n "$commit" ] || continue message=$(git show -s --format=%B "$commit" 2>/dev/null || true) slugs=$(printf '%s\n' "$message" | sed -nE 's/^Spec:[[:space:]]*([a-z0-9][a-z0-9_-]*[a-z0-9]|[a-z0-9])[[:space:]]*$/\1/p') if printf '%s\n' "$message" | grep -qE '^Spec:[[:space:]]*none[[:space:]]*(\([[:space:]]*\))?[[:space:]]*$'; then printf '%s\n' \ "error: pushed commit ${commit}: 'Spec: none' lite channel requires a non-empty reason" \ " Write 'Spec: none ()' in the commit footer and retry." >&2 exit 1 fi if [ -z "$slugs" ]; then # F11 lite channel: `Spec: none ()` attributes a # trivial unpackaged commit. It contributes no package slug, so a # none-only push skips the package gate below; the full gate still # applies to every slug-attributed commit in the same ref. if printf '%s\n' "$message" | grep -qE '^Spec:[[:space:]]*none[[:space:]]*\([^()]+\)[[:space:]]*$'; then continue fi printf '%s\n' \ "error: pushed commit ${commit} has no Spec: attribution" \ " Retro-pack the work into a completed Development Record and amend the commit with its Spec footer," \ " or attribute trivial unpackaged work with 'Spec: none ()', and retry." >&2 exit 1 fi printf '%s\n' "$slugs" >>"$footer_slugs" done <"$commits_path" touched_slugs=$(sort -u "$footer_slugs") # F9 (AUD-06): footer-attributed pushes carry the push-window base so the # single judge inside check_all can warn (observation period) when a cited # archive's recorded commit interval predates this delivery window. if [ "$remote_sha" != "$zero" ]; then footer_window_base=$(git merge-base "$remote_sha" "$local_sha" 2>/dev/null || true) fi fi # A ref deletion, or a ref with no new commits/files, has nothing to gate. [ -n "$touched_slugs" ] || continue set -- if [ "$mode" != "all" ]; then for slug in $touched_slugs; do set -- "$@" --slug "$slug" done fi if [ -n "$footer_window_base" ]; then set -- "$@" --footer-window-base "$footer_window_base" fi # shellcheck disable=SC2086 set -- "$@" $specs_args set -- "$@" --legacy-baseline "$legacy_baseline" "$python" "$checker" --root "$repo" --revision "$local_sha" --require-archived "$@" done <"$input_path"