Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .agents/skills/afk/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ afk changes how the captain is informed and what happens at a captain-owned deci
A PR ready for merge keeps the merge authority from `AGENTS.md` section 7, and a needs-decision finding keeps the `ask-user-authority` policy; anything requiring the captain still waits for the captain's explicit word.
While the away-posture record exists, any pull request green at its live head may merge under away authority; which one the captain's words meant is the away session's reading, and a merge the words do not call for holds for the return.
Away authority never releases a captain hold, and it expires when the away record is archived.
`--allow-red` remains attended-only and is refused while the record exists.
`--allow-red` and `--allow-missing` remain attended-only and are refused while the record exists.
A merge under away authority must be synchronous; `fm-pr-merge.sh` refuses auto-merge and any GitHub queue state that cannot prove an immediate merge while the record exists.
The same gates bind whichever actor performs the action: on Pi the parked main's standing authority relocates to the supervision branch, which meets exactly these rules, and the spend cap recorded at entry is enforced by `fm-spawn.sh` for both actors while the record exists.
The captain's away words are their explicit instruction given before leaving, recorded verbatim and acted on by the away session's judgment at the moment an event makes them relevant; the words cover nothing they do not say, are never applied by analogy, and die at archive.
Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -366,7 +366,7 @@ The path's worker, automated gates, and captain approval remain authoritative:

