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 README.md
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,7 @@ Firstmate's skills live in two separate places with different audiences:
- [docs/cmux-backend.md](docs/cmux-backend.md) - current setup, socket security, and limits for the experimental cmux backend.
- [docs/codex-app-backend.md](docs/codex-app-backend.md) - the current blocked Codex App backend boundary and rollout contract.
- [docs/verification/runtime-backends.md](docs/verification/runtime-backends.md) - active maintainer verification for runtime backend guarantees.
- [docs/gitlab-merge-watch.md](docs/gitlab-merge-watch.md) - maintainer verification for GitLab merge watching on arbitrary instances.
- [docs/gitlab-merge-watch.md](docs/gitlab-merge-watch.md) - maintainer verification for watching and merging GitLab merge requests on arbitrary instances.
- [docs/turnend-guard.md](docs/turnend-guard.md) - the primary session's current "no turn ends blind" backstop, scope, loop safety, and compatibility limits.
- [docs/verification/supervision.md](docs/verification/supervision.md) - active maintainer verification for session-start, guard, continuity, and wedge integrations.
- [docs/supervision-protocols/](docs/supervision-protocols/) - rendered primary-harness watcher protocols for Claude, Codex, OpenCode, Pi and `pi-signed`, Grok, Cursor, and unknown harness fallback.
Expand Down
2 changes: 2 additions & 0 deletions bin/fm-pr-check.sh
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@ fi
# bin/fm-teardown.sh reads the head from the forge at teardown rather than from
# metadata and falls back to its provider-agnostic content check, and
# bin/fm-review-diff.sh resolves the head from the remote when none is recorded.
# bin/fm-pr-merge.sh reads a GitLab head live at merge time for the same reason,
# and treats a recorded value that disagrees as stale rather than authoritative.
WT=$(grep '^worktree=' "$META" | tail -1 | cut -d= -f2- || true)
PR_HEAD=
if [ "$PROVIDER" = github ] && [ -n "$WT" ] && [ -d "$WT" ] && command -v gh >/dev/null 2>&1; then
Expand Down
4 changes: 2 additions & 2 deletions bin/fm-pr-lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -163,8 +163,8 @@ fm_pr_gitlab_path_valid() {
#
# FM_PR_OWNER and FM_PR_REPO are additionally set for github because
# bin/fm-pr-merge.sh addresses GitHub by owner/repository. A gitlab URL leaves
# them empty; teaching the merge path about GitLab is a separate change, and
# until then it refuses a GitLab URL rather than merging anything.
# them empty, and that path addresses the project by FM_PR_HOST and FM_PR_PATH
# instead, so a merge request on any instance resolves without a hardcoded host.
fm_pr_url_parse() {
local raw=${1-} pattern host path
local LC_ALL=C
Expand Down
215 changes: 197 additions & 18 deletions bin/fm-pr-merge.sh
Original file line number Diff line number Diff line change
@@ -1,13 +1,33 @@
#!/usr/bin/env bash
# Merge a task's PR after recording pr= and any available pr_head= through
# Merge a task's PR or MR after recording pr= and any available pr_head= through
# bin/fm-pr-check.sh, so teardown can verify landed work after squash merges.
# The full canonical GitHub PR URL is parsed by bin/fm-pr-lib.sh and the derived
# owner/repository and PR number are passed to gh-axi as separate arguments.
# The full canonical URL is parsed by bin/fm-pr-lib.sh. A GitHub pull request is
# addressed through gh-axi by the derived owner and repository; a GitLab merge
# request is addressed through glab by the project URL rebuilt from the parsed
# host and path, so any instance works and no host is hardcoded.
#
# Merge method defaults to --squash when the caller passes none of --squash,
# --merge, --rebase, or --method after the optional -- separator. Extra args
# must not include --repo or -R because the repository comes only from the URL.
# Usage: fm-pr-merge.sh <task-id> <pr-url> [-- <extra gh-axi pr merge args>]
# Merge method on GitHub defaults to --squash when the caller passes none of
# --squash, --merge, --rebase, or --method after the optional -- separator.
# GitLab adds no method flag at all: its merge method is the project's own
# setting, which the merge API applies, and imposing squash there would override
# that convention rather than mirror the GitHub default.
#
# A GitLab merge is refused unless every pre-merge condition holds, each read
# live at merge time rather than taken from recorded metadata: the merge request
# is open, detailed_merge_status is mergeable, has_conflicts is false,
# blocking_discussions_resolved is true, and the head pipeline succeeded at the
# exact current head commit. Every failing condition is reported, not just the
# first. The verified head is then passed to glab as --sha, so a push that lands
# between that read and the merge fails the merge instead of landing commits
# nothing verified. A recorded pr_head that disagrees with the live head is
# reported rather than trusted, because a rebase moves the head and leaves the
# recorded value stale. Reading that state needs glab and jq, and either one
# absent stops the merge before any state is recorded.
#
# Extra args must not include --repo or -R in any form, including a bundled
# short-option cluster such as -yR, because the repository comes only from the
# URL, nor --sha on GitLab because the head comes only from the live read.
# Usage: fm-pr-merge.sh <task-id> <pr-url> [-- <extra forge merge args>]
set -eu

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
Expand All @@ -24,18 +44,18 @@ if [ "$#" -lt 2 ]; then
fi
ID=$1
RAW_URL=$2
# bin/fm-pr-lib.sh parses GitLab merge request URLs so the watcher can follow
# them, but this path still addresses only GitHub by owner/repository. The
# provider check holds that refusal exactly as it was until merge parity lands.
if ! fm_pr_task_id_valid "$ID" || ! fm_pr_url_parse "$RAW_URL" \
|| [ "$FM_PR_PROVIDER" != github ]; then
if ! fm_pr_task_id_valid "$ID" || ! fm_pr_url_parse "$RAW_URL"; then
echo "error: invalid PR merge request" >&2
exit 2
fi
URL=$FM_PR_URL
PROVIDER=$FM_PR_PROVIDER
PR_OWNER=$FM_PR_OWNER
PR_REPO=$FM_PR_REPO
PR_NUMBER=$FM_PR_NUMBER
# glab resolves the instance from the project URL passed to -R, so the host is
# rebuilt from the parsed identity rather than read from any ambient default.
PROJECT_URL="https://$FM_PR_HOST/$FM_PR_PATH"
shift 2
[ "${1:-}" = "--" ] && shift

Expand All @@ -53,15 +73,35 @@ reject_repo_overrides() {
local arg
for arg in "$@"; do
case "$arg" in
--repo|--repo=*|-R|-R?*)
--repo|--repo=*)
echo "error: extra merge arguments must not override the repository" >&2
return 1
;;
--*) ;;
# A single-dash argument is a short-option cluster, which both CLIs expand
# one character at a time, so -yR carries --repo exactly as a bare -R does.
-*R*)
echo "error: extra merge arguments must not override the repository" >&2
return 1
;;
esac
done
}

