Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
9b384bc
docs: record pnpm native-dep build-approval gotchas (#1)
waleedarafa Jun 23, 2026
b11822d
Add guarded local merge target support
waleedarafa Jul 18, 2026
0a3fdc2
no-mistakes(review): Captain, persist local merge targets for safe te…
waleedarafa Jul 18, 2026
e281cfc
no-mistakes(review): Captain, harden merge metadata and report landin…
waleedarafa Jul 18, 2026
020da58
no-mistakes(test): Captain, align stale tests with vocabulary and opt…
waleedarafa Jul 18, 2026
9e62306
no-mistakes(review): Harden local merge target validation and command…
waleedarafa Jul 18, 2026
87e6832
no-mistakes(document): Document explicit local merge targets
waleedarafa Jul 19, 2026
6626bb1
no-mistakes(document): Update target-aware local merge assertion
waleedarafa Jul 19, 2026
c9fc942
merge: preserve fork history with guarded merge target
waleedarafa Jul 19, 2026
e5dff3c
no-mistakes(review): Captain, guard metadata updates and report merge…
waleedarafa Jul 19, 2026
7142d22
no-mistakes: apply CI fixes
waleedarafa Jul 19, 2026
ce3a236
merge: include current upstream firstmate
waleedarafa Jul 19, 2026
c88501d
fix: preserve concurrent merge metadata
waleedarafa Jul 19, 2026
3cf8b03
fix: serialize task metadata replacements
waleedarafa Jul 19, 2026
8f36137
fix: harden metadata lock boundaries
waleedarafa Jul 19, 2026
6083594
fix: bind PR publication to task identity
waleedarafa Jul 19, 2026
6b44bc3
fix: bind PR checks to task instances
waleedarafa Jul 19, 2026
4f0f19a
fix: serialize spawn metadata publication
waleedarafa Jul 19, 2026
c02015c
fix: order same-ID spawn generations
waleedarafa Jul 19, 2026
26e2a71
fix: hold spawn lock through launch
waleedarafa Jul 19, 2026
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
6 changes: 4 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ state/ volatile runtime signals; gitignored
<id>.status appended by crewmates: "<state>: <note>" wake-event lines, not current-state truth
<id>.turn-ended touched by turn-end hooks
<id>.grok-turnend-token firstmate-owned grok hook registry token for the task; removed by teardown
<id>.meta written by fm-spawn: window=, worktree=, project=, harness=, model=, effort=, kind=, mode=, yolo=, tasktmp=; kind=secondmate also records home= and projects=; a non-default runtime backend records further backend-specific fields (docs/configuration.md "Runtime backend"; bin/fm-backend.sh, section 8); fm-pr-check, including through fm-pr-merge, records one canonical pr= and GitHub's pr_head= when available; fm-x-link appends x_request=, x_request_ts=, x_followups=, and optional x_platform=/x_reply_max_chars= for an X-mode-originated task (section 14)
<id>.meta task metadata; docs/configuration.md routes base, backend-specific, and producer-appended fields to their authoritative owners
<id>.check.sh authenticated slow poll; the watcher dispatches validated PR data and the byte-identified X shim through trusted repository scripts, runs registered custom checks from hash-validated private snapshots, and rejects every other state check without execution
<id>.check-trust private content binding created by fm-check-register.sh for an intentional custom check
<id>.pr-poll private validated data sidecar for the byte-static PR merge poll
Expand Down Expand Up @@ -199,10 +199,12 @@ Route durable knowledge to its most specific owner:
- Fleet-local operational facts belong in curated, home-local `data/learnings.md`.
- Task-scoped notes belong with the backlog item, and investigation findings belong in the scout report.
- Knowledge useful to almost every contributor to one project belongs in that project's committed `AGENTS.md`.
- Build and dependency sharp edges belong there, including when a pnpm project with native dependencies requires `pnpm approve-builds` before install, start, or test can avoid `ERR_PNPM_IGNORED_BUILDS`.
- Knowledge general to every firstmate user belongs in this repo's shared tracked surface.

Firstmate never writes a project's `AGENTS.md` directly.
A crewmate creates or updates it lazily through the project's selected delivery path, using `bin/fm-ensure-agents-md.sh` and preferring pointers to authoritative sources over copied detail.
Keep pooled project clones read-only and dependency-free; install or approve dependencies only in isolated project work.
Keep fleet delivery posture and captain-private strategy out of project memory.
When the captain invokes `/stow`, load the `stow` skill for the complete knowledge-routing and unfinished-work sweep.

Expand Down Expand Up @@ -263,7 +265,7 @@ With `yolo` off, the captain owns ask-user findings, PR merges, and local-only m
With `yolo` on, firstmate decides those routine gates and merges only green or otherwise approved work, but still escalates destructive, irreversible, and security-sensitive choices.
Never merge a red PR.
Use `bin/fm-pr-merge.sh` for every task PR merge so merge metadata is recorded, and use `bin/fm-merge-local.sh` for approved local-only landing; never call a lower-level merge command around their guards.
After an autonomous merge, give the captain a one-line full-URL or local-main outcome.
After an autonomous merge, give the captain a one-line full-URL or actual local-branch outcome.

### Validate

Expand Down
7 changes: 4 additions & 3 deletions bin/fm-brief.sh
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
# no-mistakes implement -> /no-mistakes pipeline -> PR -> captain merge (default)
# 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
# captain approves, firstmate merges to the guarded local target
# 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 @@ -295,9 +295,10 @@ EOF
# Definition of done
This project 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.
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.
Keep your branch a clean fast-forward onto its intended landing target.
Unless firstmate tells you that an existing local target was selected, the target is the current default branch; if the target advances, 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.
The configured merge authority approves the ready branch, then firstmate merges it into local \`main\` through the guarded fast-forward path.
The configured merge authority approves the ready branch, then firstmate merges it into the clean, checked-out local target through the guarded fast-forward path.
EOF
)
;;
Expand Down
28 changes: 26 additions & 2 deletions bin/fm-decision-hold.sh
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,15 @@ DATA="${FM_DATA_OVERRIDE:-$FM_HOME/data}"
# shellcheck source=bin/fm-tasks-axi-lib.sh
# shellcheck disable=SC1091
. "$SCRIPT_DIR/fm-tasks-axi-lib.sh"
# shellcheck source=bin/fm-wake-lib.sh
# shellcheck disable=SC1091
. "$SCRIPT_DIR/fm-wake-lib.sh"

DECISION_META=
DECISION_META_LOCKED=0
decision_meta_lock_cleanup() {
[ "$DECISION_META_LOCKED" != 1 ] || fm_meta_lock_release "$DECISION_META"
}

usage() {
awk '
Expand Down Expand Up @@ -280,7 +289,6 @@ command_complete() {
validate_slug origin-id "$origin"
shift
meta="$STATE/$origin.meta"
[ -f "$meta" ] && has_meta=1
require_tasks_axi
origin_exists_here "$origin" || fail "origin $origin is not owned by the active home $FM_HOME"
if [ "$#" -eq 1 ] && [ "$1" = --none ]; then
Expand All @@ -293,10 +301,21 @@ command_complete() {
shift
done
fi
if [ "$has_meta" = 1 ]; then
DECISION_META=$meta
fm_meta_lock_acquire "$meta" || fail "could not acquire metadata lock for $origin"
DECISION_META_LOCKED=1
trap decision_meta_lock_cleanup EXIT
if [ -f "$meta" ] && [ ! -L "$meta" ]; then
has_meta=1
previous=$(meta_value "$meta" decision_keys)
fi
keys=$(sorted_key_union "$previous" "$supplied")
if [ "$has_meta" != 1 ]; then
fm_meta_lock_release "$meta"
DECISION_META_LOCKED=0
DECISION_META=
trap - EXIT
fi
if [ -n "$keys" ]; then
while IFS= read -r key; do
[ -n "$key" ] || continue
Expand All @@ -318,9 +337,14 @@ $open
EOF

if [ "$has_meta" = 1 ]; then
[ -f "$meta" ] && [ ! -L "$meta" ] || fail "task metadata disappeared for $origin"
if [ "$(meta_value "$meta" decisions_reviewed)" != 1 ] || [ "$previous" != "$keys" ]; then
printf 'decisions_reviewed=1\ndecision_keys=%s\n' "$keys" >> "$meta"
fi
fm_meta_lock_release "$meta"
DECISION_META_LOCKED=0
DECISION_META=
trap - EXIT

# Transfer any still-open status decision to its durable backlog owner so the
# live status fold does not duplicate the same Captain's Call item.
Expand Down
188 changes: 162 additions & 26 deletions bin/fm-merge-local.sh
Original file line number Diff line number Diff line change
@@ -1,25 +1,122 @@
#!/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.
# Perform the approved local landing for a local-only ship task by fast-forwarding
# a clean, checked-out local target branch to the task's fm/<id> branch.
#
# 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
# rule #1 "never run state-changing git in projects/", and it is narrow: it only
# runs for mode=local-only tasks, only after the captain approves (or yolo=on
# auto-approves), and only as a clean fast-forward - it refuses a diverged branch
# and tells you to have the crewmate rebase. See AGENTS.md prime directives,
# This is firstmate's sanctioned local merge action after captain approval or
# routine yolo authority. Without --target, the target is the project's default
# branch, preserving the original behavior. An explicit --target must name an
# existing local branch and the project's main local copy must already be clean
# and checked out on it. This command never changes branches or contacts a remote.
# It only runs for mode=local-only tasks and only performs a clean
# `git merge --ff-only` after all guards pass. See AGENTS.md prime directives,
# project management, and task lifecycle.
# Usage: fm-merge-local.sh <task-id>
# After a successful fast-forward it atomically records one
# `local_merge_target=<branch>` in the task metadata for teardown verification.
#
# Usage: fm-merge-local.sh <task-id> [--target <local-branch>]
set -eu

usage() {
cat <<'EOF'
Usage: fm-merge-local.sh <task-id> [--target <local-branch>]

Fast-forward a clean, checked-out local branch to fm/<task-id> for an approved
local-only task. Without --target, the project's default branch is the target.
--target never checks out or creates the requested local branch.
Successful landing records local_merge_target in the task metadata.
EOF
}

fail_usage() {
echo "error: $1" >&2
usage >&2
exit 2
}

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-pr-lib.sh
. "$SCRIPT_DIR/fm-pr-lib.sh"
# shellcheck source=bin/fm-wake-lib.sh
. "$SCRIPT_DIR/fm-wake-lib.sh"
"$FM_ROOT/bin/fm-guard.sh" || true
ID=${1:?usage: fm-merge-local.sh <task-id>}

case "${1:-}" in
-h|--help)
usage
exit 0
;;
'')
fail_usage "missing task id"
;;
-*)
fail_usage "unknown option '$1'"
;;
esac

ID=$1
fm_task_id_path_safe "$ID" || fail_usage "invalid task id"
shift
REQUESTED_TARGET=
if [ "$#" -gt 0 ]; then
case "$1" in
--target)
[ "$#" -ge 2 ] || fail_usage "--target requires a local branch name"
[ "$#" -eq 2 ] || fail_usage "unexpected extra argument '${3:-}'"
[ -n "$2" ] || fail_usage "--target requires a non-empty local branch name"
REQUESTED_TARGET=$2
;;
--*)
fail_usage "unknown option '$1'"
;;
*)
fail_usage "unexpected positional argument '$1'"
;;
esac
fi

validate_target() {
local target=$1 single_quote backslash unsafe_char
case "$target" in
-*|*[[:space:]]*)
fail_usage "unsafe or option-like target branch '$target'"
;;
esac
single_quote=$(printf '\047')
backslash=$(printf '\134')
for unsafe_char in '$' '`' ';' '|' '&' '<' '>' "$backslash" "$single_quote" '"' '(' ')' '{' '}'; do
case "$target" in
*"$unsafe_char"*)
fail_usage "unsafe or option-like target branch '$target'"
;;
esac
done
git check-ref-format --branch "$target" >/dev/null 2>&1 \
|| fail_usage "invalid target branch '$target'"
}