Delivery mode and `yolo` are orthogonal.
`yolo` governs merge authority only: with it off, the captain approves every PR merge and every local-only landing; with it on, firstmate merges green, in-scope work itself.
Never merge a red PR under either setting unless a current explicit captain instruction names the single GitHub check waived through `fm-pr-merge.sh --allow-red`; that attended-only waiver still requires every other check green.
Never merge a red PR, or one with a required check that has not reported, under either setting unless a current explicit captain instruction names the GitHub check to waive; `bin/fm-pr-merge.sh`'s header owns the attended-only waiver mechanics and remaining guards.
Destructive, irreversible, and security-sensitive merges still escalate.
Without a current explicit captain instruction that states the concrete merge, the green default stands, and standing `yolo` cannot authorize a red merge; section 1 owns when such an instruction overrides a Firstmate-written standing rule within its exact scope.
Load `ask-user-authority` before deciding any ask-user finding; the implementation worker never answers its own finding.
Expand Down
2 changes: 1 addition & 1 deletion bin/fm-branch-prompt.sh
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ Away (the record exists): the wake message ends with a `POSTURE: AWAY` tail carr
The record is the captain's away words, recorded verbatim: the explicit instruction the captain gave before leaving, and the whole mandate.
No script parses them; you read them at the tail of every wake, decide by your own judgment whether the event in front of you is the moment they name, and act on them only through the guarded scripts under MAIN's standing authority - never more than MAIN could do attended - which enforce what a script can check without reading words:
- `bin/fm-pr-merge.sh`: a merge the words call for proceeds when the pull request is green at its live head, synchronously, under the record lock; which pull request the words meant is your reading, and any green merge is mechanically permitted while the record exists.
A red pull request is never merged while away, whatever the words say, and `--allow-red` is refused under the record: a merge the words want past a red check holds for the return.
A red pull request, or one with a required check that has not reported, is never merged while away, whatever the words say, and `--allow-red` and `--allow-missing` are refused under the record: a merge the words want past a red or unreported check holds for the return.
- `bin/fm-spawn.sh`: work the words explicitly call for is dispatched within the record's spend cap, from a queued backlog item - one already queued, or one you file yourself for exactly that step under the `backlog` lease, writing its brief intent from the captain's words and a backlog note citing them; filing the item the captain asked for is not inventing work, and anything the words do not call for is.
- `bin/fm-send.sh` and `bin/fm-control.sh`: a run the words say to abort or a worker the words say to steer is steered, as in any posture.
- `bin/fm-send.sh --resolve-key`: a decision the words pre-answer is answered with the captain's own answer, and every other decision only as the ask-user-authority policy at the end of this prompt lets firstmate decide; a finding it says to escalate is reported with verdict captain and left for the return.
Expand Down
216 changes: 194 additions & 22 deletions bin/fm-pr-merge.sh
Original file line number Diff line number Diff line change
Expand Up @@ -12,18 +12,41 @@
# --squash, --merge, --rebase, or --method after the optional -- separator.
# A GitHub merge is refused unless every pre-merge condition holds, each read
# live at merge time rather than taken from recorded metadata: the pull request
# is open, not a draft, mergeable, free of conflicts, and every unwaived check
# is open, not a draft, mergeable, free of conflicts, every unwaived check
# is green at the exact current head commit, where github_checks_not_green below
# owns what makes a check green and judges each one by its current run.
# Every failing condition is reported, not
# just the first. The verified head is then passed to gh as
# owns what makes a check green and judges each one by its current run, and
# every unwaived check the forge requires for the base branch has reported at
# that head. A required check that never reported is absent from the checks
# list rather than red, so github_read_required_contexts below reads the
# required set from classic branch protection and active rulesets. Check-run
# requirements retain their producer app binding: a same-named check run from another app cannot
# satisfy them, and a duplicate name-only entry cannot weaken that binding.
# Unbound requirements match by name. A bound requirement reported as a check
# run also needs a matching producer in the check-runs read at the verified
# head, while one reported as a commit status matches by name, because the
# status carries no app id to compare. Status-creator app binding is not verified
# here, so an attended --attended-override -- --admin merge can bypass that
# protection without a missing-check waiver when a same-named status reported.
# An unreadable producer read still refuses.
# Successfully read requirements remain checked even if another
# source fails, so known missing checks and all read errors are reported together.
# github_branch_rules_unavailable_on_plan owns the narrow plan-unavailable
# exception; every other unreadable required source refuses.
# Every failing condition is reported, not just the first.
# The verified head is then passed to gh as
# --match-head-commit, so a push that lands between that read and the merge
# fails the merge instead of landing commits nothing verified. Reading that
# state needs gh and jq, and either one absent stops the merge before any
# state is recorded. An attended --allow-red <check-name> may be passed once,
# with the name as a separate argument; it waives only checks with that exact
# name, still requires every other check green, and still binds the head. It is
# refused while the away-posture record exists, and it never
# name, still requires every other check green, and still binds the head. Its
# twin, an attended --allow-missing <check-name>, follows the same rules for one
# required check that has not reported: it waives only that exact name, still
# requires every other required check to have reported and every check to be
# green unless separately waived by --allow-red. It matches the required
# context name even for an app-bound requirement, and never waives an unreadable
# required source or producer read. Both are
# refused while the away-posture record exists, and neither
# applies on GitLab, where a merge already requires the head pipeline to have
# succeeded. After gh returns success, GitHub's live state is read back and
# accepted only when the pull request is merged or in the merge queue. gh's
Expand Down Expand Up @@ -102,7 +125,7 @@
# explicit captain instruction and never skips the live green check, the
# away-record read, or a captain hold.
#
# Usage: fm-pr-merge.sh <task-id> <pr-url> [--attended-override] [--allow-red <check-name>] [-- <extra forge merge args>]
# Usage: fm-pr-merge.sh <task-id> <pr-url> [--attended-override] [--allow-red <check-name>] [--allow-missing <check-name>] [-- <extra forge merge args>]
#
# On GitLab, this script confirms the MR is actually merged before reporting it;
# an auto-merge-queued or unconfirmed request leaves the poll armed and records
Expand Down Expand Up @@ -162,6 +185,7 @@ fi
shift 2
ATTENDED_OVERRIDE=false
ALLOW_RED=()
ALLOW_MISSING=()
while [ "$#" -gt 0 ]; do
case "$1" in
--attended-override)
Expand All @@ -182,6 +206,16 @@ while [ "$#" -gt 0 ]; do
echo "error: --allow-red requires a separate check name argument" >&2
exit 2
;;
--allow-missing)
[ -n "${2:-}" ] || { echo "error: --allow-missing requires a check name" >&2; exit 2; }
[ "${#ALLOW_MISSING[@]}" -eq 0 ] || { echo "error: --allow-missing may be specified only once" >&2; exit 2; }
ALLOW_MISSING+=("$2")
shift 2
;;
--allow-missing=*)
echo "error: --allow-missing requires a separate check name argument" >&2
exit 2
;;
--) shift; break ;;
*) break ;;
esac
Expand All @@ -190,6 +224,10 @@ if [ "${#ALLOW_RED[@]}" -gt 0 ] && [ "$PROVIDER" = gitlab ]; then
echo "error: --allow-red does not apply to GitLab, where a merge already requires the head pipeline to have succeeded" >&2
exit 2
fi
if [ "${#ALLOW_MISSING[@]}" -gt 0 ] && [ "$PROVIDER" = gitlab ]; then
echo "error: --allow-missing does not apply to GitLab, where a merge already requires the head pipeline to have succeeded" >&2
exit 2
fi

