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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ data/ personal fleet records; LOCAL, gitignored as a whole
captain.md this home's domain-local captain preferences and working style; LOCAL, gitignored, canonical even if harness memory mirrors it, and updated with inspect-then-update
captain-shared.md main-authoritative shared captain preferences propagated read-only to secondmate homes; LOCAL, gitignored, owned by secondmate-provisioning
learnings.md fleet-local operational facts and gotchas; LOCAL, gitignored; dated, evidence-backed, curated, and updated with inspect-then-update - rewrite and prune rather than append forever, the same contract as captain.md; created lazily, absent until this home has a learning to store
projects.md thin fleet navigation registry recording each project's standing delivery posture; firstmate-private, parsed for mechanical sync and seeding by fm-project-mode.sh (section 6)
projects.md thin fleet navigation registry recording each project's standing delivery posture; firstmate-private, with its mechanical parser contract owned by fm-project-mode.sh (section 6)
secondmates.md local and remote secondmate routing table; firstmate-private, maintained by the secondmate seed helpers (section 6)
<id>/brief.md per-task crewmate brief, or per-secondmate charter brief when kind=secondmate
<id>/report.md scout task deliverable, written by the crewmate; survives teardown
Expand Down
20 changes: 7 additions & 13 deletions bin/fm-ff-lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -27,27 +27,21 @@
SUB_HOME_MARKER="${SUB_HOME_MARKER:-.fm-secondmate-home}"
# shellcheck source=bin/fm-secondmate-registry-lib.sh
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/fm-secondmate-registry-lib.sh"
# shellcheck source=bin/fm-tangle-lib.sh
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/fm-tangle-lib.sh"

# --- helpers ---------------------------------------------------------------

first_line() {
printf '%s\n' "$1" | sed -n '1s/[[:space:]]\{1,\}/ /g;1p'
}

local_default_branch() {
fm_local_default_branch "$@"
}

default_branch() {
local dir=$1 ref branch
ref=$(git -C "$dir" symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null || true)
if [ -n "$ref" ]; then
echo "${ref#origin/}"
return 0
fi
for branch in main master; do
if git -C "$dir" show-ref --verify --quiet "refs/heads/$branch"; then
echo "$branch"
return 0
fi
done
return 1
fm_default_branch "$@"
}

# Resolve the PRIMARY checkout's current default-branch commit - the local-HEAD
Expand Down
41 changes: 21 additions & 20 deletions bin/fm-merge-local.sh
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,11 @@
# 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,
# project management, and task lifecycle.
# auto-approves), and only as a clean fast-forward whose task branch contains the
# shared lifecycle resolver's current base. A configured origin must validate;
# remote-less landing is limited to a registered local-only project. It refuses a
# diverged branch and tells you to have the crewmate rebase. See AGENTS.md prime
# directives, project management, and task lifecycle.
# Usage: fm-merge-local.sh <task-id>
set -eu

Expand All @@ -22,35 +24,34 @@ STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}"
# no-op in homes without a branch actor).
# shellcheck source=bin/fm-lease-lib.sh
. "$SCRIPT_DIR/fm-lease-lib.sh"
# shellcheck source=bin/fm-tangle-lib.sh
. "$SCRIPT_DIR/fm-tangle-lib.sh"
fm_lease_forbid_branch "local-only landing (fm-merge-local)"
ID=${1:?usage: fm-merge-local.sh <task-id>}
META="$STATE/$ID.meta"
[ -f "$META" ] || { echo "error: no meta for task $ID at $META" >&2; exit 1; }

PROJ=$(grep '^project=' "$META" | cut -d= -f2-)
WT=$(grep '^worktree=' "$META" | cut -d= -f2-)
MODE=$(grep '^mode=' "$META" | cut -d= -f2- || true)
[ "$MODE" = local-only ] || { echo "error: task $ID is mode=$MODE, not local-only; merge PR tasks with bin/fm-pr-merge.sh <id> <PR url> after approval" >&2; exit 1; }

default_branch() {
local ref branch
ref=$(git -C "$PROJ" symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null || true)
if [ -n "$ref" ]; then
echo "${ref#origin/}"
return 0
fi
for branch in main master; do
if git -C "$PROJ" show-ref --verify --quiet "refs/heads/$branch"; then
echo "$branch"
return 0
fi
done
return 1
}
[ -n "$PROJ" ] || { echo "error: task $ID has no recorded authoritative project" >&2; exit 1; }
[ -n "$WT" ] || { echo "error: task $ID has no recorded worktree" >&2; exit 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; }

DEFAULT=$(default_branch) || { echo "error: cannot determine default branch for $PROJ; expected origin/HEAD, main, or master" >&2; exit 1; }
if ! fm_project_base_resolve "$PROJ" "$WT" yes no; then
echo "error: $FM_PROJECT_BASE_ERROR; cannot merge safely" >&2
exit 1
fi
DEFAULT=$FM_PROJECT_BASE_BRANCH