reject_head_overrides() {
local arg
for arg in "$@"; do
case "$arg" in
--sha|--sha=*)
echo "error: extra merge arguments must not override the head commit" >&2
return 1
;;
esac
done
}

reject_repo_overrides "$@" || exit 1
[ "$PROVIDER" != gitlab ] || reject_head_overrides "$@" || exit 1

# Task-derived paths are constructed only after the canonical ID validation.
META="$STATE/$ID.meta"
Expand All @@ -70,15 +110,154 @@ if [ ! -f "$META" ] || [ -L "$META" ]; then
exit 1
fi

# Reading the merge request state needs both tools. Report them together and
# before anything is recorded, so a missing tool is a named prerequisite rather
# than a merge that is armed and then refused for an unexplained reason.
GITLAB_MISSING=
if [ "$PROVIDER" = gitlab ]; then
command -v glab >/dev/null 2>&1 || GITLAB_MISSING="glab"
if ! command -v jq >/dev/null 2>&1; then
GITLAB_MISSING="${GITLAB_MISSING:+$GITLAB_MISSING and }jq"
fi
if [ -n "$GITLAB_MISSING" ]; then
echo "error: merging a GitLab merge request requires $GITLAB_MISSING on PATH" >&2
exit 1
fi
fi

# The recorded head is read before bin/fm-pr-check.sh rewrites the metadata,
# because that script re-records pr= and drops a pr_head= it cannot resolve.
RECORDED_HEAD=
if [ "$PROVIDER" = gitlab ]; then
RECORDED_HEAD=$(grep '^pr_head=' "$META" | tail -1 | cut -d= -f2- || true)
fi

