diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8530c5f9b2..407a29c88f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -342,8 +342,8 @@ jobs: bearings_output=$(/bin/bash tests/fm-bearings-snapshot.test.sh) printf '%s\n' "$bearings_output" bearings_count=$(printf '%s\n' "$bearings_output" | grep -c '^ok - ') - [ "$bearings_count" -eq 41 ] || { - echo "::error::expected 41 Bearings tests, got $bearings_count" + [ "$bearings_count" -eq 42 ] || { + echo "::error::expected 42 Bearings tests, got $bearings_count" exit 1 } diff --git a/AGENTS.md b/AGENTS.md index cca7f28d5e..a675a41d43 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -466,6 +466,7 @@ If a ship task touches firstmate's shared tracked material, explicitly require ` If a task will drive Herdr lifecycle behavior, scaffold with `--herdr-lab`; if that need appears after an unguarded scaffold, stop and regenerate rather than adding commands by hand. The generated Herdr contract must use a named non-`default` isolated lab and its guarded helper for every lifecycle action. When a task is linked to an external bead (via `--beads ` at spawn), set `FM_HOOK_BEADS_ID=` before scaffolding so the brief receives Bead Receipt and Bead Closure sections that guide the worker's interaction with the tracking system. +For push-mode ship briefs (direct-PR and no-mistakes) on projects whose git origin is not under the trillium/ namespace (upstream forks the worker cannot push to), the generated brief receives a fork-first push rule that directs the worker to push to the `trillium/` fork and open the PR from there; local-only briefs are exempt, as are briefs for Trillium-owned origins, unreadable origins, or absent clones. Load `secondmate-provisioning` before creating or using a charter brief and preserve its idle-by-default and marked-return-channel contracts. Status appends are sparse supervisor-actionable events, not routine progress; `bin/fm-classify-lib.sh` owns keyed open and resolved semantics. diff --git a/bin/fm-brief.sh b/bin/fm-brief.sh index 412144da00..30dd2f6f13 100755 --- a/bin/fm-brief.sh +++ b/bin/fm-brief.sh @@ -39,6 +39,11 @@ # direct-PR implement -> push + open PR via gh-axi (no pipeline) -> captain merge # local-only implement on branch, stop and report "ready in branch" (no push/PR); # captain approves, firstmate merges to local main +# Push-mode ship briefs (direct-PR, no-mistakes) whose project clone has a +# non-Trillium git origin add a fork-first push rule: push the branch to the +# trillium/ fork and open the PR from there, since the upstream origin +# refuses the push. Detection reads the clone's real origin remote; a Trillium, +# unreadable, or absent origin (and every local-only brief) adds no such rule. # Ship briefs begin with a worktree-isolation assertion before the branch step. # Scout tasks ignore mode - their deliverable is a report, not a merge. # Every scaffold's status protocol distinguishes the configured @@ -132,6 +137,34 @@ shell_quote() { printf "'" } +# Print the bare repository name to fork under trillium/ when a ship task's +# project clone has a non-Trillium git origin (an upstream repo the worker +# cannot push to); print nothing (and succeed) when the origin is Trillium-owned, +# unreadable, or the clone is absent, so the generated brief stays unchanged in +# every case that is not a known upstream fork. Detection reads the clone's real +# `git remote get-url origin`, never data/projects.md prose. +fork_repo_for_origin() { + local repo=$1 dir origin name rest owner + case "$repo" in + /*) dir=$repo ;; + projects/*) dir="${FM_PROJECTS_OVERRIDE:-$FM_HOME/projects}/${repo#projects/}" ;; + *) dir="${FM_PROJECTS_OVERRIDE:-$FM_HOME/projects}/$repo" ;; + esac + origin=$(git -C "$dir" remote get-url origin 2>/dev/null) || return 0 + [ -n "$origin" ] || return 0 + origin=${origin%.git} + origin=${origin%/} + name=${origin##*/} + rest=${origin%/*} + owner=${rest##*/} # https://host/owner/repo -> owner + owner=${owner##*:} # git@host:owner/repo -> owner + case "$(printf '%s' "$owner" | tr '[:upper:]' '[:lower:]')" in + trillium) return 0 ;; + esac + [ -n "$name" ] || return 0 + printf '%s\n' "$name" +} + STATUS_FILE=$(shell_quote "$STATE/$ID.status") if [ "$KIND" = secondmate ]; then @@ -362,6 +395,31 @@ esac # briefs stay byte-identical to the historical Bash 5 output. DOD=${DOD%$'\n'} +# Fork-first push rule: a project whose git origin is the upstream repository +# (not under trillium/) cannot be pushed to directly, so a worker on the push +# modes must push its branch to the trillium/ fork and open the PR from +# there. Only direct-PR and no-mistakes push; local-only never does, so it is +# exempt. The rule text lives here exactly once and is empty (no rule) for +# local-only and for every Trillium-origin, unreadable, or absent-clone case, +# keeping those briefs byte-identical to the pre-rule output. +FORK_FIRST="" +if [ "$MODE" != local-only ]; then + FORK_REPO=$(fork_repo_for_origin "$REPO") + if [ -n "$FORK_REPO" ]; then + IFS= read -r -d '' FORK_FIRST < "$BRIEF" < fork; a +# Trillium-origin project gets no such rule (byte-identical to pre-rule output), +# and local-only never pushes so it stays exempt even on an upstream origin. +make_clone() { + local dir=$1 origin=$2 + mkdir -p "$dir" + git -C "$dir" init -q + git -C "$dir" remote add origin "$origin" +} + +test_fork_first_push_rule() { + local home brief + home="$TMP_ROOT/fork-first-home" + mkdir -p "$home/data" "$home/projects" + # local-only fixture project (for the exemption case) needs the registry mode. + cat > "$home/data/projects.md" <<'EOF' +- upstream-local [local-only] - upstream fork on a local-only project (added 2026-07-01) +EOF + make_clone "$home/projects/upstream-proj" "https://github.com/kunchenguid/gnhf.git" + make_clone "$home/projects/upstream-ssh" "git@github.com:david-tejada/rango.git" + make_clone "$home/projects/trillium-proj" "git@github.com:trillium/firstmate.git" + make_clone "$home/projects/upstream-local" "https://github.com/gastownhall/gascity.git" + + # no-mistakes on a non-Trillium origin: rule present, correct fork named. + FM_HOME="$home" "$ROOT/bin/fm-brief.sh" fork-nm upstream-proj >/dev/null 2>&1 + brief="$home/data/fork-nm/brief.md" + assert_grep "# Fork-based project: all pushes target the fork" "$brief" \ + "no-mistakes brief on an upstream origin lost the fork-first rule" + # shellcheck disable=SC2016 # Literal backticks must stay unexpanded. + assert_grep 'the `trillium/gnhf` fork' "$brief" \ + "no-mistakes fork-first rule named the wrong fork" + assert_grep "never stop to ask fork-vs-local" "$brief" \ + "fork-first rule dropped the never-ask-fork-vs-local instruction" + # shellcheck disable=SC2016 # Literal backticks must stay unexpanded. + assert_grep 'Never push to the upstream `origin`' "$brief" \ + "fork-first rule dropped the never-push-upstream instruction" + + # direct-PR pushes too; SSH origin still resolves the fork name. + cat >> "$home/data/projects.md" <<'EOF' +- upstream-ssh [direct-PR] - upstream fork reached over SSH (added 2026-07-01) +EOF + FM_HOME="$home" "$ROOT/bin/fm-brief.sh" fork-dp upstream-ssh >/dev/null 2>&1 + brief="$home/data/fork-dp/brief.md" + # shellcheck disable=SC2016 # Literal backticks must stay unexpanded. + assert_grep 'the `trillium/rango` fork' "$brief" \ + "direct-PR fork-first rule did not resolve the SSH-origin fork name" + + # Trillium-owned origin: no fork-first rule at all. + FM_HOME="$home" "$ROOT/bin/fm-brief.sh" fork-tr trillium-proj >/dev/null 2>&1 + brief="$home/data/fork-tr/brief.md" + assert_no_grep "# Fork-based project: all pushes target the fork" "$brief" \ + "Trillium-origin brief wrongly carried the fork-first rule" + + # local-only never pushes: exempt even though the origin is upstream. + FM_HOME="$home" "$ROOT/bin/fm-brief.sh" fork-lo upstream-local >/dev/null 2>&1 + brief="$home/data/fork-lo/brief.md" + assert_no_grep "# Fork-based project: all pushes target the fork" "$brief" \ + "local-only brief wrongly carried the fork-first push rule" + pass "fm-brief.sh: fork-first push rule appears only for push modes on non-Trillium origins" +} + # Scout and secondmate paths still scaffold well-formed briefs. test_scout_and_secondmate_scaffold() { local brief @@ -634,4 +696,5 @@ test_secondmate_marked_request_reporting_contract test_secondmate_directory_paths_are_absolute_and_output_is_stable test_pause_verb_override_renders_all_brief_scaffolds test_scout_and_secondmate_load_decision_hold_policy +test_fork_first_push_rule test_scout_and_secondmate_scaffold