if ! git -C "$PROJ" merge-base --is-ancestor "$FM_PROJECT_BASE_COMMIT" "$BRANCH"; then
echo "REFUSED: $BRANCH does not contain the current resolved base $FM_PROJECT_BASE_REF." >&2
echo "Have the crewmate rebase $BRANCH onto $FM_PROJECT_BASE_REF, then retry." >&2
exit 1
fi

# The project's main checkout must be on its default branch and clean, so the
# fast-forward lands predictably (firstmate never writes here otherwise).
Expand Down
6 changes: 4 additions & 2 deletions bin/fm-project-mode.sh
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@
# yolo are resolved by firstmate at intake and passed explicitly to
# bin/fm-brief.sh, bin/fm-spawn.sh, and bin/fm-promote.sh (AGENTS.md section 7).
# The consumers are bin/fm-fleet-sync.sh (skip local-only clones),
# bin/fm-home-seed.sh (refuse local-only seeding, run no-mistakes init), and
# bin/fm-spawn.sh's advisory registry-deviation notice.
# bin/fm-home-seed.sh (refuse local-only seeding, run no-mistakes init),
# bin/fm-spawn.sh (delivery advisory), and the shared lifecycle base resolver
# (registered-local-only eligibility for spawn, promotion, review, local landing,
# and cleanup).
#
# Registry line format (data/projects.md):
# - <name> - <desc> (added <date>) -> no-mistakes off (legacy default)
Expand Down
29 changes: 27 additions & 2 deletions bin/fm-promote.sh
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,10 @@
# contract is decided: --mode and --yolo are REQUIRED and written into the meta
# alongside the kind= flip. Firstmate resolves both at promotion time, having just
# read the scout's report (AGENTS.md section 7); data/projects.md holds the
# captain's standing posture as context, and this script never looks it up.
# no-mistakes-prod-only is a registry policy rather than a task mode and is refused.
# captain's standing posture as context. The shared lifecycle-base resolver requires
# task-worktree origin for PR-backed modes and limits remote-less promotion to a
# registered local-only project. no-mistakes-prod-only is a registry policy rather
# than a task mode and is refused.
# Usage: fm-promote.sh <task-id> --mode <no-mistakes|direct-PR|local-only> --yolo <on|off>
set -eu

Expand All @@ -37,6 +39,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-tangle-lib.sh
. "$SCRIPT_DIR/fm-tangle-lib.sh"

MODE=
YOLO=
Expand Down Expand Up @@ -120,6 +124,27 @@ fm_lock_acquire_wait "$META_LOCK"
META_LOCK_HELD=1
[ -f "$META" ] || { echo "error: no meta for task $ID at $META" >&2; exit 1; }
grep -qx 'kind=scout' "$META" || { echo "error: task $ID is not a scout task (kind=scout not in meta)" >&2; exit 1; }
PROMOTE_WT=$(fmx_meta_get "$META" worktree)
PROMOTE_PROJECT=$(fmx_meta_get "$META" project)
[ -n "$PROMOTE_WT" ] || {
echo "error: scout task $ID has no recorded worktree; refusing to create a delivery contract" >&2
exit 1
}
[ -n "$PROMOTE_PROJECT" ] || {
echo "error: scout task $ID has no recorded authoritative project; refusing to create a delivery contract" >&2
exit 1
}
PROMOTE_ALLOW_REMOTELESS=no
PROMOTE_REQUIRE_TASK_ORIGIN=yes
if [ "$MODE" = local-only ]; then
PROMOTE_ALLOW_REMOTELESS=yes
PROMOTE_REQUIRE_TASK_ORIGIN=no
fi
if ! fm_project_base_resolve "$PROMOTE_PROJECT" "$PROMOTE_WT" \
"$PROMOTE_ALLOW_REMOTELESS" "$PROMOTE_REQUIRE_TASK_ORIGIN"; then
echo "error: $FM_PROJECT_BASE_ERROR; refusing to create a $MODE task contract" >&2
exit 1
fi

# 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
Expand Down
67 changes: 35 additions & 32 deletions bin/fm-review-diff.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,17 @@
# Review a crewmate branch against the authoritative base.
#
# Pooled project clones do not keep their local default branch current, so this
# helper compares remote-backed projects against origin/<default> after fetching
# the default branch, and local-only projects against the local default branch.
# helper first validates the authoritative base through the shared lifecycle-base
# resolver. Any configured remote requires origin-backed resolution at its fetched
# current default; an eligible registered local-only project with no remotes uses
# its local default; PR-backed tasks require origin in the task worktree.
# When state/<id>.meta records pr= (URL or number) for an open PR, the compare
# side is ALWAYS a freshly fetched refs/pull/<n>/head by default so review stays
# current after no-mistakes fix rounds push to the PR. A recorded pr_head= is
# only a fallback when fetch fails (stale recorded SHAs must never win over a
# reachable remote PR head). If neither PR head can be resolved, fall back to
# the local branch with a warning. Without pr=, compare the local branch.
# only a fallback when that pull-head fetch fails after base validation (stale
# recorded SHAs must never win over a reachable remote PR head). If neither PR
# head can be resolved, fall back to the local branch with a warning. Without
# pr=, compare the local branch.
# Usage: fm-review-diff.sh <task-id> [--stat]
# --stat prints only the stat summary; default prints stat summary plus full diff.
set -eu
Expand All @@ -18,6 +21,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-tangle-lib.sh
. "$SCRIPT_DIR/fm-tangle-lib.sh"
"$FM_ROOT/bin/fm-guard.sh" || true