META="$STATE/$ID.meta"
[ -f "$META" ] || { echo "error: no meta for task $ID at $META" >&2; exit 1; }
fm_meta_lock_acquire "$META"
META_LOCKED=1
META_TMP=
merge_local_cleanup() {
[ -z "$META_TMP" ] || rm -f -- "$META_TMP"
[ "${META_LOCKED:-0}" != 1 ] || fm_meta_lock_release "$META"
}
trap merge_local_cleanup EXIT
trap 'exit 1' HUP INT TERM
STATE_DEVICE=$(fm_pr_file_device "$STATE") \
|| { echo "error: task metadata is unavailable" >&2; exit 1; }
if ! fm_pr_regular_destination_on_device_or_absent "$META" "$STATE_DEVICE" || [ ! -f "$META" ]; then
echo "error: task metadata is unavailable" >&2
exit 1
fi
META_DEVICE=$(fm_pr_file_device "$META") || exit 1
META_MODE=$(fm_pr_file_mode "$META") || exit 1
META_IDENTITY=$(fm_pr_file_identity "$META") || exit 1
META_HASH=$(fm_pr_sha256 "$META") || exit 1

PROJ=$(grep '^project=' "$META" | cut -d= -f2-)
MODE=$(grep '^mode=' "$META" | cut -d= -f2- || true)
Expand All @@ -41,28 +138,67 @@ default_branch() {
return 1
}

