diff --git a/AGENTS.md b/AGENTS.md index 452bcc3b..a106c440 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,6 +33,7 @@ Hard rules, in priority order: 3. **Never tear down a worktree that holds unlanded work.** `bin/fm-teardown.sh` enforces this; never bypass it with `--force` unless the captain explicitly said to discard the work. The work is "landed" once `HEAD` is reachable from any remote-tracking branch (a fork counts as a remote - upstream-contribution PRs pushed to a fork satisfy this in any mode); for a normal ship task whose commits are not so reachable, it is also landed when its PR is merged and GitHub reports a PR head that contains the current local work (including a local `HEAD` that is an ancestor of the PR head, or unpushed local patches that were replayed into that PR head) or when its content is already present in the up-to-date default branch; for `local-only` ship tasks with no remote at all, the work may instead be merged into the local default branch. + A ship task whose meta records `base=` (dispatched with `--base `) treats that branch, not the default branch, as its landing target throughout these checks. The PR consulted for that check comes from the task's recorded `pr=` when present, or - when no `pr=` was ever recorded, e.g. a yolo-authorized merge on a repo with no PR CI where the usual "checks green" `fm-pr-check.sh` trigger never fires - from a merged PR discovered by matching the worktree's own branch name, so a missing `pr=` never by itself false-refuses landed work. Use `bin/fm-pr-merge.sh ` for every merge (captain-requested or yolo) so `pr=` and any available `pr_head=` are recorded as part of the merge itself rather than relying on that discovery fallback. Uncommitted changes are never landed. @@ -93,7 +94,7 @@ state/ volatile runtime signals; gitignored .status appended by crewmates: ": " wake-event lines, not current-state truth .turn-ended touched by turn-end hooks .grok-turnend-token firstmate-owned grok hook registry token for the task; removed by teardown - .meta written by fm-spawn: window=, worktree=, project=, harness=, model=, effort=, kind=, mode=, yolo=, tasktmp=; kind=secondmate also records home= and projects=; a task on a non-default spawn-capable runtime backend also records backend= (absent means tmux, the verified reference backend; herdr records herdr_session=, herdr_workspace_id=, herdr_tab_id=, herdr_pane_id= (docs/herdr-backend.md), zellij records zellij_session=, zellij_tab_id=, zellij_pane_id= (docs/zellij-backend.md), and orca records orca_worktree_id= plus terminal= while keeping window=fm- as the firstmate alias (docs/orca-backend.md); bin/fm-backend.sh, section 8) (fm-pr-check, including through fm-pr-merge, appends pr= and GitHub's pr_head= when available; fm-x-link appends x_request= and x_request_ts= for an X-mention-originated task, section 14) + .meta written by fm-spawn: window=, worktree=, project=, harness=, model=, effort=, kind=, mode=, yolo=, tasktmp=; a ship task dispatched with --base also records base=, its landing target (section 7); kind=secondmate also records home= and projects=; a task on a non-default spawn-capable runtime backend also records backend= (absent means tmux, the verified reference backend; herdr records herdr_session=, herdr_workspace_id=, herdr_tab_id=, herdr_pane_id= (docs/herdr-backend.md), zellij records zellij_session=, zellij_tab_id=, zellij_pane_id= (docs/zellij-backend.md), and orca records orca_worktree_id= plus terminal= while keeping window=fm- as the firstmate alias (docs/orca-backend.md); bin/fm-backend.sh, section 8) (fm-pr-check, including through fm-pr-merge, appends pr= and GitHub's pr_head= when available; fm-x-link appends x_request= and x_request_ts= for an X-mention-originated task, section 14) .check.sh optional slow poll you write per task (e.g. merged-PR check) x-watch.check.sh generated X-mode relay poll shim; present only when opted in (section 14) x-inbox/ generated X-mode pending mention payloads; fmx-respond drains it (section 14) @@ -411,7 +412,7 @@ It sweeps the current session for uncaptured durable knowledge, routes findings - `no-mistakes` (default; `[...]` may be omitted) - full pipeline -> PR -> captain merge. Highest assurance. - `direct-PR` - push + open a PR via `gh-axi`, no pipeline -> captain merge. -- `local-only` - local branch, no remote, no PR; firstmate reviews the diff, the captain approves, firstmate merges to local `main` (section 7). +- `local-only` - local branch, no remote, no PR; firstmate reviews the diff, the captain approves, firstmate merges to local `main` (or the task's recorded base branch; section 7). Orthogonal to mode is an optional `+yolo` flag (`[direct-PR +yolo]`), default off and **not recommended**: with `yolo` on, firstmate makes the approval decisions itself instead of asking the captain (section 7). When the captain adds a project without saying, default to `no-mistakes` with yolo off; only set a faster mode or `+yolo` on the captain's explicit say-so. @@ -495,6 +496,7 @@ bin/fm-spawn.sh projects/ --backend tmux # explicit runtime backend bin/fm-spawn.sh projects/ --backend herdr # experimental herdr backend (docs/herdr-backend.md); version-gates at spawn bin/fm-spawn.sh projects/ --backend zellij # experimental zellij backend (docs/zellij-backend.md); version-gates at spawn bin/fm-spawn.sh projects/ --backend orca # experimental Orca backend (docs/orca-backend.md); Orca owns worktree + terminal; Escape unsupported +bin/fm-spawn.sh projects/ --base # ship task branched from and landing into instead of the default branch; records base= in meta bin/fm-spawn.sh projects/ --scout # scout task; records kind=scout in meta bin/fm-spawn.sh --secondmate # launch a registered persistent secondmate in its home bin/fm-spawn.sh --secondmate # launch or recover an explicit secondmate home @@ -506,6 +508,8 @@ If one pair fails, the rest still run and the batch exits non-zero. When `config/crew-dispatch.json` exists, include a shared `--harness` for every crewmate or scout batch after consulting the dispatch rules. The script resolves the harness (`fm-harness.sh crew` for crewmate/scout tasks only when `config/crew-dispatch.json` is absent, `fm-harness.sh secondmate` for `kind=secondmate`; section 4), resolves the runtime backend (`--backend`, then `FM_BACKEND`, then `config/backend`, then runtime auto-detection - the runtime firstmate itself is executing inside, from `$TMUX`/`HERDR_ENV=1` markers, nesting resolved innermost-first - then `tmux`; an auto-detected herdr spawn prints a loud stderr notice, auto-detected tmux stays silent; zellij is never auto-detected, only explicit `--backend zellij`/`FM_BACKEND=zellij`/`config/backend`), validates the requested backend against spawn-capable adapters, owns the verified launch templates, resolves the project's delivery mode (`fm-project-mode.sh`) for ship/scout tasks, and records `harness=`, `model=`, `effort=`, `kind=`, `mode=`, and `yolo=` in the task's meta; only a non-default runtime backend is recorded as `backend=` because absent means tmux. +An explicit `--base ` (ship tasks only; refused for `--scout` and `--secondmate`) is recorded as `base=` in meta, the single source of truth that makes `fm-brief.sh --base`, `fm-review-diff.sh`, `fm-merge-local.sh`, and teardown's landed-work checks target that branch instead of the default branch; absent flag means no `base=` line and default-branch behavior. +Known `no-mistakes`-mode limitation: `--base` shapes only the branch starting point, the review diff, and teardown's landed-work checks - the no-mistakes pipeline owns PR creation and still opens the PR against its configured push target (typically the default branch), so the PR's landing target is not redirected; the end-to-end `--base` flows are `local-only` and `direct-PR`. A non-flag third argument containing whitespace is treated as a raw launch command (only for verifying new adapters). When `config/crew-dispatch.json` exists, the script refuses crewmate or scout launches without an explicit harness because firstmate must have already resolved the profile choice at intake. When `--model` or `--effort` is omitted, the corresponding meta value is `default` and no launch flag is passed for that axis, except that a `kind=secondmate` spawn can fill the omitted axis from the optional tokens in `config/secondmate-harness`. @@ -540,10 +544,10 @@ A ship task's path from `done` to landed on `main` is set by the project's `mode - **no-mistakes** - the stages below as written: no-mistakes validation pipeline -> PR -> captain merge. - **direct-PR** - no pipeline. The crewmate pushes and opens the PR itself (its brief says so) and reports `done: PR `. Skip the Validate step and go straight to PR ready (run `fm-pr-check`, relay the PR). Teardown uses the normal landed-work check. -- **local-only** - no remote, no PR. The crewmate stops at `done: ready in branch fm/`. Review the diff with `bin/fm-review-diff.sh `, relay a one-paragraph summary to the captain, and on approval run `bin/fm-merge-local.sh ` to fast-forward local `main` (it refuses anything but a clean fast-forward - if it does, have the crewmate rebase). No `fm-pr-check`. Then teardown, whose safety check requires the branch already merged into local `main`, OR the work pushed to any remote (a fork counts - relevant for upstream-contribution PRs on a local-only-registered project). +- **local-only** - no remote, no PR. The crewmate stops at `done: ready in branch fm/`. Review the diff with `bin/fm-review-diff.sh `, relay a one-paragraph summary to the captain, and on approval run `bin/fm-merge-local.sh ` to fast-forward local `main` - or the task's recorded base branch (it refuses anything but a clean fast-forward - if it does, have the crewmate rebase). No `fm-pr-check`. Then teardown, whose safety check requires the branch already merged into local `main` (or the task's recorded base branch), OR the work pushed to any remote (a fork counts - relevant for upstream-contribution PRs on a local-only-registered project). When reviewing any crewmate branch diff, use `bin/fm-review-diff.sh ` rather than `git diff ...branch` directly. -Pooled clones keep their local default refs frozen at clone time and can lag `origin`; the helper always compares against the authoritative base. +Pooled clones keep their local default refs frozen at clone time and can lag `origin`; the helper always compares against the authoritative base (the task's recorded base branch when `base=` is set, else the default branch). **yolo (orthogonal).** With `yolo=off` (default) every approval is the captain's: ask-user findings, PR merges, the local-only merge. With `yolo=on`, firstmate makes those calls itself without asking - resolve ask-user findings on your judgment, and run `bin/fm-pr-merge.sh ` / `bin/fm-merge-local.sh` once the work is green/approved - EXCEPT anything destructive, irreversible, or security-sensitive, which still escalates to the captain. @@ -594,7 +598,7 @@ bin/fm-teardown.sh ``` The script refuses if the worktree holds uncommitted changes or committed work that has not landed; treat a refusal as a stop-and-investigate, not an obstacle. -"Landed" is broader than remote-reachable: for a normal ship task whose commits are not reachable from any remote-tracking branch, the script also accepts the work when its PR is merged and GitHub reports a PR head that contains the current local work, or when its content is already present in the up-to-date default branch. +"Landed" is broader than remote-reachable: for a normal ship task whose commits are not reachable from any remote-tracking branch, the script also accepts the work when its PR is merged and GitHub reports a PR head that contains the current local work, or when its content is already present in the up-to-date default branch (or the task's recorded base branch). Containment means local `HEAD` is the PR head, local `HEAD` is an ancestor of the PR head, or the unpushed local patches have matching patch IDs in that PR head after no-mistakes replayed the branch. This recognizes the common squash-merge-then-delete-branch flow, where the branch's own commits live nowhere on a remote yet the change is fully in `main`; a merged-and-deleted branch now tears down cleanly instead of false-refusing. The PR is looked up from the task's recorded `pr=` when present, or, when no `pr=` was ever recorded, by finding a merged PR whose head branch matches the worktree's branch and fetching its head via `refs/pull//head` if the branch itself was deleted - so a task whose merge skipped `bin/fm-pr-check.sh` (typically a yolo-authorized merge on a repo with no PR CI, where the "checks green" trigger never fires) still tears down cleanly instead of false-refusing. @@ -829,6 +833,7 @@ The ship-brief Setup opens with a worktree-isolation assertion ahead of the bran For a ship task the definition of done is shaped by the project's delivery mode (section 6): `no-mistakes` stops after the implementation commit, then firstmate triggers the harness-appropriate no-mistakes validation pipeline; `direct-PR` has the crewmate push and open the PR itself, and `local-only` has it stop at "ready in branch" for firstmate to review and merge locally. The no-mistakes brief points to no-mistakes' version-matched guidance and keeps only firstmate-specific wrapper rules for `ask-user` escalation, `--yes` avoidance, and the CI-green done line. The scaffold reads the mode via `fm-project-mode.sh`, so you do not pass it. +For a ship task dispatched with `fm-spawn.sh --base `, scaffold with the same `--base `: the Setup then has the crewmate verify the base branch exists in the worktree (fetching it from origin if needed) and branch from it, and the local-only/direct-PR landing wording targets that branch; like `fm-spawn.sh`, the flag is refused for `--scout` and `--secondmate`, and without it the scaffold is byte-identical to the default-branch brief. Ship briefs also include the project-memory contract: run `bin/fm-ensure-agents-md.sh` when the project already has agent-memory files or when the task produced durable project-intrinsic knowledge, then record proportionate learnings in `AGENTS.md`. For scout tasks add `--scout`: the scaffold swaps the definition of done for the report contract (findings to `data//report.md`, no branch, no push, no PR) and declares the worktree scratch; scout is mode-agnostic. Scout briefs do not include the project-memory step, because their deliverable is a report rather than a committed project change. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1d503c52..c8152570 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -88,6 +88,7 @@ tests/fm-secondmate-harness.test.sh # secondmate-vs-crewmate harness resol tests/fm-secondmate-lifecycle-e2e.test.sh # persistent secondmate routing, seeding, backlog handoff, spawn, recovery, teardown, and FM_HOME flow tests tests/fm-secondmate-safety.test.sh # secondmate home safety, idle charter, handoff validation, and teardown boundary tests tests/fm-teardown.test.sh # fm-teardown.sh landed-work safety and reminder checks: fork-remote allow, squash/content landings, dirty and unlanded refusals, PR-head metadata, no-pr= branch discovery, tasks-axi/manual backlog reminder, --force override +tests/fm-base-branch.test.sh # configurable ship base branch: fm-spawn --base meta recording (single and batch) with scout/secondmate refusals, fm-brief --base setup and landing wording, fm-review-diff/fm-merge-local/fm-teardown base targeting, missing-base refusals, and no-base back-compat tests/fm-pr-merge.test.sh # fm-pr-merge.sh records pr= and available pr_head= before merging, parses PR URLs into gh-axi number/--repo calls, defaults to squash, preserves explicit merge methods, rejects malformed URLs and repo overrides, and propagates real merge failures tests/fm-crew-state.test.sh # fm-crew-state.sh current-state reconciliation: run-step authority including closed panes, stale needs-decision/blocked superseded by a resumed run, genuine-parked, cross-branch attribution, pane/status-log fallback, scout skip, torn-down/missing-meta graceful tests/fm-backend.test.sh # runtime-backend abstraction: fm-backend.sh selection/meta/dispatch helpers, and old-vs-new fake-tool command-log conformance for fm-send/fm-peek/fm-spawn/fm-teardown diff --git a/bin/fm-brief.sh b/bin/fm-brief.sh index e0f2ec78..1e019123 100755 --- a/bin/fm-brief.sh +++ b/bin/fm-brief.sh @@ -6,8 +6,20 @@ # description, acceptance criteria, and context, and may adjust other sections # when the task genuinely deviates (e.g. working an existing external PR instead # of shipping a new one). -# Usage: fm-brief.sh [--scout] +# Usage: fm-brief.sh [--base ] [--scout] # fm-brief.sh --secondmate ... +# --base (ship only) makes the scaffolded Setup start the task branch +# from that base branch instead of the clean default-branch detached HEAD +# (verify it exists in the worktree, fetch it from origin if needed, then +# `git checkout -b fm/ `), and points the local-only/direct-PR +# landing wording at it. Pair it with fm-spawn.sh --base so the task's meta +# records the same branch. Refused for --scout and --secondmate. Without the +# flag the output stays byte-identical to the default-branch scaffold. +# no-mistakes-mode limitation: --base shapes only the branch starting point +# in the scaffold; the no-mistakes pipeline owns PR creation and opens the PR +# against its configured push target (typically the default branch), so the +# PR's landing target is NOT redirected. The end-to-end --base flows are +# local-only and direct-PR. # --scout writes the scout contract instead: the deliverable is a report at # data//report.md (no branch, no push, no PR) and the worktree is scratch. # --secondmate writes a persistent secondmate charter. The project list @@ -39,14 +51,36 @@ FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" DATA="${FM_DATA_OVERRIDE:-$FM_HOME/data}" STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" KIND=ship +BASE= +BASE_SET=0 POS=() +want_value= for a in "$@"; do + if [ -n "$want_value" ]; then + case "$a" in + --*) echo "error: --$want_value requires a value" >&2; exit 1 ;; + esac + BASE=$a + BASE_SET=1 + want_value= + continue + fi case "$a" in --scout) KIND=scout ;; --secondmate) KIND=secondmate ;; + --base) want_value=base ;; + --base=*) BASE=${a#--base=}; BASE_SET=1 ;; *) POS+=("$a") ;; esac done +[ -z "$want_value" ] || { echo "error: --$want_value requires a value" >&2; exit 1; } +[ "$BASE_SET" -eq 0 ] || [ -n "$BASE" ] || { echo "error: --base requires a non-empty value" >&2; exit 1; } +# --base shapes the ship branch/landing contract; a scout worktree is scratch +# and a secondmate charter has no task branch, so both refuse it. +if [ -n "$BASE" ] && [ "$KIND" != ship ]; then + echo "error: --base applies only to ship briefs; refusing for --$KIND" >&2 + exit 1 +fi ID=${POS[0]} BRIEF="$DATA/$ID/brief.md" @@ -171,6 +205,24 @@ read -r MODE _ < branch. +# project's default branch - or the task's recorded base= branch (a ship task +# dispatched with fm-spawn.sh --base) - to the crewmate's fm/ branch. +# Absent base= keeps today's default-branch behavior; the same safety refusals +# (checkout on the target branch and clean, ff-only, rebase advice on +# divergence) apply to whichever branch is the target. # # 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 @@ -44,25 +48,35 @@ default_branch() { 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; } +# The task's recorded base= is the single source of truth for a non-default +# merge target; absent base= falls back to the default branch as before. +BASE_BRANCH=$(grep '^base=' "$META" | cut -d= -f2- || true) +if [ -n "$BASE_BRANCH" ]; then + TARGET=$BASE_BRANCH + TARGET_DESC="base branch" + git -C "$PROJ" show-ref --verify --quiet "refs/heads/$TARGET" || { echo "error: recorded base branch '$TARGET' does not exist in $PROJ" >&2; exit 1; } +else + TARGET=$(default_branch) || { echo "error: cannot determine default branch for $PROJ; expected origin/HEAD, main, or master" >&2; exit 1; } + TARGET_DESC="default branch" +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). +# The project's main checkout must be on the merge target 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; } +[ "$cur" = "$TARGET" ] || { echo "error: $PROJ is on '$cur', expected $TARGET_DESC '$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 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: TARGET must be an ancestor of BRANCH. +if ! git -C "$PROJ" merge-base --is-ancestor "$TARGET" "$BRANCH"; then + echo "REFUSED: $BRANCH is not a fast-forward of $TARGET (it has diverged)." >&2 + echo "Have the crewmate rebase $BRANCH onto $TARGET, then retry." >&2 exit 1 fi -before=$(git -C "$PROJ" rev-parse --short "$DEFAULT") +before=$(git -C "$PROJ" rev-parse --short "$TARGET") 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" +after=$(git -C "$PROJ" rev-parse --short "$TARGET") +echo "merged $BRANCH into local $TARGET ($before -> $after) in $PROJ" diff --git a/bin/fm-review-diff.sh b/bin/fm-review-diff.sh index 23962e44..492b239f 100755 --- a/bin/fm-review-diff.sh +++ b/bin/fm-review-diff.sh @@ -4,6 +4,11 @@ # Pooled project clones do not keep their local default branch current, so this # helper compares remote-backed projects against origin/ after fetching # the default branch, and local-only projects against the local default branch. +# When the task's meta records base= (a ship task dispatched with +# fm-spawn.sh --base), that branch replaces the default branch as the review +# base under the same logic: origin/ after a fetch for remote-backed +# projects, the local base branch otherwise. Absent base= keeps today's +# default-branch behavior. # Usage: fm-review-diff.sh [--stat] # --stat prints only the stat summary; default prints stat summary plus full diff. set -eu @@ -59,7 +64,14 @@ default_branch() { return 1 } -DEFAULT=$(default_branch) || { echo "error: cannot determine default branch for $PROJ; expected origin/HEAD, main, or master" >&2; exit 1; } +# The task's recorded base= is the single source of truth for a non-default +# review base; absent base= falls back to the default branch as before. +BASE_BRANCH=$(grep '^base=' "$META" | cut -d= -f2- || true) +if [ -n "$BASE_BRANCH" ]; then + TARGET=$BASE_BRANCH +else + TARGET=$(default_branch) || { echo "error: cannot determine default branch for $PROJ; expected origin/HEAD, main, or master" >&2; exit 1; } +fi BRANCH="fm/$ID" if ! git -C "$WT" rev-parse --verify --quiet "refs/heads/$BRANCH" >/dev/null; then @@ -71,10 +83,17 @@ 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/ stale on some Git versions and only refresh FETCH_HEAD. - git -C "$WT" fetch origin "+refs/heads/$DEFAULT:refs/remotes/origin/$DEFAULT" --quiet - BASE="origin/$DEFAULT" + if ! git -C "$WT" fetch origin "+refs/heads/$TARGET:refs/remotes/origin/$TARGET" --quiet; then + if [ -n "$BASE_BRANCH" ]; then + echo "error: recorded base branch '$TARGET' does not exist on origin for $PROJ (or the fetch failed)" >&2 + else + echo "error: cannot fetch origin/$TARGET for $PROJ" >&2 + fi + exit 1 + fi + BASE="origin/$TARGET" else - BASE="$DEFAULT" + BASE="$TARGET" fi git -C "$WT" rev-parse --verify --quiet "$BASE^{commit}" >/dev/null || { echo "error: base $BASE does not exist in $WT" >&2; exit 1; } diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index 26cb38cd..d5bb703f 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash # Spawn a direct report: a crewmate in a treehouse or Orca worktree, or a # secondmate in its isolated firstmate home. -# Usage: fm-spawn.sh [--harness |harness|launch-command] [--model ] [--effort ] [--backend ] [--scout] +# Usage: fm-spawn.sh [--harness |harness|launch-command] [--model ] [--effort ] [--backend ] [--base ] [--scout] # fm-spawn.sh [] [--harness |harness|launch-command] [--model ] [--effort ] [--backend ] --secondmate # --harness is the explicit per-spawn harness/profile adapter. The old # positional harness arg still works for back-compat. @@ -9,6 +9,19 @@ # axes chosen by firstmate at intake. They are only threaded into harnesses whose # installed CLIs were verified to support that axis; unsupported axes are omitted # from that harness's launch rather than guessed. +# --base (ship tasks only) records base= in the task's meta: +# the branch the work is based on and lands into instead of the project's +# default branch. fm-review-diff, fm-merge-local, and fm-teardown's +# landed-work checks read that single meta value. Refused for --scout and +# --secondmate (a scout worktree is scratch with a report deliverable; a +# secondmate home has no task branch), so a base nothing would honor is never +# recorded. Absent flag writes no base= line, keeping today's default-branch +# behavior byte-identical. +# no-mistakes-mode limitation: --base governs the branch starting point, the +# review diff, and teardown's landed-work checks, but the no-mistakes pipeline +# owns PR creation and still opens the PR against its configured push target +# (typically the default branch) - the PR's landing target is NOT redirected. +# The end-to-end --base flows are local-only and direct-PR. # --backend is the explicit runtime session-provider backend for this # spawn. Without it, the script resolves FM_BACKEND, then config/backend, then # runtime auto-detection (the runtime firstmate itself is executing inside - @@ -52,7 +65,7 @@ # Batch dispatch: pass one or more `id=repo` pairs instead of a single , e.g. # fm-spawn.sh fix-a-k3=projects/foo add-b-q7=projects/bar [--scout] # Each pair re-execs this script in single-task mode, so the single path stays the only -# source of truth; shared --scout/--harness/--model/--effort/--backend applies to every pair. +# source of truth; shared --scout/--harness/--model/--effort/--backend/--base applies to every pair. # If config/crew-dispatch.json exists, shared --harness is required for crewmate # and scout batches. The loop lives here, in bash, so callers never hand-write a # multi-task shell loop (the tool shell is zsh, which does not word-split unquoted @@ -93,10 +106,12 @@ HARNESS_ARG= MODEL= EFFORT= BACKEND_ARG= +BASE= HARNESS_SET=0 MODEL_SET=0 EFFORT_SET=0 BACKEND_SET=0 +BASE_SET=0 POS=() want_value= for a in "$@"; do @@ -109,6 +124,7 @@ for a in "$@"; do model) MODEL=$a; MODEL_SET=1 ;; effort) EFFORT=$a; EFFORT_SET=1 ;; backend) BACKEND_ARG=$a; BACKEND_SET=1 ;; + base) BASE=$a; BASE_SET=1 ;; *) echo "error: internal parser state for --$want_value" >&2; exit 1 ;; esac want_value= @@ -125,6 +141,8 @@ for a in "$@"; do --effort=*) EFFORT=${a#--effort=}; EFFORT_SET=1 ;; --backend) want_value=backend ;; --backend=*) BACKEND_ARG=${a#--backend=}; BACKEND_SET=1 ;; + --base) want_value=base ;; + --base=*) BASE=${a#--base=}; BASE_SET=1 ;; *) POS+=("$a") ;; esac done @@ -133,10 +151,18 @@ done [ "$MODEL_SET" -eq 0 ] || [ -n "$MODEL" ] || { echo "error: --model requires a non-empty value" >&2; exit 1; } [ "$EFFORT_SET" -eq 0 ] || [ -n "$EFFORT" ] || { echo "error: --effort requires a non-empty value" >&2; exit 1; } [ "$BACKEND_SET" -eq 0 ] || [ -n "$BACKEND_ARG" ] || { echo "error: --backend requires a non-empty value" >&2; exit 1; } +[ "$BASE_SET" -eq 0 ] || [ -n "$BASE" ] || { echo "error: --base requires a non-empty value" >&2; exit 1; } case "$EFFORT" in ''|low|medium|high|xhigh|max) ;; *) echo "error: --effort must be one of low, medium, high, xhigh, max" >&2; exit 1 ;; esac +# --base names the ship task's base/landing branch. A scout worktree is scratch +# with a report deliverable and a secondmate home has no task branch, so both +# refuse rather than record a base= nothing would honor. +if [ -n "$BASE" ] && [ "$KIND" != ship ]; then + echo "error: --base applies only to ship tasks; refusing for --$KIND" >&2 + exit 1 +fi # Backend selection (data/fm-backend-design-d7): explicit --backend, else # FM_BACKEND env, else config/backend, else runtime auto-detection, else @@ -232,6 +258,7 @@ if [ "${#POS[@]}" -gt 0 ] && [ "${POS[0]}" != "$idpart" ] && case "$idpart" in * [ -z "$MODEL" ] || shared_args+=(--model "$MODEL") [ -z "$EFFORT" ] || shared_args+=(--effort "$EFFORT") [ -z "$BACKEND_ARG" ] || shared_args+=(--backend "$BACKEND_ARG") + [ -z "$BASE" ] || shared_args+=(--base "$BASE") for pair in "${POS[@]}"; do case "$pair" in *=*) : ;; @@ -915,6 +942,9 @@ META_WINDOW=$T echo "kind=$KIND" echo "mode=$MODE" echo "yolo=$YOLO" + # base= is written only when --base was passed, so a default-branch task's + # meta stays byte-identical (absent base= means the default branch). + [ -z "$BASE" ] || echo "base=$BASE" echo "tasktmp=$TASK_TMP" echo "model=${MODEL:-default}" echo "effort=${EFFORT:-default}" diff --git a/bin/fm-teardown.sh b/bin/fm-teardown.sh index f74df5f2..850ce49a 100755 --- a/bin/fm-teardown.sh +++ b/bin/fm-teardown.sh @@ -12,6 +12,11 @@ # already present in the up-to-date default branch. This recognizes the common # squash-merge-then-delete-branch flow, where the branch's own commits live nowhere # on a remote yet the change is fully in main. +# A task whose meta records base= (a ship task dispatched with fm-spawn.sh --base) +# treats that branch, not the default branch, as its landing target: the +# content-in-default fallback checks content-in-base, and the local-only +# merged-into-local-default acceptance becomes merged-into-the-recorded-base. +# Genuinely unlanded work still refuses; absent base= keeps today's behavior. # The PR itself is resolved from the task's recorded pr= when present, or - when # no pr= was ever recorded (e.g. a yolo-authorized merge on a repo with no PR CI, # where the usual "checks green" fm-pr-check.sh trigger never fires) - by looking @@ -81,6 +86,9 @@ KIND=$(grep '^kind=' "$META" | cut -d= -f2- || true) [ -n "$KIND" ] || KIND=ship MODE=$(grep '^mode=' "$META" | cut -d= -f2- || true) [ -n "$MODE" ] || MODE=no-mistakes +# base= is recorded by fm-spawn --base for ship tasks landing into a non-default +# branch; absent for every task dispatched without it (default-branch behavior). +BASE_BRANCH=$(grep '^base=' "$META" | cut -d= -f2- || true) default_branch() { local ref branch @@ -129,6 +137,17 @@ if [ "$BACKEND" = orca ] && [ "$KIND" != secondmate ]; then [ -z "$T_ORCA" ] || T=$T_ORCA fi +# The branch a ship task's work lands into: the task's recorded base= when +# present, else the project's default branch. Fails only on the default-branch +# path when no default branch can be determined. +landing_branch() { + if [ -n "$BASE_BRANCH" ]; then + printf '%s\n' "$BASE_BRANCH" + return 0 + fi + default_branch +} + remove_grok_turnend_auth() { local state_dir=$1 id=$2 token hooks_dir token=$(cat "$state_dir/$id.grok-turnend-token" 2>/dev/null || true) @@ -235,16 +254,17 @@ pr_is_merged() { unpushed_patches_are_in_pr_head "$head" } -# Is the branch's content already present in the up-to-date default branch? Fetches -# first, then 3-way merges the default branch with HEAD: when HEAD introduces nothing -# the default branch does not already contain (e.g. its change landed via squash) the -# merged tree equals the default branch's tree. This isolates branch-only changes, so -# unrelated commits the default branch gained past the merge-base do not count as -# "added". Returns non-zero when inconclusive (no default ref, or a merge conflict), -# so the caller refuses rather than guesses. +# Is the branch's content already present in the up-to-date landing branch (the +# recorded base= branch, else the default branch)? Fetches first, then 3-way +# merges the landing branch with HEAD: when HEAD introduces nothing the landing +# branch does not already contain (e.g. its change landed via squash) the +# merged tree equals the landing branch's tree. This isolates branch-only changes, +# so unrelated commits the landing branch gained past the merge-base do not count +# as "added". Returns non-zero when inconclusive (no landing ref, or a merge +# conflict), so the caller refuses rather than guesses. content_in_default() { local name ref default_tree merged_tree - name=$(default_branch) || return 1 + name=$(landing_branch) || return 1 if git -C "$WT" remote get-url origin >/dev/null 2>&1; then git -C "$WT" fetch --quiet origin "+refs/heads/$name:refs/remotes/origin/$name" >/dev/null 2>&1 || return 1 ref="refs/remotes/origin/$name" @@ -263,8 +283,9 @@ content_in_default() { # Has the worktree's committed work actually LANDED, though its commits are not # reachable from any remote-tracking branch? True when a merged PR proves the # current local work is contained in the PR head, OR the content is already in the -# default branch (fallback, which also covers the no-PR and gh-error paths). False -# only for genuinely unlanded work. +# landing branch - the recorded base= branch, else the default branch (fallback, +# which also covers the no-PR and gh-error paths). False only for genuinely +# unlanded work. work_is_landed() { local branch=$1 pr_is_merged "$branch" && return 0 @@ -723,9 +744,17 @@ if [ -d "$WT" ] && [ "$FORCE" != "--force" ]; then if [ -n "$unpushed" ] && [ "$MODE" = local-only ]; then # local-only ships have no remote in the common case, so the "on a remote" # test above is expected to be non-empty. The work is safe once it is merged - # into the local default branch (firstmate does that merge on the captain's - # approval). Refuse until then. - DEFAULT=$(default_branch) || { echo "REFUSED: cannot determine default branch for $PROJ; expected origin/HEAD, main, or master." >&2; exit 1; } + # into the local landing branch - the recorded base= branch, else the default + # branch (firstmate does that merge on the captain's approval). Refuse until then. + DEFAULT=$(landing_branch) || { echo "REFUSED: cannot determine default branch for $PROJ; expected origin/HEAD, main, or master." >&2; exit 1; } + # A recorded base branch that no longer exists must refuse loudly: the + # `git log --not ` below would error, the error is swallowed, + # and empty output would read as "all merged" - tearing down unlanded work. + if [ -n "$BASE_BRANCH" ] && ! git -C "$WT" show-ref --verify --quiet "refs/heads/$BASE_BRANCH"; then + echo "REFUSED: recorded base branch '$BASE_BRANCH' does not exist in $PROJ; cannot verify the work landed." >&2 + echo "Restore the base branch (or get the captain's explicit OK to discard, then --force)." >&2 + exit 1 + fi unmerged=$(git -C "$WT" log --oneline HEAD --not "$DEFAULT" -- 2>/dev/null | head -5 || true) if [ -n "$dirty" ] || [ -n "$unmerged" ]; then echo "REFUSED: local-only worktree $WT has work not yet merged into $DEFAULT and not on any remote." >&2 diff --git a/docs/architecture.md b/docs/architecture.md index 1117f487..bb7299c7 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -130,12 +130,14 @@ The `data/secondmates.md` line schema and the secondmate environment variables a `no-mistakes` projects run the full validation pipeline, `direct-PR` projects open PRs without that pipeline, and `local-only` projects stay local until firstmate performs an approved fast-forward merge. PR-based task merges go through `bin/fm-pr-merge.sh`, which records `pr=` and any available `pr_head=` through `bin/fm-pr-check.sh` before calling `gh-axi pr merge`. The helper requires a full `https://github.com///pull/` URL, invokes `gh-axi pr merge --repo /`, defaults to `--squash`, preserves explicit merge-method flags, and rejects malformed URLs or repo override flags before recording merge state. +A ship task dispatched with `fm-spawn.sh --base ` records that branch as `base=` in its meta, the single source of truth that makes the brief scaffold, `bin/fm-review-diff.sh`, `bin/fm-merge-local.sh`, and teardown's landed-work checks branch from and land into it instead of the default branch; absent `base=` keeps default-branch behavior, and the flag is refused for scout and secondmate spawns. +The `no-mistakes` pipeline still opens its PR against its configured push target, so the end-to-end `--base` flows are `local-only` and `direct-PR`. Teardown is fail-closed for ship worktrees: dirty worktrees refuse, and committed work must be landed before the worktree is returned. -Landed work is accepted when `HEAD` is reachable from any remote-tracking branch, when a merged PR's GitHub head contains the current local work, or when the worktree content is already present in the freshly fetched default branch. +Landed work is accepted when `HEAD` is reachable from any remote-tracking branch, when a merged PR's GitHub head contains the current local work, or when the worktree content is already present in the freshly fetched landing branch (the task's recorded `base=` branch, else the default branch). PR-head containment covers an exact PR head match, a local `HEAD` that is an ancestor of the PR head, or unpushed local patches whose patch IDs appear in the PR head after no-mistakes replayed the branch. GitHub lookup errors fall back to the content check and still refuse if that check is inconclusive. If no `pr=` was ever recorded, teardown can still discover a merged PR by matching the worktree branch name and fetching `refs/pull//head` when the head branch was deleted. -Those PR-head and content checks let a squash-merged PR whose head branch was deleted tear down cleanly without using `--force`; `local-only` work instead tears down after the approved local default-branch merge or after the branch is pushed to any remote. +Those PR-head and content checks let a squash-merged PR whose head branch was deleted tear down cleanly without using `--force`; `local-only` work instead tears down after the approved local merge into the landing branch or after the branch is pushed to any remote, and a recorded `base=` branch that no longer exists locally refuses loudly instead of passing unlanded work. ## Optional X mode diff --git a/docs/scripts.md b/docs/scripts.md index e5b0b6c8..794c802d 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -10,11 +10,11 @@ Each file also starts with a short header comment. | `fm-fleet-sync.sh` | Fetch clones, fast-forward safe default-branch states, self-heal clean detached ancestor drift, report unsafe drift as `STUCK:`, and safely prune branches whose remote is gone | | `fm-update.sh` | Self-update the running firstmate repo and registered secondmate homes with fast-forward-only pulls from origin | | `fm-backlog-handoff.sh` | Move already-judged in-scope queued backlog items from the main home into a seeded secondmate home | -| `fm-brief.sh` | Scaffold a ship brief with a worktree-isolation assertion, a report-only scout brief with `--scout`, or a secondmate charter with `--secondmate` | +| `fm-brief.sh` | Scaffold a ship brief with a worktree-isolation assertion (optionally branched from and landing into a non-default base branch with `--base `, refused for the other kinds), a report-only scout brief with `--scout`, or a secondmate charter with `--secondmate` | | `fm-ensure-agents-md.sh` | Ensure project `AGENTS.md` is the real memory file and `CLAUDE.md` symlinks to it | | `fm-guard.sh` | Warn when the primary checkout is tangled, when queued wakes are pending, or when a stale or missing watcher needs a prominent banner; `FM_GUARD_READ_ONLY=1` keeps the alarms but suppresses drain, arm, and checkout repair commands | | `fm-home-seed.sh` | Lease/provision a secondmate home transactionally, clone projects, initialize gates, and maintain `data/secondmates.md` | -| `fm-spawn.sh` | Spawn one task, several `id=repo` pairs, or a persistent secondmate with `--secondmate`; accepts concrete `--harness`, `--model`, `--effort`, and `--backend` axes; ship/scout spawns require an explicit resolved harness when dispatch profiles are active and an isolated worktree, install per-harness turn-end signaling, and secondmate spawns resolve the secondmate harness plus optional `config/secondmate-harness` model/effort tokens, locally sync the home, propagate declared inheritable config, land herdr tabs in the target home's workspace, land zellij tabs in the selected shared zellij session, or create Orca worktrees/terminals before launch | +| `fm-spawn.sh` | Spawn one task, several `id=repo` pairs, or a persistent secondmate with `--secondmate`; accepts concrete `--harness`, `--model`, `--effort`, and `--backend` axes, plus `--base ` for ship tasks only (records `base=` in meta as the task's landing target; refused for `--scout` and `--secondmate`); ship/scout spawns require an explicit resolved harness when dispatch profiles are active and an isolated worktree, install per-harness turn-end signaling, and secondmate spawns resolve the secondmate harness plus optional `config/secondmate-harness` model/effort tokens, locally sync the home, propagate declared inheritable config, land herdr tabs in the target home's workspace, land zellij tabs in the selected shared zellij session, or create Orca worktrees/terminals before launch | | `fm-backend.sh` | Runtime session-provider backend selector with explicit/env/config/runtime auto-detection precedence, meta helper, selector resolver, spawn-capability validation, and operation dispatcher; defaults absent `backend=` meta to `tmux`; `fm_backend_target_exists` is a cheap read-only alive/dead endpoint check that never starts a server or session | | `backends/tmux.sh` | Verified tmux session-provider adapter used by `fm-backend.sh`; owns create, send, capture, current-path, live-window, and kill primitives | | `backends/herdr.sh` | Experimental herdr session-provider adapter used by `fm-backend.sh`; owns version/tool gating, per-home workspace/tab creation, created-vs-adopted default-tab prune safety, session-scoped CLI calls, send with structural composer-state verification, capture, native busy-state, current-path, label-based live discovery, and kill primitives | @@ -22,8 +22,8 @@ Each file also starts with a short header comment. | `backends/orca.sh` | Experimental Orca backend used by `fm-backend.sh`; owns repo registration, worktree creation/removal, terminal creation, capture, send text, Enter/Ctrl-C interrupt keys, and close; Escape is unsupported | | `fm-config-push.sh` | Config-only mid-session push of declared inheritable local config into live secondmate homes; reports each item as pushed, unchanged, skipped, or error without fast-forwarding tracked files or nudging agents | | `fm-project-mode.sh` | Resolve a project's delivery mode and `+yolo` flag from `data/projects.md` | -| `fm-merge-local.sh` | Fast-forward a `local-only` project's local default branch after approval | -| `fm-review-diff.sh` | Review a crewmate branch against the authoritative base, with optional `--stat` output | +| `fm-merge-local.sh` | Fast-forward a `local-only` project's local default branch - or the task's recorded `base=` branch - after approval | +| `fm-review-diff.sh` | Review a crewmate branch against the authoritative base (the task's recorded `base=` branch when set, else the default branch), with optional `--stat` output | | `fm-marker-lib.sh` | Shared from-firstmate request marker and detector sourced by `fm-send.sh`, `fm-brief.sh`, and tests | | `fm-watch-arm.sh` | Verified per-home watcher re-arm; reports `started`, `healthy`, or `FAILED`; `--restart` relaunches only this home's watcher | | `fm-watch.sh` | Singleton-safe always-on watcher and default pull-based event source; uses backend-native busy state when available before the shared regex fallback, absorbs no-verb signal and stale wakes only when the crew is provably working, queues and exits for actionable wakes, and reverts to daemon-owned one-shot behavior while `state/.afk` exists | @@ -42,7 +42,7 @@ Each file also starts with a short header comment. | `fm-pr-check.sh` | Record `pr=` and GitHub's `pr_head=` when available for a PR-ready task, then arm the watcher's merge poll | | `fm-pr-merge.sh` | Require a full GitHub PR URL, record `pr=` and available `pr_head=` via `fm-pr-check.sh`, parse it into `gh-axi pr merge --repo /`, default to `--squash` unless a merge method is forwarded, and reject malformed URLs or repo overrides | | `fm-promote.sh` | Promote a scout task in place so it becomes a protected ship task | -| `fm-teardown.sh` | Return a clean, landed ship worktree or retire/release a secondmate home; requires scout reports, checks child work, removes firstmate-owned hook artifacts, releases Orca worktrees through `orca worktree rm`, and prints the backlog-backend reminder | +| `fm-teardown.sh` | Return a clean, landed ship worktree (landing into the task's recorded `base=` branch when set, else the default branch) or retire/release a secondmate home; requires scout reports, checks child work, removes firstmate-owned hook artifacts, releases Orca worktrees through `orca worktree rm`, and prints the backlog-backend reminder | | `fm-harness.sh` | Detect the running harness; resolve the effective crewmate (`crew`) or secondmate-launch (`secondmate`) harness; expose optional `config/secondmate-harness` model and effort tokens with `secondmate-model` and `secondmate-effort` | | `fm-lock.sh` | Per-home firstmate session lock | | `fm-x-lib.sh` | Shared X-mode `.env`, alternate env-file, relay, dry-run config, reply-thread splitting, outbound image payloads, and task-to-X-request meta-link helpers | diff --git a/tests/fm-base-branch.test.sh b/tests/fm-base-branch.test.sh new file mode 100755 index 00000000..963f9065 --- /dev/null +++ b/tests/fm-base-branch.test.sh @@ -0,0 +1,589 @@ +#!/usr/bin/env bash +# Behavior tests for the configurable base/merge-target branch of ship tasks +# (fm-spawn --base -> base= in meta -> fm-brief/fm-review-diff/fm-merge-local/ +# fm-teardown honoring it). +# +# base= in state/.meta is the single source of truth every consumer reads; +# a meta without base= must behave exactly as before the feature existed. +# +# Matrix: +# spawn: --base records base= in meta (single and batch); absent flag writes +# no base= line; --scout/--secondmate + --base refuse fast. +# brief: --base rewrites the ship Setup branch step to start from the base +# branch and points local-only landing text at it; no flag keeps the +# plain default-branch step; --scout/--secondmate + --base refuse. +# review: base= diffs against the local base branch (no origin) or +# origin/ (remote-backed); no base= keeps the default branch; +# a base branch missing from origin errors naming the branch. +# merge: base= fast-forwards the base branch (default branch untouched), +# refuses a checkout not on the base branch, refuses divergence with +# the same rebase advice, refuses a missing base branch. +# teardown: local-only work merged into the recorded base branch is landed; +# work merged nowhere (or only into the default branch) still +# refuses; a recorded base branch that no longer exists refuses +# instead of passing unlanded work; the content-landed fallback +# checks origin/, not origin/. +set -u + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" +fm_git_identity fmtest fmtest@example.invalid + +SPAWN="$ROOT/bin/fm-spawn.sh" +BRIEF="$ROOT/bin/fm-brief.sh" +REVIEW="$ROOT/bin/fm-review-diff.sh" +MERGE="$ROOT/bin/fm-merge-local.sh" +TEARDOWN="$ROOT/bin/fm-teardown.sh" +TMP_ROOT=$(fm_test_tmproot fm-base-branch) + +# --- fm-spawn harness (fake tmux + treehouse, real isolated worktree) -------- + +make_spawn_fakebin() { + local dir=$1 fakebin + fakebin=$(fm_fakebin "$dir") + cat > "$fakebin/tmux" <<'SH' +#!/usr/bin/env bash +set -u +case "$*" in + *"#{pane_current_path}"*) printf '%s\n' "${FM_FAKE_PANE_PATH:-}"; exit 0 ;; +esac +exit 0 +SH + chmod +x "$fakebin/tmux" + fm_fake_exit0 "$fakebin" treehouse + printf '%s\n' "$fakebin" +} + +make_spawn_case() { + local name=$1 case_dir home proj wt fakebin id + shift + case_dir="$TMP_ROOT/$name" + home="$case_dir/home" + proj="$case_dir/project" + wt="$case_dir/wt" + fakebin=$(make_spawn_fakebin "$case_dir/fake") + mkdir -p "$home/data" "$home/projects" "$home/state" "$home/config" + printf '%s\n' claude > "$home/config/crew-harness" + fm_git_worktree "$proj" "$wt" "wt-$name" + touch "$home/state/.last-watcher-beat" + for id in "$@"; do + mkdir -p "$home/data/$id" + printf 'brief for %s\n' "$id" > "$home/data/$id/brief.md" + done + printf '%s\n' "$case_dir|$home|$proj|$wt|$fakebin" +} + +run_spawn() { + local home=$1 wt=$2 fakebin=$3 + shift 3 + FM_ROOT_OVERRIDE='' FM_HOME="$home" \ + FM_STATE_OVERRIDE="$home/state" FM_DATA_OVERRIDE="$home/data" \ + FM_PROJECTS_OVERRIDE="$home/projects" FM_CONFIG_OVERRIDE="$home/config" \ + FM_SPAWN_NO_GUARD=1 FM_FAKE_PANE_PATH="$wt" TMUX="fake,1,0" FM_BACKEND=tmux \ + PATH="$fakebin:$PATH" \ + "$SPAWN" "$@" 2>&1 +} + +read_spawn_case() { + # shellcheck disable=SC2034 # CASE_DIR is part of the record; not every test reads it + IFS='|' read -r CASE_DIR HOME_DIR PROJ_DIR WT_DIR FAKEBIN_DIR <&1 +} + +make_brief_home() { + local name=$1 mode=$2 home + home="$TMP_ROOT/$name" + mkdir -p "$home/data" "$home/state" + printf -- '- myrepo [%s] - test project (added 2026-07-03)\n' "$mode" > "$home/data/projects.md" + printf '%s\n' "$home" +} + +test_brief_base_rewrites_ship_setup() { + local home id brief + home=$(make_brief_home brief-base local-only) + id='brief-base-z6' + run_brief "$home" "$id" myrepo --base feature/train >/dev/null || fail "brief --base scaffold failed" + brief="$home/data/$id/brief.md" + assert_grep 'git checkout -b fm/'"$id"' feature/train' "$brief" "Setup does not branch from the base branch" + assert_grep 'git rev-parse --verify feature/train' "$brief" "Setup does not verify the base branch exists" + assert_grep 'git fetch origin feature/train:feature/train' "$brief" "Setup does not offer the origin fetch fallback" + # shellcheck disable=SC2016 # single quotes are deliberate: literal backticks in the brief + assert_grep 'firstmate merges it into local `feature/train`' "$brief" "local-only landing text does not target the base branch" + # shellcheck disable=SC2016 # single quotes are deliberate: literal backticks in the brief + assert_no_grep 'into local `main`' "$brief" "local-only landing text still mentions main despite --base" + pass "fm-brief --base starts the ship branch from the base branch and lands into it" +} + +test_brief_without_base_keeps_default_step() { + local home id brief + home=$(make_brief_home brief-plain local-only) + id='brief-plain-z7' + run_brief "$home" "$id" myrepo >/dev/null || fail "plain brief scaffold failed" + brief="$home/data/$id/brief.md" + assert_grep '1. First action: create your branch: `git checkout -b fm/'"$id"'`' "$brief" \ + "plain scaffold lost the original branch step" + # shellcheck disable=SC2016 # single quotes are deliberate: literal backticks in the brief + assert_grep 'firstmate merges it into local `main`' "$brief" "plain local-only landing text changed" + assert_no_grep 'base branch' "$brief" "plain scaffold unexpectedly mentions a base branch" + pass "fm-brief without --base keeps the default-branch scaffold wording" +} + +test_brief_refuses_base_for_scout_and_secondmate() { + local home out status + home=$(make_brief_home brief-refuse no-mistakes) + out=$(run_brief "$home" brief-refuse-z8 myrepo --scout --base feature/train) + status=$? + expect_code 1 "$status" "brief --scout with --base should refuse" + assert_contains "$out" "error: --base applies only to ship briefs; refusing for --scout" \ + "scout brief refusal message missing" + out=$(run_brief "$home" brief-refuse-z9 --secondmate alpha --base feature/train) + status=$? + expect_code 1 "$status" "brief --secondmate with --base should refuse" + assert_contains "$out" "error: --base applies only to ship briefs; refusing for --secondmate" \ + "secondmate brief refusal message missing" + pass "fm-brief refuses --base for --scout and --secondmate" +} + +# --- fm-review-diff / fm-merge-local / fm-teardown fixtures ------------------- + +# Build a project with a default branch, a feature base branch holding one +# base-only commit, and a task worktree branched from the base branch with one +# task commit. Echoes "|". +make_base_project_case() { + local name=$1 case_dir proj wt default + case_dir="$TMP_ROOT/$name" + proj="$case_dir/project" + wt="$case_dir/wt" + mkdir -p "$case_dir/state" "$case_dir/config" + touch "$case_dir/state/.last-watcher-beat" + fm_git_init_commit "$proj" + default=$(git -C "$proj" symbolic-ref --short HEAD) + git -C "$proj" branch feature + git -C "$proj" checkout -q feature + printf 'base work\n' > "$proj/base-only.txt" + git -C "$proj" add base-only.txt + git -C "$proj" commit -qm "base-only commit" + git -C "$proj" worktree add -q -b fm/task-x1 "$wt" feature + printf 'task work\n' > "$wt/task.txt" + git -C "$wt" add task.txt + git -C "$wt" commit -qm "task commit" + printf '%s\n' "$case_dir|$default" +} + +write_task_meta() { + local case_dir=$1 mode=$2 base=$3 + fm_write_meta "$case_dir/state/task-x1.meta" \ + "window=fm-task-x1" \ + "worktree=$case_dir/wt" \ + "project=$case_dir/project" \ + "kind=ship" \ + "mode=$mode" + [ -z "$base" ] || printf 'base=%s\n' "$base" >> "$case_dir/state/task-x1.meta" +} + +run_with_state() { + local case_dir=$1 script=$2 + shift 2 + FM_ROOT_OVERRIDE="$ROOT" FM_STATE_OVERRIDE="$case_dir/state" \ + "$script" "$@" +} + +# --- fm-review-diff ------------------------------------------------------------ + +test_review_diff_uses_recorded_base_locally() { + local rec case_dir default out + rec=$(make_base_project_case review-local) + IFS='|' read -r case_dir default <&1) || fail "review-diff with base= failed: $out" + assert_contains "$out" "diff base: feature" "review did not diff against the recorded base branch" + assert_contains "$out" "task.txt" "review diff missing the task's change" + assert_not_contains "$out" "base-only.txt" "review diff leaked the base branch's own commits" + pass "fm-review-diff diffs against the recorded local base branch" +} + +test_review_diff_uses_origin_base_when_remote_backed() { + local rec case_dir default out + rec=$(make_base_project_case review-remote) + IFS='|' read -r case_dir default <&1) || fail "remote review-diff with base= failed: $out" + assert_contains "$out" "diff base: origin/feature" "remote-backed review did not fetch and use origin/" + assert_contains "$out" "task.txt" "remote-backed review diff missing the task's change" + pass "fm-review-diff fetches and diffs against origin/ for remote-backed projects" +} + +test_review_diff_errors_on_base_missing_from_origin() { + local rec case_dir default out status + rec=$(make_base_project_case review-missing-origin-base) + IFS='|' read -r case_dir default < must fail with an explicit error naming the branch, not die + # on git's raw fatal message. The bare clone starts with every branch, so + # drop feature from it. + fm_git_add_origin "$case_dir/project" "$case_dir/origin.git" + git -C "$case_dir/origin.git" update-ref -d refs/heads/feature + git -C "$case_dir/origin.git" symbolic-ref HEAD "refs/heads/$default" + write_task_meta "$case_dir" no-mistakes feature + + set +e + out=$(run_with_state "$case_dir" "$REVIEW" task-x1 --stat 2>&1) + status=$? + set -e + expect_code 1 "$status" "review-diff should error when the base branch is missing from origin" + assert_contains "$out" "recorded base branch 'feature' does not exist on origin" \ + "missing-origin-base error did not name the base branch" + pass "fm-review-diff errors explicitly when the recorded base branch is missing from origin" +} + +test_review_diff_without_base_keeps_default_branch() { + local rec case_dir default out + rec=$(make_base_project_case review-default) + IFS='|' read -r case_dir default <&1) || fail "review-diff without base= failed: $out" + assert_contains "$out" "diff base: $default" "review without base= did not use the default branch" + pass "fm-review-diff without base= keeps the default-branch comparison" +} + +# --- fm-merge-local ------------------------------------------------------------ + +test_merge_local_fast_forwards_base_branch() { + local rec case_dir default out wt_head feature_head default_before default_after + rec=$(make_base_project_case merge-ff) + IFS='|' read -r case_dir default <&1) || fail "merge-local with base= failed: $out" + assert_contains "$out" "merged fm/task-x1 into local feature" "merge did not report the base branch as target" + wt_head=$(git -C "$case_dir/wt" rev-parse HEAD) + feature_head=$(git -C "$case_dir/project" rev-parse feature) + [ "$feature_head" = "$wt_head" ] || fail "feature was not fast-forwarded to the task branch head" + default_after=$(git -C "$case_dir/project" rev-parse "$default") + [ "$default_after" = "$default_before" ] || fail "default branch moved during a base-branch merge" + pass "fm-merge-local fast-forwards the recorded base branch and leaves the default branch alone" +} + +test_merge_local_refuses_checkout_not_on_base() { + local rec case_dir default out status + rec=$(make_base_project_case merge-wrong-checkout) + IFS='|' read -r case_dir default <&1) + status=$? + set -e + expect_code 1 "$status" "merge should refuse when the checkout is not on the base branch" + assert_contains "$out" "expected base branch 'feature'" "refusal did not name the base branch" + pass "fm-merge-local refuses when the project checkout is not on the base branch" +} + +test_merge_local_refuses_diverged_base() { + local rec case_dir default out status + rec=$(make_base_project_case merge-diverged) + IFS='|' read -r case_dir default < "$case_dir/project/diverge.txt" + git -C "$case_dir/project" add diverge.txt + git -C "$case_dir/project" commit -qm "feature diverges" + + set +e + out=$(run_with_state "$case_dir" "$MERGE" task-x1 2>&1) + status=$? + set -e + expect_code 1 "$status" "merge should refuse a diverged base branch" + assert_contains "$out" "REFUSED: fm/task-x1 is not a fast-forward of feature" "divergence refusal missing" + assert_contains "$out" "rebase fm/task-x1 onto feature" "divergence refusal lost the rebase advice" + pass "fm-merge-local refuses divergence against the base branch with rebase advice" +} + +test_merge_local_refuses_missing_base_branch() { + local rec case_dir default out status + rec=$(make_base_project_case merge-missing-base) + IFS='|' read -r case_dir default <&1) + status=$? + set -e + expect_code 1 "$status" "merge should refuse a recorded base branch that does not exist" + assert_contains "$out" "recorded base branch 'nonexistent-base' does not exist" "missing-base refusal missing" + pass "fm-merge-local refuses a recorded base branch missing from the project" +} + +# --- fm-teardown --------------------------------------------------------------- + +add_teardown_mocks() { + local case_dir=$1 fakebin + fakebin=$(fm_fakebin "$case_dir") + fm_fake_exit0 "$fakebin" treehouse tmux + cat > "$fakebin/gh-axi" <<'SH' +#!/usr/bin/env bash +case "${1:-} ${2:-}" in + "pr list") printf '%s\n' "count: 0 (showing first 0)" "pull_requests[]: []" ; exit 0 ;; + "pr view") echo "error: pull request not found" >&2 ; exit 1 ;; +esac +exit 0 +SH + cat > "$fakebin/gh" <<'SH' +#!/usr/bin/env bash +case "${1:-} ${2:-}" in + "pr view") echo "error: pull request not found" >&2 ; exit 1 ;; +esac +exit 0 +SH + chmod +x "$fakebin/gh-axi" "$fakebin/gh" + printf '%s\n' "$fakebin" +} + +run_teardown() { + local case_dir=$1 fakebin=$2 + shift 2 + FM_ROOT_OVERRIDE="$ROOT" \ + FM_STATE_OVERRIDE="$case_dir/state" \ + FM_CONFIG_OVERRIDE="$case_dir/config" \ + PATH="$fakebin:$PATH" \ + "$TEARDOWN" task-x1 "$@" +} + +test_teardown_accepts_local_only_work_merged_into_base() { + local rec case_dir default fakebin rc wt_head + rec=$(make_base_project_case td-base-merged) + IFS='|' read -r case_dir default < "$case_dir/stdout" 2> "$case_dir/stderr" + rc=$? + set -e + expect_code 0 "$rc" "teardown should accept local-only work merged into the recorded base branch" + ! grep -q REFUSED "$case_dir/stderr" || fail "td-base-merged: teardown printed a REFUSED line" + pass "teardown accepts local-only work merged into the recorded base branch" +} + +test_teardown_refuses_local_only_work_not_merged_into_base() { + local rec case_dir default fakebin rc wt_head + rec=$(make_base_project_case td-base-unmerged) + IFS='|' read -r case_dir default < "$case_dir/stdout" 2> "$case_dir/stderr" + rc=$? + set -e + expect_code 1 "$rc" "teardown should refuse local-only work not merged into the recorded base branch" + grep -q REFUSED "$case_dir/stderr" || fail "td-base-unmerged: no REFUSED line in stderr" + grep -q "not yet merged into feature" "$case_dir/stderr" \ + || fail "td-base-unmerged: refusal did not name the base branch as the landing target" + pass "teardown still refuses base-branch work merged only into the default branch" +} + +test_teardown_refuses_missing_base_branch() { + local rec case_dir default fakebin rc + rec=$(make_base_project_case td-missing-base) + IFS='|' read -r case_dir default <` errors + # (swallowed), so without an existence check the unmerged work would read as + # merged and teardown would destroy it. It must refuse instead. + write_task_meta "$case_dir" local-only nonexistent-base + + set +e + run_teardown "$case_dir" "$fakebin" > "$case_dir/stdout" 2> "$case_dir/stderr" + rc=$? + set -e + expect_code 1 "$rc" "teardown should refuse when the recorded base branch does not exist" + grep -q REFUSED "$case_dir/stderr" || fail "td-missing-base: no REFUSED line in stderr" + grep -q "recorded base branch 'nonexistent-base' does not exist" "$case_dir/stderr" \ + || fail "td-missing-base: refusal did not name the missing base branch" + [ -d "$case_dir/wt" ] || fail "td-missing-base: worktree was removed despite the refusal" + git -C "$case_dir/project" show-ref --verify --quiet refs/heads/fm/task-x1 \ + || fail "td-missing-base: task branch was deleted despite the refusal" + pass "teardown refuses a recorded base branch missing from the project instead of passing unlanded work" +} + +# Land = as a squash-style commit on origin's via a +# separate clone, so the task branch's own commits stay unreachable. +land_on_origin_branch() { + local case_dir=$1 branch=$2 file=$3 content=$4 tmp + tmp="$case_dir/_land" + git clone -q "$case_dir/origin.git" "$tmp" + git -C "$tmp" checkout -q "$branch" + printf '%s\n' "$content" > "$tmp/$file" + git -C "$tmp" add -- "$file" + git -C "$tmp" commit -qm "squash $file onto $branch" + git -C "$tmp" push -q origin "HEAD:$branch" + rm -rf -- "$tmp" +} + +test_teardown_content_fallback_checks_base_not_default() { + local rec case_dir default fakebin rc + # Content landed on origin/ -> ALLOW; content landed only on + # origin/ -> REFUSE. Both with base= recorded and no PR anywhere. + rec=$(make_base_project_case td-content-base) + IFS='|' read -r case_dir default < "$case_dir/stdout" 2> "$case_dir/stderr" + rc=$? + set -e + expect_code 0 "$rc" "teardown should accept content already landed on origin/" + ! grep -q REFUSED "$case_dir/stderr" || fail "td-content-base: teardown printed a REFUSED line" + + rec=$(make_base_project_case td-content-default-only) + IFS='|' read -r case_dir default < "$case_dir/stdout" 2> "$case_dir/stderr" + rc=$? + set -e + expect_code 1 "$rc" "teardown should refuse when content landed only on origin/ but base= is recorded" + grep -q REFUSED "$case_dir/stderr" || fail "td-content-default-only: no REFUSED line in stderr" + pass "teardown's content-landed fallback targets origin/, not origin/, when base= is recorded" +} + +test_spawn_records_base_in_meta +test_spawn_without_base_writes_no_base_line +test_spawn_batch_threads_base_to_every_pair +test_spawn_refuses_base_for_scout_and_secondmate +test_brief_base_rewrites_ship_setup +test_brief_without_base_keeps_default_step +test_brief_refuses_base_for_scout_and_secondmate +test_review_diff_uses_recorded_base_locally +test_review_diff_uses_origin_base_when_remote_backed +test_review_diff_errors_on_base_missing_from_origin +test_review_diff_without_base_keeps_default_branch +test_merge_local_fast_forwards_base_branch +test_merge_local_refuses_checkout_not_on_base +test_merge_local_refuses_diverged_base +test_merge_local_refuses_missing_base_branch +test_teardown_accepts_local_only_work_merged_into_base +test_teardown_refuses_local_only_work_not_merged_into_base +test_teardown_refuses_missing_base_branch +test_teardown_content_fallback_checks_base_not_default