usage() {
Expand Down Expand Up @@ -49,23 +54,23 @@ PROJ=$(grep '^project=' "$META" | cut -d= -f2-)
[ -d "$WT" ] || { echo "error: worktree for task $ID is missing: $WT" >&2; exit 1; }
[ -d "$PROJ" ] || { echo "error: project for task $ID is missing: $PROJ" >&2; exit 1; }

default_branch() {
local ref branch
ref=$(git -C "$PROJ" symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null || true)
if [ -n "$ref" ]; then
echo "${ref#origin/}"
return 0
fi
for branch in main master; do
if git -C "$PROJ" show-ref --verify --quiet "refs/heads/$branch"; then
echo "$branch"
return 0
fi
done
return 1
}

DEFAULT=$(default_branch) || { echo "error: cannot determine default branch for $PROJ; expected origin/HEAD, main, or master" >&2; exit 1; }
TASK_KIND=$(grep '^kind=' "$META" | tail -1 | cut -d= -f2- || true)
[ -n "$TASK_KIND" ] || TASK_KIND=ship
TASK_MODE=$(grep '^mode=' "$META" | tail -1 | cut -d= -f2- || true)
if [ -z "$TASK_MODE" ] && [ "$TASK_KIND" != scout ]; then
TASK_MODE=no-mistakes
fi
ALLOW_REMOTELESS=no
REQUIRE_TASK_ORIGIN=yes
if [ "$TASK_KIND" = scout ] || [ "$TASK_MODE" = local-only ]; then
ALLOW_REMOTELESS=yes
REQUIRE_TASK_ORIGIN=no
fi
if ! fm_project_base_resolve "$PROJ" "$WT" "$ALLOW_REMOTELESS" "$REQUIRE_TASK_ORIGIN"; then
echo "error: $FM_PROJECT_BASE_ERROR; refusing to review against an unverified base" >&2
exit 1
fi
DEFAULT=$FM_PROJECT_BASE_BRANCH

BRANCH="fm/$ID"
if ! git -C "$WT" rev-parse --verify --quiet "refs/heads/$BRANCH" >/dev/null; then
Expand Down Expand Up @@ -112,8 +117,8 @@ resolve_pr_head() {
return 0
fi
fi
# Offline / unreachable remote: recorded pr_head is better than the local
# branch, but never preferred over a successful pull-head fetch above.
# A pull-head fetch can fail after base validation; recorded pr_head is then
# better than the local branch, but never preferred over a successful fetch.
if [ -n "$recorded_head" ] \
&& git -C "$WT" cat-file -e "$recorded_head^{commit}" 2>/dev/null; then
printf '%s' "$recorded_head"
Expand All @@ -133,26 +138,24 @@ if [ -n "$PR_URL" ]; then
fi
fi

if git -C "$PROJ" remote get-url origin >/dev/null 2>&1; then
# Update the remote-tracking ref itself; a bare single-branch fetch can leave
# origin/<default> stale on some Git versions and only refresh FETCH_HEAD.
git -C "$WT" fetch origin "+refs/heads/$DEFAULT:refs/remotes/origin/$DEFAULT" --quiet
BASE_REF=$FM_PROJECT_BASE_COMMIT
if [ "$FM_PROJECT_BASE_KIND" = origin ]; then
BASE="origin/$DEFAULT"
else
BASE="$DEFAULT"
fi

git -C "$WT" rev-parse --verify --quiet "$BASE^{commit}" >/dev/null || { echo "error: base $BASE does not exist in $WT" >&2; exit 1; }
git -C "$WT" rev-parse --verify --quiet "$BASE_REF^{commit}" >/dev/null || { echo "error: base $BASE does not exist in $WT" >&2; exit 1; }
git -C "$WT" rev-parse --verify --quiet "$COMPARE_REF^{commit}" >/dev/null || { echo "error: compare ref $COMPARE_REF does not resolve in $WT" >&2; exit 1; }

echo "diff base: $BASE"
if git -C "$WT" diff --quiet "$BASE...$COMPARE_REF" --; then
if git -C "$WT" diff --quiet "$BASE_REF...$COMPARE_REF" --; then
echo "no changes vs $BASE"
exit 0
fi

git -C "$WT" diff --stat "$BASE...$COMPARE_REF" --
git -C "$WT" diff --stat "$BASE_REF...$COMPARE_REF" --
if ! "$STAT_ONLY"; then
echo
git -C "$WT" diff "$BASE...$COMPARE_REF" --
git -C "$WT" diff "$BASE_REF...$COMPARE_REF" --
fi
Loading