if [ -n "$REQUESTED_TARGET" ]; then
TARGET=$REQUESTED_TARGET
else
TARGET=$(default_branch) || { echo "error: cannot determine target default branch for $PROJ; expected origin/HEAD, main, or master" >&2; exit 1; }
fi
validate_target "$TARGET"

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; }
TARGET_REF="refs/heads/$TARGET"
BRANCH_REF="refs/heads/$BRANCH"

DEFAULT=$(default_branch) || { echo "error: cannot determine default branch for $PROJ; expected origin/HEAD, main, or master" >&2; exit 1; }
[ "$TARGET" != "$BRANCH" ] || { echo "error: target branch '$TARGET' is the task branch; refusing to merge a branch into itself" >&2; exit 1; }
git -C "$PROJ" show-ref --verify --quiet "$TARGET_REF" || { echo "error: target branch '$TARGET' does not exist locally in $PROJ" >&2; exit 1; }
git -C "$PROJ" show-ref --verify --quiet "$BRANCH_REF" || { echo "error: task branch '$BRANCH' does not exist in $PROJ; target '$TARGET' was not changed" >&2; exit 1; }

# The project's main checkout must be on its default branch and clean, so the
# fast-forward lands predictably (firstmate never writes here otherwise).
cur=$(git -C "$PROJ" symbolic-ref --short HEAD 2>/dev/null || echo "")
[ "$cur" = "$DEFAULT" ] || { echo "error: $PROJ is on '$cur', expected default branch '$DEFAULT'; cannot merge safely" >&2; exit 1; }
# The project's main local copy must already be on the target and clean, so the
# fast-forward lands predictably without checkout, stash, reset, or force.
cur=$(git -C "$PROJ" symbolic-ref --quiet --short HEAD 2>/dev/null || echo "")
[ "$cur" = "$TARGET" ] || { echo "error: $PROJ is on '$cur', expected target branch '$TARGET'; cannot merge safely" >&2; exit 1; }
if [ -n "$(git -C "$PROJ" status --porcelain 2>/dev/null | head -1)" ]; then
echo "error: $PROJ has a dirty working tree; refusing to merge into it" >&2
echo "error: $PROJ has a dirty working tree; refusing to merge into target '$TARGET'" >&2
exit 1
fi

