Skip to content
Closed
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: 4 additions & 0 deletions .agents/skills/project-management/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<prefix>` 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.
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
28 changes: 26 additions & 2 deletions bin/fm-bearings-snapshot.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down Expand Up @@ -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/<id> 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
Expand All @@ -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"),
Expand Down
152 changes: 152 additions & 0 deletions bin/fm-branch-lib.sh
Original file line number Diff line number Diff line change
@@ -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/<task-id>` 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-<task-id>`). 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=<prefix>` 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/<id> (the default)
# <seg>/fm- -> branch <seg>/fm-<id> (a repo-compliant prefix)
# The literal `fm-<id>` 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() { # <prefix> <name>
echo "error: project '${2:-?}' has an unsupported branch prefix '$1'; expected 'fm/' or '<segment>/fm-'" >&2
}

# fm_registry_posture_tokens <registry-file> <project-name> -> 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() { # <registry-file> <project-name>
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 <project-name-or-path> -> 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 <project-name-or-path> <task-id> -> 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 <branch-or-headRefName> -> 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 <branch-or-headRefName> -> success if ref is the
# unconfigured default shape (fm/<id>), as opposed to a configured <seg>/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
}
14 changes: 10 additions & 4 deletions bin/fm-brief.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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/<id>.
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.
Expand Down Expand Up @@ -433,19 +439,19 @@ 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="
2. Run \`no-mistakes doctor\`; if it reports the repo is not initialized here, run \`no-mistakes init\`."
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" <<EOF
You are a crewmate: an autonomous worker agent managed by firstmate. Work on your own; do not wait for a human.
Expand All @@ -461,7 +467,7 @@ You are in a disposable git worktree of $REPO, at a detached HEAD on a clean def
The path check is authoritative: \`git rev-parse --git-dir\` and \`git rev-parse --git-common-dir\` can help inspect the repo, but they do not prove you are outside the primary checkout.
If the top-level path is the primary checkout or not the worktree you were launched in, STOP - do not branch or commit here - append \`blocked: launched in primary checkout, not an isolated worktree\` to the status file and stop.

1. First action: create your branch: \`git checkout -b fm/$ID\`$SETUP2
1. First action: create your branch: \`git checkout -b $BRANCH\`$SETUP2

# Rules
$RULE1
Expand Down
14 changes: 8 additions & 6 deletions bin/fm-dod-lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@
# receives. Both paths must hand the worker the same contract: a promoted
# no-mistakes worker that never received the ask-user escalation rule or the
# `--yes` ban is the exact delivery hole this single owner exists to close.
# fm_dod_block <no-mistakes|direct-PR|local-only> <task-id> prints the block on
# stdout with no trailing blank line. The caller validates the mode; an unknown
# fm_dod_block <no-mistakes|direct-PR|local-only> <task-id> [branch] prints the
# block on stdout with no trailing blank line. The optional branch (default
# fm/<task-id>) 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=<mode>"
# line that bin/fm-spawn.sh checks a ship brief against.
Expand Down Expand Up @@ -190,8 +192,8 @@ fm_ask_user_escalation_block() { # <data-dir> <task-id>
EOF
}

fm_dod_block() { # <mode> <task-id>
local mode=$1 id=$2
fm_dod_block() { # <mode> <task-id> <branch>
local mode=$1 id=$2 branch=${3:-fm/$2}
case "$mode" in
direct-PR)
cat <<EOF
Expand All @@ -208,9 +210,9 @@ EOF
# Definition of done
Delivery contract: mode=local-only
This task ships **local-only**: no remote, no PR, no pipeline.
The task is complete only when committed on your branch \`fm/$id\`. Do NOT push, do NOT open a PR, do NOT merge.
The task is complete only when committed on your branch \`$branch\`. Do NOT push, do NOT open a PR, do NOT merge.
Keep your branch a clean fast-forward onto the current default branch - if \`main\` has advanced, rebase onto it so the eventual merge stays a fast-forward.
When it is implemented and committed, append \`done: ready in branch fm/$id\` to the status file and stop.
When it is implemented and committed, append \`done: ready in branch $branch\` to the status file and stop.
The configured merge authority approves the ready branch, then firstmate merges it into local \`main\` through the guarded fast-forward path.
EOF
;;
Expand Down
11 changes: 8 additions & 3 deletions bin/fm-merge-local.sh
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
#!/usr/bin/env bash
# Perform the approved local merge for a local-only ship task: fast-forward the
# project's default branch to the crewmate's fm/<id> branch.
# project's default branch to the crewmate's task branch (bin/fm-branch-lib.sh;
# fm/<id> 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
Expand All @@ -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
Expand Down Expand Up @@ -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; }

Expand Down
Loading