"$SCRIPT_DIR/fm-pr-check.sh" "$ID" "$URL"
grep -qxF "pr=$URL" "$META" || {
echo "error: PR metadata recording failed" >&2
exit 1
}

merge_args=()
if ! caller_has_merge_method "$@"; then
merge_args=(--squash)
fi
# Pre-merge conditions for a GitLab merge request, read from one live view of
# the merge request. Sets FM_PR_MERGE_HEAD to the verified head on success and
# returns non-zero after reporting every condition that failed.
FM_PR_MERGE_HEAD=
gitlab_verify_mergeable() {
local json fields line
local total=0 named=0 refusals=''
local state='' detail='' conflicts='' discussions=''
local live_head='' pipeline_sha='' pipeline_status=''

# GITLAB_HOST is set to the same host the project URL already carries, so the
# instance is taken from the parsed URL by both signals and never from the
# operator's configured default.
if ! json=$(GITLAB_HOST="$FM_PR_HOST" glab mr view "$PR_NUMBER" -R "$PROJECT_URL" -F json 2>/dev/null) \
|| [ -z "$json" ]; then
echo "error: could not read the GitLab merge request state before merging" >&2
return 1
fi
# One named field per line. The names keep a trailing empty value readable
# after command substitution strips blank lines, and an absent or null field
# becomes an empty string or the literal "null", neither of which satisfies any
# check below, so an unreadable field refuses the merge instead of passing it.
if ! fields=$(printf '%s' "$json" | jq -r '
if type == "object" then
"state=" + ((.state // "") | tostring),
"detail=" + ((.detailed_merge_status // "") | tostring),
"conflicts=" + (.has_conflicts | tostring),
"discussions=" + (.blocking_discussions_resolved | tostring),
"head=" + ((.sha // "") | tostring),
"pipeline_sha=" + ((.head_pipeline.sha // "") | tostring),
"pipeline_status=" + ((.head_pipeline.status // "") | tostring)
else
error("merge request payload is not an object")
end' 2>/dev/null); then
echo "error: could not read the GitLab merge request state before merging" >&2
return 1
fi
while IFS= read -r line; do
total=$((total + 1))
case "$line" in
state=*) state=${line#state=} ;;
detail=*) detail=${line#detail=} ;;
conflicts=*) conflicts=${line#conflicts=} ;;
discussions=*) discussions=${line#discussions=} ;;
head=*) live_head=${line#head=} ;;
pipeline_sha=*) pipeline_sha=${line#pipeline_sha=} ;;
pipeline_status=*) pipeline_status=${line#pipeline_status=} ;;
*) continue ;;
esac
named=$((named + 1))
done <<FIELDS
$fields
FIELDS
# Every field named exactly once and no unnamed line: a value carrying a
# newline would split into a line no name matches, so it is refused here
# rather than silently truncated into a value a check could accept.
if [ "$named" -ne 7 ] || [ "$total" -ne 7 ]; then
echo "error: could not read the GitLab merge request state before merging" >&2
return 1
fi

if ! fm_pr_head_valid "$live_head"; then
echo "error: could not read the GitLab merge request head commit before merging" >&2
return 1
fi
# A rebase moves the head and leaves the recorded value behind, so the
# disagreement is reported and the live head is what gets verified and merged.
if [ -n "$RECORDED_HEAD" ] && [ "$RECORDED_HEAD" != "$live_head" ]; then
printf 'notice: recorded head %s disagrees with the live head %s; verifying the live head\n' \
"$RECORDED_HEAD" "$live_head" >&2
fi

[ "$state" = opened ] \
|| refusals="$refusals - state is \"${state:-unreadable}\", not open
"
[ "$detail" = mergeable ] \
|| refusals="$refusals - detailed_merge_status is \"${detail:-unreadable}\", not mergeable
"
[ "$conflicts" = false ] \
|| refusals="$refusals - has_conflicts is \"${conflicts:-unreadable}\", not false
"
[ "$discussions" = true ] \
|| refusals="$refusals - blocking_discussions_resolved is \"${discussions:-unreadable}\", not true
"
[ "$pipeline_status" = success ] \
|| refusals="$refusals - the head pipeline status is \"${pipeline_status:-none}\", not success
"
[ "$pipeline_sha" = "$live_head" ] \
|| refusals="$refusals - the head pipeline ran at \"${pipeline_sha:-none}\", not at the current head $live_head
"

if [ -n "$refusals" ]; then
printf 'error: refusing to merge %s\n' "$URL" >&2
printf '%s' "$refusals" >&2
return 1
fi
printf 'verified: %s is open and mergeable, with a successful pipeline at head %s\n' \
"$URL" "$live_head" >&2
FM_PR_MERGE_HEAD=$live_head
}

gh-axi pr merge "$PR_NUMBER" --repo "$PR_OWNER/$PR_REPO" "${merge_args[@]+"${merge_args[@]}"}" "$@"
case "$PROVIDER" in
github)
merge_args=()
if ! caller_has_merge_method "$@"; then
merge_args=(--squash)
fi
gh-axi pr merge "$PR_NUMBER" --repo "$PR_OWNER/$PR_REPO" "${merge_args[@]+"${merge_args[@]}"}" "$@"
;;
gitlab)
gitlab_verify_mergeable || exit 1
# --sha binds the merge to the head this run verified, so a push that lands
# in between is refused by GitLab instead of merged unverified. --yes only
# skips the interactive confirmation, which no supervised run can answer;
# the conditions above are what authorize the merge.
GITLAB_HOST="$FM_PR_HOST" glab mr merge "$PR_NUMBER" -R "$PROJECT_URL" \
--sha "$FM_PR_MERGE_HEAD" --yes "$@"
;;
*)
echo "error: invalid PR merge request" >&2
exit 2
;;
esac
7 changes: 5 additions & 2 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -253,8 +253,11 @@ A ship brief records its mode as a fixed machine-readable line and the spawn ref
When a selected delivery path calls for a diff, `bin/fm-review-diff.sh` refreshes the authoritative base and, when task meta records `pr=`, always fetches and compares against `refs/pull/<n>/head` by default (recorded `pr_head=` is only an offline fallback) before falling back to the local branch with a warning.
Where a no-mistakes pipeline stores evidence in the repo, it publishes that PR-viewable validation evidence to an orphan evidence branch that shares no history with code branches, so it never enters the crew branch or the default branch.
This repo uses that setting, and its own `.no-mistakes/` directory remains local state that stays gitignored and is rejected by CI if tracked; [`configuration.md`](configuration.md) owns the setting.
PR-based task merges go through `bin/fm-pr-merge.sh`, which records `pr=` and any available `pr_head=` through `bin/fm-pr-check.sh` before calling `gh-axi pr merge`.
The helper requires a full `https://github.com/<owner>/<repo>/pull/<n>` URL, invokes `gh-axi pr merge <n> --repo <owner>/<repo>`, defaults to `--squash`, preserves explicit merge-method flags, and rejects malformed URLs or repo override flags before recording merge state; a well-formed GitLab merge request URL (see [docs/gitlab-merge-watch.md](gitlab-merge-watch.md)) is refused too, explicitly, rather than sent to the wrong forge.
PR-based task merges go through `bin/fm-pr-merge.sh`, which records `pr=` and any available `pr_head=` through `bin/fm-pr-check.sh` before calling the forge CLI.
The helper requires a full canonical URL and rejects malformed URLs or repo override flags before recording merge state.
A `https://github.com/<owner>/<repo>/pull/<n>` URL invokes `gh-axi pr merge <n> --repo <owner>/<repo>`, defaults to `--squash`, and preserves explicit merge-method flags.
A `https://<host>/<path>/-/merge_requests/<n>` URL (see [docs/gitlab-merge-watch.md](gitlab-merge-watch.md)) invokes `glab mr merge <n> -R https://<host>/<path>`, so the instance comes from the URL, and adds no merge-method flag because the project's own merge method applies.
That path merges only after one live read of the merge request confirms it is open, mergeable, conflict-free, with blocking discussions resolved and a successful pipeline at the current head, and it binds the merge to that verified head; recorded metadata is never the authority for those conditions because a rebase leaves it stale.
Teardown is fail-closed for ship worktrees: dirty worktrees refuse, and committed work must be landed before the worktree is returned.
[`bin/fm-teardown.sh`](../bin/fm-teardown.sh)'s header owns the landed-work proofs, PR-discovery fallback, and stale-lock recovery procedure.

Expand Down
Loading
Loading