# Clean fast-forward only: DEFAULT must be an ancestor of BRANCH.
if ! git -C "$PROJ" merge-base --is-ancestor "$DEFAULT" "$BRANCH"; then
echo "REFUSED: $BRANCH is not a fast-forward of $DEFAULT (it has diverged)." >&2
echo "Have the crewmate rebase $BRANCH onto $DEFAULT, then retry." >&2
# Clean fast-forward only: the fully qualified target must be an ancestor of the
# fully qualified task branch.
if ! git -C "$PROJ" merge-base --is-ancestor "$TARGET_REF" "$BRANCH_REF"; then
echo "REFUSED: task branch '$BRANCH' is not a fast-forward of target '$TARGET' (it has diverged)." >&2
echo "Have the worker rebase '$BRANCH' onto '$TARGET', then retry." >&2
exit 1
fi

before=$(git -C "$PROJ" rev-parse --short "$DEFAULT")
git -C "$PROJ" merge --ff-only "$BRANCH" >/dev/null
after=$(git -C "$PROJ" rev-parse --short "$DEFAULT")
echo "merged $BRANCH into local $DEFAULT ($before -> $after) in $PROJ"
META_TMP=$(mktemp "$STATE/.fm-merge-local-meta.XXXXXX") || exit 1
while IFS= read -r line || [ -n "$line" ]; do
case "$line" in
local_merge_target=*) ;;
*) printf '%s\n' "$line" >> "$META_TMP" || exit 1 ;;
esac
done < "$META"
printf 'local_merge_target=%s\n' "$TARGET" >> "$META_TMP" || exit 1
chmod "$META_MODE" "$META_TMP" || exit 1
fm_pr_private_file_valid "$META_TMP" "$META_MODE" "$STATE_DEVICE" || exit 1
fm_pr_regular_destination_on_device_or_absent "$META" "$STATE_DEVICE" \
|| { echo "error: task metadata changed during local merge preparation" >&2; exit 1; }
[ "$(fm_pr_file_identity "$META")" = "$META_IDENTITY" ] \
|| { echo "error: task metadata changed during local merge preparation" >&2; exit 1; }
[ "$(fm_pr_sha256 "$META")" = "$META_HASH" ] \
|| { echo "error: task metadata changed during local merge preparation" >&2; exit 1; }

before=$(git -C "$PROJ" rev-parse --short "$TARGET_REF")
git -C "$PROJ" merge --ff-only "$BRANCH_REF" >/dev/null
after=$(git -C "$PROJ" rev-parse --short "$TARGET_REF")
fm_pr_regular_destination_on_device_or_absent "$META" "$META_DEVICE" \
|| { echo "error: task metadata changed during local merge" >&2; exit 1; }
[ "$(fm_pr_file_identity "$META")" = "$META_IDENTITY" ] \
|| { echo "error: task metadata changed during local merge" >&2; exit 1; }
[ "$(fm_pr_sha256 "$META")" = "$META_HASH" ] \
|| { echo "error: task metadata changed during local merge" >&2; exit 1; }
mv -f -- "$META_TMP" "$META" || exit 1
META_TMP=
fm_pr_private_file_valid "$META" "$META_MODE" "$STATE_DEVICE" || exit 1
fm_meta_lock_release "$META"
META_LOCKED=0
echo "merged $BRANCH into local target $TARGET ($before -> $after) in $PROJ"
Loading
Loading