From e6aa3ced5fb0b1179265b80f96f2d72d67cd6c97 Mon Sep 17 00:00:00 2001 From: Joey Richter Date: Sun, 13 Sep 2026 12:22:26 -0700 Subject: [PATCH] feat(bin): make a task's git branch prefix configurable per project Firstmate named every task branch fm/ in six places that each re-derived the pattern, so a project whose repository enforces its own branch-naming convention (e.g. a Conventional-Commits check that only exempts prefixes such as chore/, docs/, ci/, build/) had no way to ship a compliant branch, and every firstmate-opened non-ticket PR there tripped that check. Add bin/fm-branch-lib.sh as the single owner of a task's branch name: a per-project prefix read from the data/projects.md registry's optional branch= token (default fm/, so unconfigured projects are unchanged), and the reverse PR-head-to-task-id mapping the bearings board needs. The reverse map is prefix-shape based and leftmost, so a task id that itself begins with fm- is never over-stripped. A shared fm_registry_posture_tokens helper tokenizes the posture bracket once for both fm-branch-lib.sh and fm-project-mode.sh, and a configured prefix of an unsupported shape (including one whose leading segment is fm) fails closed. Route every consumer through it: fm-brief.sh, fm-dod-lib.sh, fm-merge-local.sh, fm-review-diff.sh, fm-promote.sh, and fm-bearings-snapshot.sh. The bearings PR-head link is gated on a known task id for the configured-prefix shape only, so a repository's own human branches are never claimed as tasks while the default fm/ shape keeps its prior behavior. Registry-format docs note the new token, and the stock-Bash CI bearings-count assertion is updated for the added test. --- .agents/skills/project-management/SKILL.md | 4 + .github/workflows/ci.yml | 4 +- bin/fm-bearings-snapshot.sh | 28 +++- bin/fm-branch-lib.sh | 152 +++++++++++++++++++++ bin/fm-brief.sh | 14 +- bin/fm-dod-lib.sh | 14 +- bin/fm-merge-local.sh | 11 +- bin/fm-project-mode.sh | 41 +++--- bin/fm-promote.sh | 15 +- bin/fm-review-diff.sh | 9 +- docs/architecture.md | 2 +- docs/scripts.md | 1 + tests/fm-bearings-snapshot.test.sh | 37 +++++ tests/fm-branch-lib.test.sh | 146 ++++++++++++++++++++ tests/fm-task-delivery.test.sh | 22 +++ 15 files changed, 457 insertions(+), 43 deletions(-) create mode 100644 bin/fm-branch-lib.sh create mode 100644 tests/fm-branch-lib.test.sh diff --git a/.agents/skills/project-management/SKILL.md b/.agents/skills/project-management/SKILL.md index 86e37422d17..482392040e6 100644 --- a/.agents/skills/project-management/SKILL.md +++ b/.agents/skills/project-management/SKILL.md @@ -52,6 +52,10 @@ The optional `+yolo` posture changes merge authority only and does not change th Default it off for every project and every posture, and enable it only on the captain's explicit instruction. `AGENTS.md` section 7 owns the merge-authority contract. +The registry bracket may also carry an optional `branch=` token that overrides this project's git task-branch prefix. +Set it only when the project's own branch rules require a different shape than firstmate's `fm/` default. +`bin/fm-branch-lib.sh` owns the token format, default, and validation. + ## Add or clone an existing project Confirm the source URL, local project name, delivery posture, and autonomy posture, stating the resolved default for each rather than asking the captain to invent one. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 24ade79a846..ce8270f5155 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -434,8 +434,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 59 ] || { - echo "::error::expected 59 Bearings tests, got $bearings_count" + [ "$bearings_count" -eq 60 ] || { + echo "::error::expected 60 Bearings tests, got $bearings_count" exit 1 } diff --git a/bin/fm-bearings-snapshot.sh b/bin/fm-bearings-snapshot.sh index 8f7bda840db..52aaa22f911 100755 --- a/bin/fm-bearings-snapshot.sh +++ b/bin/fm-bearings-snapshot.sh @@ -99,6 +99,9 @@ FLEET="$SCRIPT_DIR/fm-fleet-snapshot.sh" # shellcheck source=bin/fm-landed-lib.sh # shellcheck disable=SC1091 . "$SCRIPT_DIR/fm-landed-lib.sh" # FM_LANDED_JQ_DEFS: the shared landed selector +# shellcheck source=bin/fm-branch-lib.sh +# shellcheck disable=SC1091 +. "$SCRIPT_DIR/fm-branch-lib.sh" # fm_branch_task_id: PR head -> task id (single owner) # Bounds (overridable for tests / large fleets). FM_BEARINGS_LANDED=${FM_BEARINGS_LANDED:-6} @@ -286,6 +289,12 @@ EOF for repo in $repos; do PR_REPOS_TOTAL=$((PR_REPOS_TOTAL + 1)); done nrepos=0; npr=0; nwarn=0; ncapped=0; rows='[]' + # A PR head with the configured, non-default prefix shape (chore/fm-, etc.) is + # claimed as a task row only when its extracted id is a task this fleet + # actually knows, since that shape is also what a repo's humans use for their + # own non-firstmate branches (chore/, feat/, ...). The classic fm/ shape + # was never a human branch convention, so it stays claimed unconditionally. + known_task_ids=$(printf '%s' "$SNAP" | jq -r '.tasks[].id // empty') pr_fetch_limit=$((FM_BEARINGS_PR_LIMIT + 1)) for repo in $repos; do if [ "$ALL_PR_REPOS" != 1 ] && [ "$nrepos" -ge "$FM_BEARINGS_PR_REPOS" ]; then break; fi @@ -294,11 +303,26 @@ EOF --json number,title,url,headRefName,reviewDecision,mergeable,statusCheckRollup 2>/dev/null) \ || { nwarn=$((nwarn + 1)); continue; } [ -n "$out" ] || out='[]' - repo_result=$(printf '%s' "$out" | jq --arg repo "$repo" --argjson limit "$FM_BEARINGS_PR_LIMIT" ' + # Map each PR head back to its task id through the single owner + # (fm_branch_task_id), which undoes whatever prefix the project resolved to + # rather than only the literal fm/. bearings has a repo slug, not a project + # name, so the reverse mapping is deliberately prefix-shape based; only the + # non-default shape (fm_branch_ref_is_default) is then gated on a known task + # id above so a foreign chore/fm-* branch is not claimed. + taskmap=$(printf '%s' "$out" | jq -r '.[].headRefName // empty' | sort -u | while IFS= read -r ref; do + [ -n "$ref" ] || continue + tid=$(fm_branch_task_id "$ref") || continue + if ! fm_branch_ref_is_default "$ref"; then + printf '%s\n' "$known_task_ids" | grep -Fxq -- "$tid" || continue + fi + jq -n --arg k "$ref" --arg v "$tid" '{($k):$v}' + done | jq -s 'add // {}') + [ -n "$taskmap" ] || taskmap='{}' + repo_result=$(printf '%s' "$out" | jq --arg repo "$repo" --argjson limit "$FM_BEARINGS_PR_LIMIT" --argjson taskmap "$taskmap" ' [ .[] | { num:(.number|tostring), repo:$repo, - task:(if (.headRefName // "" | startswith("fm/")) then (.headRefName | ltrimstr("fm/")) else "-" end), + task:($taskmap[(.headRefName // "")] // "-"), url:(.url // "-"), review:(.reviewDecision // "none"), mergeable:(.mergeable // "UNKNOWN"), diff --git a/bin/fm-branch-lib.sh b/bin/fm-branch-lib.sh new file mode 100644 index 00000000000..e03815b38f8 --- /dev/null +++ b/bin/fm-branch-lib.sh @@ -0,0 +1,152 @@ +# shellcheck shell=bash +# Single owner of a task's git branch name. +# Usage: . bin/fm-branch-lib.sh +# +# Firstmate names every task branch `fm/` by default. A project may +# register a different prefix so its branches satisfy that repo's own branch +# rules (e.g. the CRM repo's Conventional-Commits-style require-jira-ticket check +# exempts `chore/`, so a firstmate-opened non-ticket PR there must ship on +# `chore/fm-`). This library is the ONE place that maps a project to its +# branch name; every consumer (bin/fm-brief.sh, bin/fm-dod-lib.sh, +# bin/fm-merge-local.sh, bin/fm-review-diff.sh, bin/fm-promote.sh) resolves the +# name through it rather than re-deriving `fm/$ID`, which is how the pattern +# drifted before. +# +# Per-project configuration lives in the data/projects.md registry as an optional +# `branch=` token inside the posture bracket; bin/fm-project-mode.sh's +# header owns that registry line format. An unconfigured project resolves to the +# unchanged `fm/` prefix, so no existing project's behavior changes. +# +# fm_registry_posture_tokens is the single tokenizer of that bracket; both this +# file's fm_branch_prefix (the branch= token) and bin/fm-project-mode.sh (the +# mode and +yolo tokens) call it instead of each parsing the bracket themselves. +# +# Supported prefix shapes are exactly the two the reverse mapping below can undo: +# fm/ -> branch fm/ (the default) +# /fm- -> branch /fm- (a repo-compliant prefix) +# The literal `fm-` substring in the second shape keeps the branch +# human-recognizable and lets fm_branch_task_id map a PR head back to its task. +# A configured prefix of any other shape fails closed rather than producing a +# branch the reverse mapping cannot recover. + +# Resolve the registry path from the same env the consumers already export. +_fm_branch_registry() { + if [ -n "${FM_DATA_OVERRIDE:-}" ]; then + printf '%s/projects.md\n' "$FM_DATA_OVERRIDE" + elif [ -n "${FM_HOME:-}" ]; then + printf '%s/data/projects.md\n' "$FM_HOME" + elif [ -n "${FM_ROOT:-}" ]; then + printf '%s/data/projects.md\n' "$FM_ROOT" + else + printf 'data/projects.md\n' + fi +} + +_fm_branch_invalid() { # + echo "error: project '${2:-?}' has an unsupported branch prefix '$1'; expected 'fm/' or '/fm-'" >&2 +} + +# fm_registry_posture_tokens -> the project's +# posture-bracket tokens (space-separated, possibly empty), one line on stdout. +# Exits nonzero if the registry file is missing or has no line for the project; +# a matched project with no bracket at all exits 0 with an empty line. This is +# the ONE place that tokenizes a registry line's `[...]` bracket, so fm_branch_prefix +# below and bin/fm-project-mode.sh's mode/+yolo parsing read the same tokens +# rather than two independently maintained bracket parsers. +fm_registry_posture_tokens() { # + local reg=${1:-} name=${2:-} + [ -n "$reg" ] && [ -f "$reg" ] || return 1 + awk -v n="$name" ' + $1=="-" && $2==n { + s=""; + if ($3 ~ /^\[/) { + for (i=3; i<=NF; i++) { s = s (s==""?"":" ") $i; if ($i ~ /\]$/) break } + gsub(/^\[|\]$/, "", s); + } + print s; + found=1; + exit + } + END { if (!found) exit 3 } + ' "$reg" +} + +# fm_branch_prefix -> the branch prefix (default fm/). +# Accepts either a bare registry name or an absolute project path (its basename +# is the registry name), so every consumer calls this with what it already holds. +# Absence of a registry, entry, or branch= token silently yields fm/; a +# malformed configured prefix fails closed with a nonzero status. +fm_branch_prefix() { + local proj=${1:-} name reg prefix found seg tok tokens + case "$proj" in + */*) name=${proj%/}; name=${name##*/} ;; + *) name=$proj ;; + esac + prefix=fm/ + reg=$(_fm_branch_registry) + if [ -n "$name" ]; then + tokens=$(fm_registry_posture_tokens "$reg" "$name") || tokens= + found= + for tok in $tokens; do + case "$tok" in + branch=*) found=${tok#branch=}; break ;; + esac + done + [ -z "$found" ] || prefix=$found + fi + case "$prefix" in + fm/) ;; + */fm-) + seg=${prefix%/fm-} + case "$seg" in + ""|*/*|fm) _fm_branch_invalid "$prefix" "$name"; return 1 ;; + esac + ;; + *) _fm_branch_invalid "$prefix" "$name"; return 1 ;; + esac + printf '%s\n' "$prefix" +} + +# fm_branch_name -> the full branch name. +fm_branch_name() { + local proj=${1:-} id=${2:-} prefix + [ -n "$id" ] || { echo "error: fm_branch_name: missing task id" >&2; return 2; } + prefix=$(fm_branch_prefix "$proj") || return 1 + printf '%s%s\n' "$prefix" "$id" +} + +# fm_branch_task_id -> the task id, or nonzero if the ref +# carries no firstmate prefix. Prefix-agnostic and takes no project, because the +# reverse consumer (bin/fm-bearings-snapshot.sh) has only a repo slug. It undoes +# exactly the two shapes fm_branch_prefix produces, leftmost so a task id that +# itself begins with `fm-` (e.g. fm/fm-foo -> fm-foo) is never over-stripped. +fm_branch_task_id() { + local ref=${1:-} seg rest + case "$ref" in + fm/?*) printf '%s\n' "${ref#fm/}"; return 0 ;; + esac + case "$ref" in + */fm-?*) + seg=${ref%%/*} + rest=${ref#*/} + [ -n "$seg" ] || return 1 + case "$rest" in + fm-?*) printf '%s\n' "${rest#fm-}"; return 0 ;; + esac + ;; + esac + return 1 +} + +# fm_branch_ref_is_default -> success if ref is the +# unconfigured default shape (fm/), as opposed to a configured /fm- +# shape. A caller with only a repo slug (bin/fm-bearings-snapshot.sh) uses this +# to apply extra claim evidence only to the ambiguous configured shape, without +# re-deriving prefix knowledge itself: `fm/` was never a human branch +# convention, so the default shape needs no such gate. +fm_branch_ref_is_default() { + case "${1:-}" in + fm/?*) return 0 ;; + *) return 1 ;; + esac +} diff --git a/bin/fm-brief.sh b/bin/fm-brief.sh index 750f19feba0..9afee986bd7 100755 --- a/bin/fm-brief.sh +++ b/bin/fm-brief.sh @@ -92,6 +92,8 @@ esac . "$SCRIPT_DIR/fm-classify-lib.sh" # shellcheck source=bin/fm-dod-lib.sh . "$SCRIPT_DIR/fm-dod-lib.sh" +# shellcheck source=bin/fm-branch-lib.sh +. "$SCRIPT_DIR/fm-branch-lib.sh" PAUSED_VERB=${FM_CLASSIFY_PAUSED_VERB:-$FM_CLASSIFY_PAUSED_VERB_DEFAULT} resolve_directory_input() { @@ -313,6 +315,10 @@ fi REPO=${POS[1]} +# Resolve this task's branch once through the single owner (bin/fm-branch-lib.sh); +# an unconfigured project yields the unchanged fm/. +BRANCH=$(fm_branch_name "$REPO" "$ID") || exit 1 + if [ "$HERDR_LAB" -eq 1 ]; then HERDR_LAB_HELPER=$(shell_quote "$FM_ROOT/bin/fm-herdr-lab.sh") # shellcheck disable=SC2016 # single quotes are deliberate: these lines are literal brief text whose backtick-wrapped $(...) and "$HERDR_LAB_SESSION" snippets must reach the reading agent verbatim, not expand at scaffold time; only the '"$VAR"' break-outs interpolate. @@ -433,11 +439,11 @@ fi case "$MODE" in direct-PR) SETUP2="" - RULE1='1. Never push to the default branch (push only your `fm/'"$ID"'` branch). Never merge a PR.' + RULE1='1. Never push to the default branch (push only your `'"$BRANCH"'` branch). Never merge a PR.' ;; local-only) SETUP2="" - RULE1="1. Never push to any remote and never open a PR. Work only on your \`fm/$ID\` branch; firstmate handles the merge into local \`main\`." + RULE1="1. Never push to any remote and never open a PR. Work only on your \`$BRANCH\` branch; firstmate handles the merge into local \`main\`." ;; *) # no-mistakes SETUP2=" @@ -445,7 +451,7 @@ case "$MODE" in RULE1='1. Never push to the default branch. Never merge a PR.' ;; esac -DOD=$(fm_dod_block "$MODE" "$ID") || exit 1 +DOD=$(fm_dod_block "$MODE" "$ID" "$BRANCH") || exit 1 cat > "$BRIEF" < prints the block on -# stdout with no trailing blank line. The caller validates the mode; an unknown +# fm_dod_block [branch] prints the +# block on stdout with no trailing blank line. The optional branch (default +# fm/) is resolved once by the caller through bin/fm-branch-lib.sh and +# threaded in, so this owner never re-derives it. The caller validates the mode; an unknown # mode is refused rather than silently rendered as the pipeline contract. # The block opens with the fixed machine-readable "Delivery contract: mode=" # line that bin/fm-spawn.sh checks a ship brief against. @@ -190,8 +192,8 @@ fm_ask_user_escalation_block() { # EOF } -fm_dod_block() { # - local mode=$1 id=$2 +fm_dod_block() { # + local mode=$1 id=$2 branch=${3:-fm/$2} case "$mode" in direct-PR) cat < branch. +# project's default branch to the crewmate's task branch (bin/fm-branch-lib.sh; +# fm/ by default). # # This is firstmate's merge gate-action (the captain's merge authority applied # locally instead of via a GitHub PR). It is the one sanctioned exception to hard @@ -25,6 +26,8 @@ STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" . "$SCRIPT_DIR/fm-pr-lib.sh" # shellcheck source=bin/fm-backlog-transition-lib.sh . "$SCRIPT_DIR/fm-backlog-transition-lib.sh" +# shellcheck source=bin/fm-branch-lib.sh +. "$SCRIPT_DIR/fm-branch-lib.sh" if [ "$#" -ne 1 ] || ! fm_pr_task_id_valid "$1"; then echo "error: invalid local merge request" >&2 exit 2 @@ -90,8 +93,10 @@ default_branch() { return 1 } -BRANCH="fm/$ID" -git -C "$PROJ" rev-parse --verify --quiet "refs/heads/$BRANCH" >/dev/null || { echo "error: branch $BRANCH does not exist in $PROJ" >&2; exit 1; } +# Resolve the branch through the single owner (bin/fm-branch-lib.sh). +BRANCH=$(fm_branch_name "$PROJ" "$ID") || exit 1 +git -C "$PROJ" rev-parse --verify --quiet "refs/heads/$BRANCH" >/dev/null \ + || { echo "error: branch $BRANCH does not exist in $PROJ" >&2; exit 1; } DEFAULT=$(default_branch) || { echo "error: cannot determine default branch for $PROJ; expected origin/HEAD, main, or master" >&2; exit 1; } diff --git a/bin/fm-project-mode.sh b/bin/fm-project-mode.sh index 3046202f23f..06dc8df2170 100755 --- a/bin/fm-project-mode.sh +++ b/bin/fm-project-mode.sh @@ -16,6 +16,13 @@ # - [] - (added ) -> off # - [ +yolo] - (added ) -> on # +# The posture bracket may also carry an optional `branch=` token (in any +# order, e.g. `[direct-PR +yolo branch=chore/fm-]`) that overrides this project's +# git branch prefix; bin/fm-branch-lib.sh is its single owner and default (fm/). +# This script sources that file's fm_registry_posture_tokens to tokenize the same +# bracket and skips the branch= and +yolo tokens when resolving mode, so token +# order never matters for either token. +# # Registered modes: # no-mistakes full pipeline -> PR -> configured merge authority (default) # direct-PR push + PR via gh-axi, no pipeline @@ -42,6 +49,8 @@ FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" DATA="${FM_DATA_OVERRIDE:-$FM_HOME/data}" REG="$DATA/projects.md" +# shellcheck source=bin/fm-branch-lib.sh +. "$SCRIPT_DIR/fm-branch-lib.sh" RAW=0 if [ "${1:-}" = "--raw" ]; then RAW=1 @@ -55,30 +64,22 @@ if [ ! -f "$REG" ]; then exit 0 fi -# awk emits " " (one line) or nothing if the project is absent. -parsed=$(awk -v n="$NAME" ' - $1=="-" && $2==n { - mode="no-mistakes"; yolo="off"; - if ($3 ~ /^\[/) { - s=""; - for (i=3; i<=NF; i++) { s = s (s==""?"":" ") $i; if ($i ~ /\]$/) break } - gsub(/^\[|\]$/, "", s); # strip the surrounding brackets - k = split(s, a, " "); - if (a[1] != "" && a[1] != "+yolo") mode = a[1]; - for (j=1; j<=k; j++) if (a[j]=="+yolo") yolo="on"; - } - print mode, yolo; exit - } -' "$REG") - -if [ -z "$parsed" ]; then +tokens=$(fm_registry_posture_tokens "$REG" "$NAME") || { echo "warn: project \"$NAME\" not in registry; defaulting to no-mistakes off" >&2 echo "no-mistakes off" exit 0 -fi +} -mode=${parsed%% *} -yolo=${parsed##* } +mode=no-mistakes +yolo=off +mode_set= +for tok in $tokens; do + case "$tok" in + +yolo) yolo=on ;; + branch=*) ;; + *) [ -n "$mode_set" ] || { mode=$tok; mode_set=1; } ;; + esac +done case "$mode" in no-mistakes|direct-PR|local-only|no-mistakes-prod-only) ;; *) echo "warn: unknown mode \"$mode\" for $NAME; defaulting to no-mistakes off" >&2; mode=no-mistakes; yolo=off ;; diff --git a/bin/fm-promote.sh b/bin/fm-promote.sh index f0154d5cae5..cbf2c70ce3e 100755 --- a/bin/fm-promote.sh +++ b/bin/fm-promote.sh @@ -5,7 +5,8 @@ # again. Promotion also writes the crewmate's ship instructions to # data//ship-instructions.md and prints the fm-send.sh command that # delivers them. Those instructions carry the scratch-state inventory, the clean -# default-branch base, the fm/ branch, and - rendered from +# default-branch base, the task's branch (bin/fm-branch-lib.sh; fm/ by +# default), and - rendered from # bin/fm-dod-lib.sh, the single owner an ordinary ship brief also uses - the # mode-specific Definition of done, so a promoted worker receives exactly the same # delivery contract as a briefed one, including the no-mistakes mode's ask-user @@ -46,6 +47,8 @@ DATA="${FM_DATA_OVERRIDE:-$FM_HOME/data}" . "$SCRIPT_DIR/fm-secondmate-parent-lib.sh" # shellcheck source=bin/fm-secondmate-registry-lib.sh . "$SCRIPT_DIR/fm-secondmate-registry-lib.sh" +# shellcheck source=bin/fm-branch-lib.sh +. "$SCRIPT_DIR/fm-branch-lib.sh" MODE= YOLO= @@ -153,6 +156,12 @@ if [ -z "$(printf '%s' "$INTENT_BODY" | tr -d '[:space:]')" ]; then exit 1 fi +# Resolve the ship branch once through the single owner (bin/fm-branch-lib.sh) +# from the task's registered project, so a promoted worker creates the same +# prefixed branch a briefed one would; an unconfigured project yields fm/. +PROMOTE_PROJ=$(grep '^project=' "$META" | cut -d= -f2- || true) +BRANCH=$(fm_branch_name "$PROMOTE_PROJ" "$ID") || exit 1 + # The promoted worker must receive the same delivery contract an ordinary ship # brief carries, so the mode-specific Definition of done is rendered from its # single owner (bin/fm-dod-lib.sh) rather than summarised into a hint line. A @@ -179,7 +188,7 @@ EOF ## Firstmate spec 1. **Verify isolation before anything else.** Run \`pwd -P\` and \`git rev-parse --show-toplevel\`; both must resolve to the disposable task worktree you were launched in, such as a treehouse pool path or an Orca-managed worktree, not the primary checkout firstmate operates from. If either does not resolve to the worktree you were launched in, stop and escalate to firstmate. 2. Inventory this worktree's scratch state with \`git status\` and \`git log\` before changing anything. -3. Return to a clean default-branch base, then create your branch: \`git checkout -b fm/$ID\`. +3. Return to a clean default-branch base, then create your branch: \`git checkout -b $BRANCH\`. 4. Carry over only the intended fix changes. Leave scratch commits, debug edits, and experiment files behind. 5. If you reproduced a bug, turn that reproduction into a regression test. 6. These ship instructions supersede the scout delivery rules and report-based Definition of done. Everything else in your original instructions carries over unchanged: the status protocol; the instruction inbox and its acknowledgement; the escalation rules, including ask-user; and every safety rule. @@ -187,7 +196,7 @@ $PROMOTION_ASK_USER_BLOCK 7. Treat the scout-time Firstmate spec and any unmarked legacy \`# Task\` text as investigation context, not captain intent or ship-time instructions. EOF printf '\n' - fm_dod_block "$MODE" "$ID" + fm_dod_block "$MODE" "$ID" "$BRANCH" } > "$TMP" || { echo "error: could not render ship instructions for mode=$MODE" >&2; exit 1; } mv "$TMP" "$INSTRUCTIONS" TMP= diff --git a/bin/fm-review-diff.sh b/bin/fm-review-diff.sh index 06e0efb5bd7..4e769e62fb0 100755 --- a/bin/fm-review-diff.sh +++ b/bin/fm-review-diff.sh @@ -18,6 +18,8 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" +# shellcheck source=bin/fm-branch-lib.sh +. "$SCRIPT_DIR/fm-branch-lib.sh" "$FM_ROOT/bin/fm-guard.sh" || true usage() { @@ -67,10 +69,13 @@ default_branch() { DEFAULT=$(default_branch) || { echo "error: cannot determine default branch for $PROJ; expected origin/HEAD, main, or master" >&2; exit 1; } -BRANCH="fm/$ID" +# Resolve the branch through the single owner (bin/fm-branch-lib.sh). The HEAD +# fallback below also covers an in-flight worktree whose branch predates a +# project's newly configured prefix. +BRANCH=$(fm_branch_name "$PROJ" "$ID") || exit 1 if ! git -C "$WT" rev-parse --verify --quiet "refs/heads/$BRANCH" >/dev/null; then BRANCH=$(git -C "$WT" symbolic-ref --quiet --short HEAD 2>/dev/null || true) - [ -n "$BRANCH" ] || { echo "error: branch fm/$ID does not exist and worktree $WT is detached" >&2; exit 1; } + [ -n "$BRANCH" ] || { echo "error: branch for task $ID does not exist and worktree $WT is detached" >&2; exit 1; } git -C "$WT" rev-parse --verify --quiet "refs/heads/$BRANCH" >/dev/null || { echo "error: branch $BRANCH does not exist in $WT" >&2; exit 1; } fi diff --git a/docs/architecture.md b/docs/architecture.md index 443dcaa99d4..7fa1011a652 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -229,7 +229,7 @@ Only a named non-default branch checked out in `FM_ROOT` is a worktree tangle. `fm-tangle-lib.sh` resolves the default branch from `origin/HEAD`, then local `main` or `master`, and classifies that named non-default primary branch as the tangle. `fm-guard.sh` prints the repair command on the next mutable fleet action, while `bin/fm-session-start.sh` reports the same condition through bootstrap as a `TANGLE:` line at session start. If another live session holds the fleet lock, both surfaces keep the alarm but switch to read-only wording with no repair command. -Ship briefs also tell the crewmate to verify `pwd -P` and `git rev-parse --show-toplevel` before creating `fm/`, then stop with a blocked status if it landed in the primary checkout. +Ship briefs also tell the crewmate to verify `pwd -P` and `git rev-parse --show-toplevel` before creating the task branch (`fm/` by default; `bin/fm-branch-lib.sh` is the single owner of a project's branch prefix), then stop with a blocked status if it landed in the primary checkout. Placement is proven only at launch, so `bin/fm-spawn.sh` also exports the task id as `FM_TASK_ID` into every ship and scout pane, and `bin/fm-test-run.sh` refuses to execute the behavior suite from the primary checkout while that marker is set; the runner's header owns the predicate and [`tests/fm-test-run.test.sh`](../tests/fm-test-run.test.sh) pins it. ## No-mistakes gate authority boundary diff --git a/docs/scripts.md b/docs/scripts.md index 09a27089aae..11ff3caba0b 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -69,6 +69,7 @@ The shared no-mistakes gate refusal for fleet lifecycle entrypoints is summarize | `backends/cmux.sh` | Experimental cmux session-provider adapter | | `fm-config-push.sh` | Push declared inherited local material to live local or remote secondmates and send the placement-specific config reread when changed | | `fm-project-mode.sh` | Resolve a project's registered delivery posture from `data/projects.md` for fleet sync and home seeding | +| `fm-branch-lib.sh` | Single owner of a task's git branch name: per-project prefix from the registry (default `fm/`) and the reverse PR-head-to-task-id mapping | | `fm-merge-local.sh` | Fast-forward a `local-only` project's local default branch after approval | | `fm-review-diff.sh` | Review a crewmate branch or resolved PR head against the authoritative base | | `fm-marker-lib.sh` | Compatibility entry point for the from-firstmate carrier owned by `fm-operational-input.sh` | diff --git a/tests/fm-bearings-snapshot.test.sh b/tests/fm-bearings-snapshot.test.sh index ecdde88a82c..4769c890926 100755 --- a/tests/fm-bearings-snapshot.test.sh +++ b/tests/fm-bearings-snapshot.test.sh @@ -59,6 +59,16 @@ if [ "${FAKE_GH_MANY:-0}" = 1 ]; then JSON exit 0 fi +if [ "${FAKE_GH_MIXED:-0}" = 1 ]; then + # A non-default prefix on a known task, a human chore/fm-* branch that is NOT a + # task, a foreign branch, and a default-shape fm/ branch whose id is NOT in + # this fleet's own snapshot (e.g. a secondmate's own child task). Only the known + # task and the default-shape branch must be claimed. + cat <<'JSON' +[{"number":21,"title":"Prefixed","url":"https://github.com/kunchenguid/firstmate/pull/21","headRefName":"chore/fm-ship-task","reviewDecision":"","mergeable":"MERGEABLE","statusCheckRollup":[]},{"number":22,"title":"Human chore","url":"https://github.com/kunchenguid/firstmate/pull/22","headRefName":"chore/fm-cleanup","reviewDecision":"","mergeable":"MERGEABLE","statusCheckRollup":[]},{"number":23,"title":"Foreign","url":"https://github.com/kunchenguid/firstmate/pull/23","headRefName":"feature/JIRA-1-thing","reviewDecision":"","mergeable":"MERGEABLE","statusCheckRollup":[]},{"number":24,"title":"Unknown default shape","url":"https://github.com/kunchenguid/firstmate/pull/24","headRefName":"fm/unknown-to-this-fleet","reviewDecision":"","mergeable":"MERGEABLE","statusCheckRollup":[]}] +JSON + exit 0 +fi cat <<'JSON' [{"number":9,"title":"Ship the thing","url":"https://github.com/kunchenguid/firstmate/pull/9","headRefName":"fm/ship-task","reviewDecision":"APPROVED","mergeable":"MERGEABLE","statusCheckRollup":[{"conclusion":"SUCCESS","status":"COMPLETED"}]}] JSON @@ -1416,6 +1426,32 @@ test_include_prs_is_the_only_fetch_path() { pass "--include-prs is the only path that fetches, and it enriches correctly" } +test_pr_head_task_link_is_prefix_agnostic_and_evidence_based() { + local home fakebin json + home=$(make_home prs); write_fixture "$home" + fakebin=$(make_fakebin "$home"); : > "$home/net.log" + json=$(FAKE_GH_MIXED=1 run "$home" "$fakebin" --include-prs --json) + # A non-default prefix on a KNOWN task is linked back to the task id. + printf '%s' "$json" | jq -e ' + .candidate_prs | any(.[]; .num == "21" and .task == "ship-task") + ' >/dev/null || fail "a chore/fm- head must link to its task: $json" + # A human chore/fm-* branch that is not a task must NOT be claimed. + printf '%s' "$json" | jq -e ' + .candidate_prs | any(.[]; .num == "22" and .task == "-") + ' >/dev/null || fail "a chore/fm- head must not be claimed as a task: $json" + # A foreign branch is not a firstmate task branch. + printf '%s' "$json" | jq -e ' + .candidate_prs | any(.[]; .num == "23" and .task == "-") + ' >/dev/null || fail "a foreign branch must not be claimed as a task: $json" + # A default-shape fm/ head is claimed unconditionally, even when its id is + # unknown to this fleet's own snapshot (e.g. a secondmate's own child task) - + # the known-id gate applies only to the ambiguous configured-prefix shape. + printf '%s' "$json" | jq -e ' + .candidate_prs | any(.[]; .num == "24" and .task == "unknown-to-this-fleet") + ' >/dev/null || fail "a default fm/ head must be claimed even when the id is unknown to this fleet: $json" + pass "PR head task link is prefix-agnostic and gated on a known task id only for the configured-prefix shape" +} + test_partial_github_failure_degrades() { local home fakebin json rc home=$(make_home partial); write_fixture "$home" @@ -3357,6 +3393,7 @@ test_open_decision_surfaces_end_to_end test_report_pointers_surface test_queued_item_prose_never_hides_it test_include_prs_is_the_only_fetch_path +test_pr_head_task_link_is_prefix_agnostic_and_evidence_based test_partial_github_failure_degrades test_perl_fallback_bounds_github_call test_section_caps_and_expansion_flags diff --git a/tests/fm-branch-lib.test.sh b/tests/fm-branch-lib.test.sh new file mode 100644 index 00000000000..0d430f40d53 --- /dev/null +++ b/tests/fm-branch-lib.test.sh @@ -0,0 +1,146 @@ +#!/usr/bin/env bash +# Behavior tests for bin/fm-branch-lib.sh, the single owner of a task's git +# branch name. Covers the forward map (project -> prefix, default fm/), the +# reverse map (PR head -> task id) that bin/fm-bearings-snapshot.sh relies on, +# name-or-path input normalization, and fail-closed handling of a malformed +# configured prefix. +set -u + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +# shellcheck source=bin/fm-branch-lib.sh +. "$ROOT/bin/fm-branch-lib.sh" + +TMP_ROOT=$(fm_test_tmproot fm-branch-lib) +HOME_DIR="$TMP_ROOT/home" +mkdir -p "$HOME_DIR/data" +REG="$HOME_DIR/data/projects.md" + +cat > "$REG" <<'EOF' +- widget-app [no-mistakes-prod-only branch=chore/fm-] - Example Corp widget platform, github.com/example-corp/widget-app (added 2026-09-11) +- gadget-svc [no-mistakes-prod-only] - Example Corp gadget service (added 2026-09-12) +- yolo-proj [direct-PR +yolo branch=feat/fm-] - a project with yolo and a prefix (added 2026-09-12) +- bad-prefix [no-mistakes branch=chore/deep/fm-] - malformed two-segment prefix (added 2026-09-12) +- fm-collision [no-mistakes branch=fm/fm-] - configured prefix collides with the default (added 2026-09-13) +EOF + +# (1) An unconfigured project resolves to fm/, exactly as today. +test_unconfigured_defaults_to_fm() { + local out + out=$(FM_HOME="$HOME_DIR" fm_branch_name gadget-svc task-1) + assert_equals "fm/task-1" "$out" "a registered project with no branch= token stays fm/" + + out=$(FM_HOME="$HOME_DIR" fm_branch_name not-in-registry task-1) + assert_equals "fm/task-1" "$out" "a project absent from the registry stays fm/" + + # No registry file at all also yields fm/ silently. + out=$(FM_HOME="$TMP_ROOT/empty" fm_branch_name anything task-1) + assert_equals "fm/task-1" "$out" "absent registry stays fm/ with no error" + pass "unconfigured projects resolve to fm/" +} + +# (2) widget-app resolves to chore/fm-. +test_configured_project_resolves_to_custom_prefix() { + local out + out=$(FM_HOME="$HOME_DIR" fm_branch_prefix widget-app) + assert_equals "chore/fm-" "$out" "widget-app prefix is chore/fm-" + + out=$(FM_HOME="$HOME_DIR" fm_branch_name widget-app fm-widget-cleanup) + assert_equals "chore/fm-fm-widget-cleanup" "$out" "widget-app branch keeps the literal fm- substring" + + # The branch= token is order-independent within the bracket (after +yolo here). + out=$(FM_HOME="$HOME_DIR" fm_branch_name yolo-proj task-9) + assert_equals "feat/fm-task-9" "$out" "branch= parses regardless of position in the bracket" + pass "widget-app and peers resolve to their configured prefix" +} + +# (3) The reverse PR-head-to-task-id mapping recovers the id for both shapes, +# including a task id that itself begins with fm- (the greedy trap). +test_reverse_maps_head_to_task_id() { + local out rc + out=$(fm_branch_task_id "fm/task-1"); assert_equals "task-1" "$out" "fm/ -> id" + out=$(fm_branch_task_id "chore/fm-task-1"); assert_equals "task-1" "$out" "chore/fm- -> id" + out=$(fm_branch_task_id "feat/fm-task-9"); assert_equals "task-9" "$out" "feat/fm- -> id" + + # id that starts with fm- must not be over-stripped in either shape. + out=$(fm_branch_task_id "chore/fm-fm-widget-cleanup") + assert_equals "fm-widget-cleanup" "$out" "chore/fm-fm- keeps the fm- in the id" + out=$(fm_branch_task_id "fm/fm-widget-cleanup") + assert_equals "fm-widget-cleanup" "$out" "fm/fm- keeps the fm- in the id" + + # A ref with no firstmate prefix is not a task branch (nonzero, no output). + out=$(fm_branch_task_id "feature/JIRA-123-thing"); rc=$? + expect_code 1 "$rc" "a foreign branch is not a firstmate task branch" + assert_equals "" "$out" "a foreign branch yields no id" + out=$(fm_branch_task_id "main"); rc=$? + expect_code 1 "$rc" "a bare branch name is not a firstmate task branch" + pass "reverse mapping recovers the task id for every produced shape" +} + +# name-or-path input: an absolute project path resolves via its basename, so the +# path-holding consumers (merge-local, review-diff, promote) call the same helper. +test_accepts_name_or_path() { + local out + out=$(FM_HOME="$HOME_DIR" fm_branch_name "/Users/x/projects/widget-app" t2) + assert_equals "chore/fm-t2" "$out" "an absolute project path resolves by basename" + out=$(FM_HOME="$HOME_DIR" fm_branch_name "/Users/x/projects/widget-app/" t2) + assert_equals "chore/fm-t2" "$out" "a trailing slash on the path is tolerated" + pass "forward map accepts a bare name or an absolute path" +} + +# Forward and reverse round-trip for every configured shape. +test_round_trips() { + local proj id branch back + for proj in gadget-svc widget-app yolo-proj; do + for id in plain fm-starts-with-fm; do + branch=$(FM_HOME="$HOME_DIR" fm_branch_name "$proj" "$id") + back=$(fm_branch_task_id "$branch") + assert_equals "$id" "$back" "round-trip $proj/$id via $branch" + done + done + pass "forward then reverse recovers the original task id" +} + +# A configured prefix of an unsupported shape fails closed rather than producing +# a branch the reverse mapping cannot recover. +test_malformed_prefix_fails_closed() { + local out rc + out=$(FM_HOME="$HOME_DIR" fm_branch_name bad-prefix t3 2>/dev/null); rc=$? + expect_code 1 "$rc" "a two-segment prefix is refused" + assert_equals "" "$out" "a malformed prefix yields no branch on stdout" + pass "a malformed configured prefix fails closed" +} + +# A configured prefix whose leading segment is literally `fm` (e.g. fm/fm-) +# would collide with the default fm/ shape in the reverse mapping, so it is +# refused just like any other malformed shape. +test_fm_fm_collision_fails_closed() { + local out rc + out=$(FM_HOME="$HOME_DIR" fm_branch_name fm-collision t4 2>/dev/null); rc=$? + expect_code 1 "$rc" "a branch=fm/fm- prefix is refused" + assert_equals "" "$out" "an fm/fm- prefix yields no branch on stdout" + pass "a branch prefix colliding with the default fm segment fails closed" +} + +# fm_branch_ref_is_default distinguishes the unconfigured fm/ shape from a +# configured /fm- shape, so a caller (bin/fm-bearings-snapshot.sh) can gate +# claim evidence on only the ambiguous configured shape. +test_ref_is_default_classifier() { + fm_branch_ref_is_default "fm/task-1" + expect_code 0 "$?" "fm/ is the default shape" + ! fm_branch_ref_is_default "chore/fm-task-1" + expect_code 0 "$?" "/fm- is not the default shape" + ! fm_branch_ref_is_default "feature/JIRA-123-thing" + expect_code 0 "$?" "a foreign branch is not the default shape either" + pass "fm_branch_ref_is_default classifies the two produced shapes" +} + +test_unconfigured_defaults_to_fm +test_configured_project_resolves_to_custom_prefix +test_reverse_maps_head_to_task_id +test_accepts_name_or_path +test_round_trips +test_malformed_prefix_fails_closed +test_fm_fm_collision_fails_closed +test_ref_is_default_classifier diff --git a/tests/fm-task-delivery.test.sh b/tests/fm-task-delivery.test.sh index bdace4b501e..ce6fcd6ffb4 100755 --- a/tests/fm-task-delivery.test.sh +++ b/tests/fm-task-delivery.test.sh @@ -431,6 +431,27 @@ EOF pass "fm-project-mode: the conditional policy is accepted, mapped for mechanical callers, and readable raw" } +# The posture bracket's branch= token may appear before the mode token (the +# header's documented "any order"). The mode token must still resolve correctly +# rather than being swallowed as a bogus mode and falling back to a warning. +test_project_mode_branch_token_order_independent() { + local home out err + home="$TMP_ROOT/project-mode-order/home" + mkdir -p "$home/data" + cat > "$home/data/projects.md" <<'EOF' +- leadproj [branch=chore/fm- no-mistakes-prod-only] - branch= token before mode (added 2026-09-13) +- yololeadproj [branch=chore/fm- +yolo direct-PR] - branch= then +yolo then mode (added 2026-09-13) +EOF + out=$(FM_HOME="$home" "$PROJECT_MODE" leadproj 2>/dev/null) + [ "$out" = "no-mistakes off" ] || fail "a leading branch= token broke mode resolution (got '$out')" + err=$(FM_HOME="$home" "$PROJECT_MODE" leadproj 2>&1 >/dev/null) + [ -z "$err" ] || fail "a leading branch= token wrongly triggered an unknown-mode warning: $err" + + out=$(FM_HOME="$home" "$PROJECT_MODE" yololeadproj 2>/dev/null) + [ "$out" = "direct-PR on" ] || fail "branch= and +yolo ahead of the mode token broke resolution (got '$out')" + pass "fm-project-mode: the mode token resolves regardless of where branch= sits in the bracket" +} + # Spawn and promotion refuse leftover Task-subsection placeholders through the # public brief/spawn/promote path. Filling both subsections lets the spawn # delivery checks proceed (the fake tmux still fails later). @@ -800,5 +821,6 @@ test_promote_requires_and_records_the_delivery_contract test_promote_refuses_a_symlinked_task_record test_promotion_delivers_the_real_definition_of_done test_project_mode_maps_the_conditional_policy +test_project_mode_branch_token_order_independent test_spawn_and_promote_require_filled_task_subsections echo "# all fm-task-delivery tests passed"