caller_has_merge_method() {
local arg
Expand Down Expand Up @@ -582,10 +620,94 @@ github_checks_not_green() {
' 2>/dev/null || return 1
}

# Pre-merge conditions for a GitHub pull request, read from one live view.
FM_PR_GITHUB_REQUIRED=
FM_PR_GITHUB_REQUIRED_ERROR=
github_read_required_contexts() {
local base=$1 branch_path branch_json rules_json classic='' ruleset='' api_err api_err_text
FM_PR_GITHUB_REQUIRED='[]'
FM_PR_GITHUB_REQUIRED_ERROR=
branch_path=$(github_urlencode_path_segment "$base")

if ! branch_json=$(gh api "repos/$PR_OWNER/$PR_REPO/branches/$branch_path" 2>/dev/null) \
|| [ -z "$branch_json" ] \
|| ! classic=$(printf '%s' "$branch_json" | jq -c '
if type != "object" or (.protected | type) != "boolean" then
error("branch payload is unreadable")
elif .protected == false then
empty
elif (.protection.required_status_checks | type) != "object" then
error("branch protection summary is unreadable")
else
.protection.required_status_checks
| ((.checks // []) | if type == "array" then .[] else error("invalid checks") end
| {context, app_id}),
((.contexts // []) | if type == "array" then .[] else error("invalid contexts") end
| {context: ., app_id: null})
| if (.context | type) == "string" and (.context | length) > 0
and (.app_id == null or (.app_id | type) == "number")
then . else error("invalid required check") end
| if .app_id == -1 then .app_id = null else . end
end' 2>/dev/null); then
classic=''
FM_PR_GITHUB_REQUIRED_ERROR="the branch protection summary for base branch $base could not be read"
fi

if ! api_err=$(mktemp "${TMPDIR:-/tmp}/fm-pr-merge-required-rules.XXXXXX"); then
FM_PR_GITHUB_REQUIRED_ERROR="${FM_PR_GITHUB_REQUIRED_ERROR:+$FM_PR_GITHUB_REQUIRED_ERROR
}the branch rules for base branch $base could not be read"
else
if ! rules_json=$(gh api --paginate "repos/$PR_OWNER/$PR_REPO/rules/branches/$branch_path" 2>"$api_err"); then
api_err_text=$(cat "$api_err" 2>/dev/null)
if ! github_branch_rules_unavailable_on_plan "$api_err_text"; then
FM_PR_GITHUB_REQUIRED_ERROR="${FM_PR_GITHUB_REQUIRED_ERROR:+$FM_PR_GITHUB_REQUIRED_ERROR
}the branch rules for base branch $base could not be read"
fi
elif [ -z "$rules_json" ] || ! ruleset=$(printf '%s' "$rules_json" | jq -c '
if type != "array" then error("rules payload is unreadable") else .[] end
| select(type != "object" or .type == "required_status_checks")
| if type == "object" and (.parameters.required_status_checks | type) == "array"
then .parameters.required_status_checks[] else error("invalid required check rule") end
| if type == "object" and (.context | type) == "string" and (.context | length) > 0
and (.integration_id == null or (.integration_id | type) == "number")
then {context, app_id: .integration_id} else error("invalid required check rule") end
| if .app_id == -1 then .app_id = null else . end' 2>/dev/null); then
ruleset=''
FM_PR_GITHUB_REQUIRED_ERROR="${FM_PR_GITHUB_REQUIRED_ERROR:+$FM_PR_GITHUB_REQUIRED_ERROR
}the branch rules for base branch $base could not be read"
fi
rm -f "$api_err"
fi

FM_PR_GITHUB_REQUIRED=$(printf '%s\n%s\n' "$classic" "$ruleset" | jq -sc '
unique_by([.context, .app_id]) | group_by(.context)
| map(if any(.[]; .app_id != null) then map(select(.app_id != null)) else . end) | add // []')
[ -z "$FM_PR_GITHUB_REQUIRED_ERROR" ]
}

github_required_checks_missing() {
local json=$1 required=$2 producers=$3
printf '%s' "$json" | jq -r --argjson required "$required" --argjson producers "$producers" '
if (.statusCheckRollup | type) != "array" then error("no check rollup") else . end
| .statusCheckRollup as $reported
| $required
| map(. as $requirement
| select(any($reported[];
if $requirement.app_id == null then
(if .__typename == "CheckRun" then .name else .context end) == $requirement.context
elif .__typename == "CheckRun" then
.name == $requirement.context
and any($producers[]; .name == $requirement.context and .app.id == $requirement.app_id)
else
.context == $requirement.context
end) | not)
| .context) | unique[]
' 2>/dev/null || return 1
}

# Pre-merge conditions from a live PR view, base requirements, and head producers.
# Sets FM_PR_MERGE_HEAD to the verified head on success.
github_verify_mergeable() {
local json fields line red name covered
local json fields line red name covered missing unreported producers runs
local total=0 named=0 refusals=''
local state='' draft='' mergeable='' merge_state='' live_head='' base=''

Expand Down Expand Up @@ -671,13 +793,51 @@ FIELDS
$red
EOF

unreported=''
if ! github_read_required_contexts "$base"; then
while IFS= read -r line; do
refusals="$refusals - $line, so a required check that has not reported cannot be ruled out
"
done <<EOF
$FM_PR_GITHUB_REQUIRED_ERROR
EOF
fi
producers='[]'
if printf '%s' "$FM_PR_GITHUB_REQUIRED" | jq -e 'any(.[]; .app_id != null)' >/dev/null; then
if ! runs=$(gh api --paginate "repos/$PR_OWNER/$PR_REPO/commits/$live_head/check-runs" 2>/dev/null) \
|| [ -z "$runs" ] \
|| ! producers=$(printf '%s' "$runs" | jq -sc --arg head "$live_head" '
[ .[] | if (.check_runs | type) == "array" then .check_runs[] else error("invalid check runs") end
| if (.name | type) == "string" and (.app.id | type) == "number" and .head_sha == $head
then . else error("invalid check producer") end ]' 2>/dev/null); then
producers='[]'
refusals="$refusals - required check producers at head $live_head could not be read
"
fi
fi
if ! missing=$(github_required_checks_missing "$json" "$FM_PR_GITHUB_REQUIRED" "$producers"); then
refusals="$refusals - the GitHub pull request check rollup could not be read
"
else
while IFS= read -r name; do
[ -n "$name" ] || continue
[ "${#ALLOW_MISSING[@]}" -gt 0 ] && [ "${ALLOW_MISSING[0]}" = "$name" ] && continue
refusals="$refusals - required check '$name' has not reported at head $live_head
"
unreported="${unreported:+$unreported, }$name"
done <<EOF
$missing
EOF
fi

if [ -n "$refusals" ]; then
printf 'error: refusing to merge %s\n' "$URL" >&2
printf '%s' "$refusals" >&2
[ -z "$uncovered" ] || printf 'error: these checks are not green: %s\n' "$uncovered" >&2
[ -z "$unreported" ] || printf 'error: these required checks have not reported: %s\n' "$unreported" >&2
return 1
fi
printf 'verified: %s is open and mergeable, with every required check green at head %s\n' \
printf 'verified: %s is open and mergeable, with every unwaived required check reported and every unwaived check green at head %s\n' \
"$URL" "$live_head" >&2
FM_PR_MERGE_HEAD=$live_head
FM_PR_GITHUB_BASE=$base
Expand Down Expand Up @@ -798,6 +958,20 @@ github_urlencode_path_segment() {
printf '%s' "$encoded"
}

# Whether a failed branch-rules read (the gh stderr given) is GitHub's
# plan-gated 403 ("Upgrade to GitHub Pro or make this repository public"),
# which means the repository's plan cannot expose branch rules at all, on
# GitHub or GitHub Enterprise Server - not that this script failed to read
# them, and not that the token lacks a permission. Such a repository has no
# active ruleset rule of any kind. Any other failure (auth, rate limit,
# network, a 404, an unrelated 403) is not this and stays unreadable.
github_branch_rules_unavailable_on_plan() {
case "$1" in
*"Upgrade to GitHub Pro or make this repository public"*) return 0 ;;
esac
return 1
}

# Read the effective merge-queue method for the observed base branch. The four
# situations the refusal has to keep apart - no queue rule, a rules response
# that could not be read, several rules that disagree, and a rule whose method
Expand All @@ -822,18 +996,12 @@ github_read_queue_method() {
2>"$api_err"); then
api_err_text=$(cat "$api_err" 2>/dev/null)
rm -f "$api_err"
# A plan-gated 403 on this endpoint ("Upgrade to GitHub Pro or make this
# repository public") means the repository's plan cannot expose branch
# rules at all, on GitHub or GitHub Enterprise Server - not that this
# script failed to read them. A repository that cannot have branch rules
# cannot have a merge_queue rule either, so that specific 403 resolves to
# no queue rather than the generic unreadable status. Any other failure
# (auth, rate limit, network, a 404, an unrelated 403) stays unreadable.
case "$api_err_text" in
*"Upgrade to GitHub Pro or make this repository public"*)
FM_PR_GITHUB_QUEUE_STATUS=none
;;
esac
# A repository that cannot have branch rules cannot have a merge_queue
# rule either, so that specific refusal resolves to no queue rather than
# the generic unreadable status.
if github_branch_rules_unavailable_on_plan "$api_err_text"; then
FM_PR_GITHUB_QUEUE_STATUS=none
fi
return 0
fi
rm -f "$api_err"
Expand Down Expand Up @@ -950,6 +1118,10 @@ require_current_away_authority() {
echo "error: --allow-red is attended-only; while the away-posture record exists the green check is absolute" >&2
return 2
fi
if [ "$FM_PR_AWAY_POSTURE" = true ] && [ "${#ALLOW_MISSING[@]}" -gt 0 ]; then
echo "error: --allow-missing is attended-only; while the away-posture record exists every required check must report" >&2
return 2
fi
}

persist_accepted_merge_authority() {
Expand Down
Loading
Loading