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
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id>` at spawn), set `FM_HOOK_BEADS_ID=<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/<repo>` fork and open the PR from there; local-only briefs are exempt, as are briefs for Trillium-owned origins, unreadable origins, or absent clones.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Do not edit AGENTS.md directly.

Apply this addition through the selected delivery path with bin/fm-ensure-agents-md.sh. Do not commit this direct file edit.

As per coding guidelines, Firstmate must not write AGENTS.md directly; contributors must update it lazily through bin/fm-ensure-agents-md.sh.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@AGENTS.md` at line 469, Do not edit AGENTS.md directly; apply this generated
brief behavior through the selected delivery path using
bin/fm-ensure-agents-md.sh. Ensure the script handles the fork-first rule for
eligible push-mode briefs while preserving exemptions for local-only briefs,
Trillium-owned origins, unreadable origins, and absent clones.

Source: Coding guidelines


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.
Expand Down
60 changes: 59 additions & 1 deletion bin/fm-brief.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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/<repo> 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
Expand Down Expand Up @@ -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"
Comment on lines +153 to +165

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Reject malformed origin URLs before deriving the fork name.

https://github.com/trillium has no repository segment. This code returns trillium and generates guidance for trillium/trillium. That conflicts with the stated behavior that invalid origins add no rule.

Validate the host, owner, and repository components before printing $name. Add a regression test for an origin with a missing repository component.

Proposed fix
-  origin=${origin%.git}
   origin=${origin%/}
+  origin=${origin%.git}
+  if [[ ! "$origin" =~ ^(https?|ssh)://[^/]+/[^/]+/[^/]+$ ]] &&
+     [[ ! "$origin" =~ ^[^`@/`:]+@[^/:]+:[^/]+/[^/]+$ ]]; then
+    return 0
+  fi
   name=${origin##*/}
   rest=${origin%/*}
   owner=${rest##*/}     # https://host/owner/repo -> owner
   owner=${owner##*:}    # git@host:owner/repo    -> owner
+  [ -n "$owner" ] || return 0
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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"
origin=$(git -C "$dir" remote get-url origin 2>/dev/null) || return 0
[ -n "$origin" ] || return 0
origin=${origin%/}
origin=${origin%.git}
if [[ ! "$origin" =~ ^(https?|ssh)://[^/]+/[^/]+/[^/]+$ ]] &&
[[ ! "$origin" =~ ^[^`@/`:]+@[^/:]+:[^/]+/[^/]+$ ]]; then
return 0
fi
name=${origin##*/}
rest=${origin%/*}
owner=${rest##*/} # https://host/owner/repo -> owner
owner=${owner##*:} # git@host:owner/repo -> owner
[ -n "$owner" ] || return 0
case "$(printf '%s' "$owner" | tr '[:upper:]' '[:lower:]')" in
trillium) return 0 ;;
esac
[ -n "$name" ] || return 0
printf '%s\n' "$name"
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@bin/fm-brief.sh` around lines 153 - 165, Update the origin parsing logic in
the repository-name function to validate the host, owner, and non-empty
repository components before deriving or printing name; reject malformed origins
such as https://github.com/trillium without adding a rule, while preserving the
existing trillium-owner exclusion. Add a regression test covering an origin with
a missing repository segment.

}

STATUS_FILE=$(shell_quote "$STATE/$ID.status")

if [ "$KIND" = secondmate ]; then
Expand Down Expand Up @@ -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/<repo> 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 <<EOF || true

# Fork-based project: all pushes target the fork
This project's \`origin\` is the upstream repository that refuses pushes, so a refused push to \`origin\` is expected, not a blocker.
The \`fm/$ID\` branch and its PR must target the \`trillium/$FORK_REPO\` fork, not upstream.
If the fork does not exist yet, create it with \`gh-axi\`.
Anything that pushes this branch or opens its PR must target the fork, not upstream.
In no-mistakes mode, ensure the pipeline is configured to push to the fork, not upstream.
Never push to the upstream \`origin\`, and never stop to ask fork-vs-local: always use the fork.
**CRITICAL:** a push to the upstream origin must NEVER happen automatically. If pushing to the fork is not possible, stop and get direct captain confirmation before any upstream push attempt.
EOF
fi
fi

cat > "$BRIEF" <<EOF
You are a crewmate: an autonomous worker agent managed by firstmate. Work on your own; do not wait for a human.

Expand Down Expand Up @@ -403,7 +461,7 @@ $RULE1
7. Never stop, restart, or update the shared \`no-mistakes\` daemon - it is one instance serving
every lane/home, so restarting it kills other lanes' in-flight pipeline runs. On ANY no-mistakes
daemon error, append \`blocked: {the daemon error}\` and stop; only firstmate manages the daemon.

$FORK_FIRST
# Project memory
If \`AGENTS.md\` or \`CLAUDE.md\` already exists, or if this task produced durable project-intrinsic knowledge, run \`$FM_ROOT/bin/fm-ensure-agents-md.sh .\` in the worktree.
Record only project knowledge useful to almost every future session.
Expand Down
63 changes: 63 additions & 0 deletions tests/fm-brief.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -598,6 +598,68 @@ test_scout_and_secondmate_load_decision_hold_policy() {
pass "fm-brief.sh: investigation and visual-review completions load the shared decision policy"
}

# Fork-first push rule: a ship task on a project whose git origin is NOT under
# trillium/ must be told to push its branch to the trillium/<repo> 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" "[email protected]:david-tejada/rango.git"
make_clone "$home/projects/trillium-proj" "[email protected]: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
Expand Down Expand Up @@ -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
Loading