Skip to content

feat(bin): prove native GitHub stacks before firstmate calls anything a stack - #31

Merged
SchokoShake merged 6 commits into
mainfrom
fm/fm-native-stack-mechanism
Sep 17, 2026
Merged

SchokoShake merged 6 commits into
mainfrom
fm/fm-native-stack-mechanism

Conversation

@SchokoShake

@SchokoShake SchokoShake commented Sep 17, 2026 •

Copy link
Copy Markdown
Owner

Intent

Give firstmate a mechanism, not just a note, for producing REAL native GitHub stacked pull requests and proving them.

The captain (2026-09-17): "firstmate should know how to properly produce stacks", after two failures the same day: PRs whose bases merely pointed at each other were presented as a stack (GitHub reported no stack), and stack instructions to workers did not name how to create or verify one.

Authoritative source: GitHub's docs (About stacked pull requests, quickstart, gh stack CLI reference). Key facts, verified against the doc and gh stack --help: a stack is a server-side object; gh stack link <bottom> ... <top> turns existing PRs or branches into a stack and corrects bases; gh stack init/add/submit build a new one; REST exposes membership on a PR (gh api -H 'X-GitHub-Api-Version: 2026-03-10' repos/<o>/<r>/pulls/<n> --jq .stack -> number, position, size, base), which is how a stack is proven; CI runs for every PR in a stack; merging the bottom auto-rebases the rest.

Deliverable, keep it small and owned once:

  1. A checker, bin/fm-stack-check.sh <pr-url>... (bottom to top): exits 0 only when every PR reports the same non-null stack number, positions 1..N in the given order, size N, and each PR's base is the branch of the PR below it (the bottom's base is the stack base). On failure it names exactly which PR is not in the stack or out of place. Tests with a stubbed gh.
  2. A stacked-chain brief variant in bin/fm-brief.sh (--stack), used whenever dependent work ships as a chain: its definition of done requires creating or updating the native stack with gh stack (link for existing PRs, init/add/submit for new work), keeping PR numbers when adopting existing PRs, local checks per PR in the body, NOT running no-mistakes (trunk-only), and a done line that includes the passing fm-stack-check.sh output. Chained bases alone never satisfy it.
  3. Firstmate's own side: wherever firstmate records or reports a stack (for example bin/fm-pr-check.sh or the done-line handling), use the checker so firstmate cannot call something a stack that GitHub does not report as one. Keep this to the smallest hook that makes the check unavoidable.
  4. AGENTS.md: at most one or two lines in section 7 pointing at the brief variant and the checker (owner stays in the scripts). Update docs/configuration.md or other docs only where they already describe delivery modes.
    Constraints: load firstmate-coding-guidelines before editing; never run a test suite with a live FM_HOME exported; never stop, restart or update the shared no-mistakes daemon.

Decisions made while implementing (deliberate, not mistakes):

  • Real REST shape verified live, read-only, against existing stacks: zeigmal_mono 841/842 (open, stack fix(supervision): gate terminal PR read and close the watcher arm/guard race kunchenguid/firstmate#843) returns stack: {base: {ref, sha}, id, number, position, size}; merged 811/812 (stack fix(bin): recover Codex session locks without process inspection kunchenguid/firstmate#813) shows the upper PR retargeted to the stack base after the lower merged, per GitHub's documented retargeting. So the checker accepts the stack base as the base of a PR whose lower neighbour is merged (either that or the lower head); for unmerged layers the base must be exactly the lower PR's head. The zeigmal_cms 377/382 chain that was wrongly called a stack is correctly refused ("GitHub reports no stack").
  • The checker requires at least two github.com PR URLs in one repository (a GitHub stack holds two or more; stacks are same-repo only); usage errors exit 2 before any GitHub call; not-proven exits 1 with one error: line per problem naming the PR, its given position, and what GitHub reports, then a summary; success prints exactly one line stack ok: #<n> base=<branch> size=<n>: <urls...> fit for a done line. Fields are joined with the ASCII unit separator, not tabs, because bash collapses whitespace IFS runs and an empty stack field would shift the rest.
  • The checker test's gh stub applies the checker's own --jq program to JSON fixtures with the real jq (skips if jq is absent, like other jq-backed suites), so the real program is exercised.
  • --stack requires --mode direct-PR (refused with no-mistakes, local-only, scout, secondmate); its contract line is Delivery contract: mode=direct-PR stack=native, which fm-spawn's existing mode parse still reads as direct-PR (test added). Ordinary briefs stay byte-identical. Rule 1 forbids pushing outside the stack and gh stack merge. gh stack submit --auto is named because the worker's shell is non-interactive.
  • The hook is in bin/fm-pr-check.sh: it now accepts <task-id> <bottom-url> ... <top-url>; with more than one URL it runs fm-stack-check.sh before any side effect and records nothing unless proven; pr=, pr_head, pr_base, the merge poll, and the backlog link follow the TOP PR, because stacks merge bottom-up so the top merging means every layer landed. A task whose brief carries the stack contract line is refused with a single URL, EXCEPT when that URL is the pr= already recorded (the first record proved it) - this keeps bin/fm-control.sh relaunch, which re-arms with the recorded single pr=, and fm-pr-merge.sh on the recorded top PR working.
  • bin/fm-test-run.sh maps the new test and script into the pr-forge family; docs/architecture.md (delivery modes section) and docs/scripts.md (script inventory) get one-line cross-references; docs/configuration.md does not describe delivery modes so it is unchanged.
  • Firstmate review decision on finding stack-task-lower-layer-merge-refused: keep the protective refusal in bin/fm-pr-check.sh (a stacked task given a single URL other than its recorded pr= is refused) and fix only its message; a stack-aware partial merge is a separate follow-up, not this PR. The info-only single-url-rearm-not-reproven finding stays a deliberate no-op (the relaunch re-arm path).
  • Captain clarification that narrows scope and supersedes any top-PR merge wording: firstmate never merges stacks, and stacks are only ever used in repos the captain alone merges. So add no stack merge handling: bin/fm-pr-merge.sh is deliberately untouched (the +captain-merge policy already refuses those repos before recording), and there is no "merge the top PR" logic or message anywhere. The refusal message names the PR already recorded (or that nothing is recorded yet), says to record the stack with every PR bottom to top so bin/fm-stack-check.sh can prove it, and says firstmate never merges a stacked PR; the captain merges stacks. The --stack brief says the captain merges the stack and firstmate never merges a stacked PR. Keep the stack checker and the stack brief variant.
  • Known local-only test note: tests/fm-teardown.test.sh's fork-allow case fails only when run inside this particular pool worktree, which holds a live firstmate home's private data/projects/state (FM_ROOT_OVERRIDE=$ROOT reads it); the identical commit passes from a clean archive copy, as does main.

What Changed

  • New read-only bin/fm-stack-check.sh <bottom-pr-url> ... <top-pr-url> reads each PR's stack field from GitHub's REST API (version 2026-03-10) and exits 0 only when every PR reports the same stack number, positions 1..N in the given order, size N, and correctly chained bases. The stack base is also accepted as the base of a PR whose lower neighbour has merged. Success prints one stack ok: #<n> base=<branch> size=<n>: <urls> line. Each failure prints an error: line naming the PR, its given position, and what GitHub reports. Usage errors (fewer than two URLs, a non-github.com URL, PRs in different repositories) exit 2 before any GitHub call.
  • bin/fm-brief.sh gains --stack, accepted only on ship briefs with --mode direct-PR, and writes the contract line Delivery contract: mode=direct-PR stack=native. Its definition of done requires a native stack built with gh stack (init/add/submit --auto for new work, link for existing PRs, keeping their numbers) and local check results in each layer's PR body. It forbids /no-mistakes and gh stack merge, and requires a done line quoting the passing fm-stack-check.sh output. It states that the captain merges the stack. Ordinary briefs are unchanged.
  • bin/fm-pr-check.sh now accepts <task-id> <bottom-url> ... <top-url>. With more than one URL it runs fm-stack-check.sh before any side effect and records nothing unless the stack is proven; pr=, pr_head=, pr_base=, the merge poll, and the backlog link then follow the top PR. A task whose brief carries the stack contract line is refused with a single URL, unless that URL is the pr= already recorded. The refusal names the recorded PR and says firstmate never merges a stacked PR. bin/fm-test-run.sh maps the new script and its test into the pr-forge family. AGENTS.md section 7, docs/architecture.md, and docs/scripts.md get short cross-references. Tests cover the checker with a stubbed gh, the --stack brief, the fm-pr-check.sh stack hook, and fm-spawn's mode parse of the stack contract line.

Risk Assessment

✅ Low: The change is additive and fail-closed: a new read-only checker, an opt-in --stack brief variant that leaves ordinary briefs byte-identical, and a fm-pr-check.sh hook that validates every URL and proves the stack before any side effect. I confirmed gh's jq accepts the unit-separator field join and that the brief's gh stack commands match gh stack --help. Every required intent item is present, and bin/fm-pr-merge.sh is untouched as required.

Testing

I ran the four suites the change touches (stack-check, brief, pr-check-security, task-delivery) with no FM_HOME exported, and all passed. I then ran bin/fm-stack-check.sh live and read-only against real GitHub stacks and against real non-stack chains. It proved 2-layer, 4-layer and merged stacks. It refused a chained-base pair with no stack, a wrong order, a missing layer, a PR from another stack and usage errors, with the documented exit codes and per-PR messages. In an isolated temp home I drove the real --stack brief through the real fm-pr-check.sh hook with a fixture gh. Nothing was recorded or armed until the stack was proven. After proof, the top PR was recorded and the merge poll armed. The relaunch re-arm worked, and a lone lower layer was refused with a message naming the recorded PR and stating that the captain merges stacks. I also confirmed that every gh stack flag the brief names exists in the installed extension, that ordinary briefs are unchanged from base, and that fm-pr-merge.sh is untouched. Evidence is two CLI transcripts and the demo script. This is a CLI-only change, so there are no screenshots. The live transcript has the private organisation, repository and branch names redacted, because the evidence branch is public.

Evidence: Live read-only fm-stack-check.sh transcript against real GitHub stacks (private names redacted)

Source: Live read-only fm-stack-check.sh transcript against real GitHub stacks (private names redacted)

# Live, read-only bin/fm-stack-check.sh against GitHub (commit affd1db, 2026-09-17T14:43:29Z)
Redaction: the stacks live in private repositories, so the organisation, repository, and branch names, stack ids and SHAs were replaced after capture (same name -> same placeholder); PR numbers, stack numbers, positions, sizes, messages and exit codes are verbatim. base=main is verbatim.

## 1. Open 2-layer native stack, bottom to top -> proven
$ bin/fm-stack-check.sh https://github.com/private-org/repo-a/pull/841 https://github.com/private-org/repo-a/pull/842
stack ok: #843 base=branch-2 size=2: https://github.com/private-org/repo-a/pull/841 https://github.com/private-org/repo-a/pull/842
[exit 0]

## 2. Open 4-layer native stack, bottom to top -> proven
$ bin/fm-stack-check.sh https://github.com/private-org/repo-b/pull/385 https://github.com/private-org/repo-b/pull/378 https://github.com/private-org/repo-b/pull/381 https://github.com/private-org/repo-b/pull/383
stack ok: #387 base=main size=4: https://github.com/private-org/repo-b/pull/385 https://github.com/private-org/repo-b/pull/378 https://github.com/private-org/repo-b/pull/381 https://github.com/private-org/repo-b/pull/383
[exit 0]

## 3. Merged native stack (upper PR retargeted onto the stack base after the lower merged) -> proven
$ bin/fm-stack-check.sh https://github.com/private-org/repo-a/pull/811 https://github.com/private-org/repo-a/pull/812
stack ok: #813 base=branch-3 size=2: https://github.com/private-org/repo-a/pull/811 https://github.com/private-org/repo-a/pull/812
[exit 0]

## 4. Chained bases with NO GitHub stack (repo-b 372 targets the head branch of 370) -> refused, each PR named
$ bin/fm-stack-check.sh https://github.com/private-org/repo-b/pull/370 https://github.com/private-org/repo-b/pull/372
error: https://github.com/private-org/repo-b/pull/370 (given position 1 of 2): GitHub reports no stack for this PR
error: https://github.com/private-org/repo-b/pull/372 (given position 2 of 2): GitHub reports no stack for this PR
not proven: GitHub does not report these 2 PRs as one native stack in this order
[exit 1]

## 5. Same stack, wrong order -> refused, positions named
$ bin/fm-stack-check.sh https://github.com/private-org/repo-a/pull/842 https://github.com/private-org/repo-a/pull/841
error: https://github.com/private-org/repo-a/pull/842 (given position 1 of 2): GitHub places it at position 2 of stack #843
error: https://github.com/private-org/repo-a/pull/842 (given position 1 of 2): targets 'branch-4', not the stack base 'branch-2'
error: https://github.com/private-org/repo-a/pull/841 (given position 2 of 2): GitHub places it at position 1 of stack #843
error: https://github.com/private-org/repo-a/pull/841 (given position 2 of 2): targets 'branch-2', not 'branch-5', the branch of https://github.com/private-org/repo-a/pull/842 below it
not proven: GitHub does not report these 2 PRs as one native stack in this order
[exit 1]

## 6. A layer left off the command line (4-layer stack, 3 given) -> refused
$ bin/fm-stack-check.sh https://github.com/private-org/repo-b/pull/385 https://github.com/private-org/repo-b/pull/378 https://github.com/private-org/repo-b/pull/383
error: https://github.com/private-org/repo-b/pull/385 (given position 1 of 3): stack #387 holds 4 PRs, but 3 were given
error: https://github.com/private-org/repo-b/pull/378 (given position 2 of 3): stack #387 holds 4 PRs, but 3 were given
error: https://github.com/private-org/repo-b/pull/383 (given position 3 of 3): GitHub places it at position 4 of stack #387
error: https://github.com/private-org/repo-b/pull/383 (given position 3 of 3): stack #387 holds 4 PRs, but 3 were given
error: https://github.com/private-org/repo-b/pull/383 (given position 3 of 3): targets 'branch-6', not 'branch-7', the branch of https://github.com/private-org/repo-b/pull/378 below it
not proven: GitHub does not report these 3 PRs as one native stack in this order
[exit 1]

## 7. PR from a different stack mixed in -> refused
$ bin/fm-stack-check.sh https://github.com/private-org/repo-a/pull/841 https://github.com/private-org/repo-a/pull/812
error: https://github.com/private-org/repo-a/pull/812 (given position 2 of 2): in stack #813, but https://github.com/private-org/repo-a/pull/841 is in stack #843
error: https://github.com/private-org/repo-a/pull/812 (given position 2 of 2): targets 'branch-3', not 'branch-4', the branch of https://github.com/private-org/repo-a/pull/841 below it
not proven: GitHub does not report these 2 PRs as one native stack in this order
[exit 1]

## 8. Usage errors exit 2 before any GitHub call
$ bin/fm-stack-check.sh https://github.com/private-org/repo-a/pull/841
error: a stack needs at least two PR URLs, given bottom to top
[exit 2]

$ bin/fm-stack-check.sh https://github.com/private-org/repo-a/pull/841 https://github.com/private-org/repo-b/pull/377
error: every PR in a stack must be in one repository; https://github.com/private-org/repo-b/pull/377 is not in private-org/repo-a
[exit 2]

$ bin/fm-stack-check.sh https://github.com/private-org/repo-a/pull/841 https://gitlab.com/a/b/-/merge_requests/3
error: not a github.com pull request URL: https://gitlab.com/a/b/-/merge_requests/3
[exit 2]

## Raw GitHub REST stack field the checker reads (API version 2026-03-10)
$ gh api -H X-GitHub-Api-Version: 2026-03-10 repos/private-org/repo-a/pulls/842 --jq {stack, base: .base.ref, head: .head.ref, merged}
{"base":"branch-4","head":"branch-5","merged":false,"stack":{"base":{"ref":"branch-2","sha":"<redacted>"},"id":"<redacted>","number":843,"position":2,"size":2}}
[exit 0]

$ gh api -H X-GitHub-Api-Version: 2026-03-10 repos/private-org/repo-b/pulls/372 --jq {stack, base: .base.ref, head: .head.ref, merged}
{"base":"branch-8","head":"branch-9","merged":true,"stack":null}
[exit 0]

## Note: repo-b 377/382, the chain first wrongly called a stack, has since been linked; GitHub now reports it as stack #392
$ bin/fm-stack-check.sh https://github.com/private-org/repo-b/pull/377 https://github.com/private-org/repo-b/pull/382
stack ok: #392 base=main size=2: https://github.com/private-org/repo-b/pull/377 https://github.com/private-org/repo-b/pull/382
[exit 0]
Evidence: End-to-end transcript: --stack brief, then fm-pr-check.sh hook refusing unproven stacks and recording a proven one

Source: End-to-end transcript: --stack brief, then fm-pr-check.sh hook refusing unproven stacks and recording a proven one

## A. Scaffold the stacked-chain brief
$ bin/fm-brief.sh task-a some-proj --mode direct-PR --stack
scaffolded: <tmp>/home/data/task-a/brief.md (ship, mode=direct-PR, native stack; replace {TASK})

--- brief.md: rule 1 and definition of done ---
21:1. Never push to the default branch or to any branch outside this task's stack. Never merge a PR, and never run `gh stack merge`.
# Definition of done
Delivery contract: mode=direct-PR stack=native
This task ships **direct-PR as one native GitHub stack**: the dependent changes land as a chain of PRs that GitHub itself links into a stack, without the no-mistakes pipeline.
PRs whose bases merely point at each other are NOT a stack: GitHub creates the stack as a server-side object only through `gh stack`, and nothing short of that object satisfies this task.
Do NOT run /no-mistakes: it validates against the default branch only and cannot ship a stack.

Build or update the stack with the `gh stack` extension; `gh stack <command> --help` is authoritative for its flags.
- New work: start the bottom layer with `gh stack init --base <trunk> fm/task-a`, add each layer above it with `gh stack add fm/task-a-<n>`, one concern per layer, then run `gh stack submit --auto` to push every branch, open the PRs with chained bases, and create the stack; its new PRs are drafts unless you add `--open`.
- Existing PRs: adopt them with `gh stack link <bottom> ... <top>`, naming each by PR number or URL from bottom to top; it corrects their bases and creates or extends the stack. Keep those PR numbers: never close, recreate, or replace a PR to build the stack.
- After a rebase or restack, run `gh stack submit --auto` again so GitHub's stack matches your branches.
If `gh stack` is unavailable, or GitHub reports that stacked pull requests are not enabled for the repository, append `blocked: {why}` and stop; never fall back to chained bases.

Run the project's local checks at every layer's own head, and put the exact commands and their results in that layer's PR body, so every layer carries its own evidence whether or not CI runs for it.
When every layer is committed, pushed, and in the stack, prove it: `<fm-root>/bin/fm-stack-check.sh <bottom-pr-url> ... <top-pr-url>` must exit 0.
Then append `done: {the one line fm-stack-check.sh printed}` to the status file and stop.
Never report done without that passing output.
The captain merges the stack; firstmate never merges a stacked PR and relays the outcome.

## B. --stack is refused outside direct-PR ship briefs
$ bin/fm-brief.sh t-b some-proj --mode no-mistakes --stack
error: --stack requires --mode direct-PR: no-mistakes validates against the default branch only and cannot ship a stack, and local-only opens no PR
[exit 1]
$ bin/fm-brief.sh t-c some-proj --mode local-only --stack
error: --stack requires --mode direct-PR: no-mistakes validates against the default branch only and cannot ship a stack, and local-only opens no PR
[exit 1]
$ bin/fm-brief.sh t-d some-proj --scout --stack
error: --stack applies only to ship briefs; a scout delivers a report and a secondmate charter is not a delivery contract
[exit 1]

## C. Worker opened two PRs whose bases chain, but GitHub reports no stack -> nothing recorded
$ bin/fm-pr-check.sh task-a https://github.com/o/r/pull/21 https://github.com/o/r/pull/22
error: https://github.com/o/r/pull/21 (given position 1 of 2): GitHub reports no stack for this PR
error: https://github.com/o/r/pull/22 (given position 2 of 2): GitHub reports no stack for this PR
not proven: GitHub does not report these 2 PRs as one native stack in this order
error: not recorded: GitHub does not report these PRs as one native stack in this order
[exit 1]
  state/task-a.meta pr lines: 
  merge poll armed: no

## D. Firstmate tries to record the stacked task as a lone PR -> refused
$ bin/fm-pr-check.sh task-a https://github.com/o/r/pull/22
error: https://github.com/o/r/pull/22 is one PR of stacked task task-a (recorded: nothing yet); record the stack with every PR bottom to top so bin/fm-stack-check.sh can prove it. Firstmate never merges a stacked PR; the captain merges stacks
[exit 1]
  state/task-a.meta pr lines: 
  merge poll armed: no

## E. After gh stack link, GitHub reports stack #70 -> proven, recorded on the top PR, poll armed
$ bin/fm-pr-check.sh task-a https://github.com/o/r/pull/21 https://github.com/o/r/pull/22
stack ok: #70 base=main size=2: https://github.com/o/r/pull/21 https://github.com/o/r/pull/22
skipped: tasks-axi is absent or older than 0.2.5, so the backlog was not written
armed: state/task-a.check.sh
[exit 0]
  state/task-a.meta pr lines: pr=https://github.com/o/r/pull/22 pr_head=0123456789abcdef0123456789abcdef01234567 pr_base=fm/task-a-2 
  merge poll armed: yes

## F. Relaunch re-arm with the recorded top PR alone -> allowed
$ bin/fm-pr-check.sh task-a https://github.com/o/r/pull/22
skipped: tasks-axi is absent or older than 0.2.5, so the backlog was not written
armed: state/task-a.check.sh
[exit 0]
  state/task-a.meta pr lines: pr=https://github.com/o/r/pull/22 pr_head=0123456789abcdef0123456789abcdef01234567 pr_base=fm/task-a-2 
  merge poll armed: yes

## G. A lower layer alone -> refused, names the recorded PR
$ bin/fm-pr-check.sh task-a https://github.com/o/r/pull/21
error: https://github.com/o/r/pull/21 is one PR of stacked task task-a (recorded: https://github.com/o/r/pull/22); record the stack with every PR bottom to top so bin/fm-stack-check.sh can prove it. Firstmate never merges a stacked PR; the captain merges stacks
[exit 1]
  state/task-a.meta pr lines: pr=https://github.com/o/r/pull/22 pr_head=0123456789abcdef0123456789abcdef01234567 pr_base=fm/task-a-2 
  merge poll armed: yes
Evidence: Demo script that produced the end-to-end transcript (throwaway FM_HOME, fixture gh served through real jq)

Source: Demo script that produced the end-to-end transcript (throwaway FM_HOME, fixture gh served through real jq)

#!/usr/bin/env bash
# End-to-end demo in a throwaway FM_HOME: the REAL bin/fm-brief.sh --stack brief
# drives the REAL bin/fm-pr-check.sh hook, which runs the REAL
# bin/fm-stack-check.sh. Only `gh` is a fixture: it serves pull-request JSON
# through the checker's own --jq program with the real jq.
set -u
ROOT=$1
D=$(mktemp -d /tmp/fm-stack-demo.XXXXXX); trap 'rm -rf "$D"' EXIT
mkdir -p "$D/home/state" "$D/home/data" "$D/home/config" "$D/wt" "$D/fakebin" "$D/root/bin" "$D/pulls"
printf '#!/usr/bin/env bash\nexit 0\n' > "$D/root/bin/fm-guard.sh"; chmod +x "$D/root/bin/fm-guard.sh"
JQ=$(command -v jq)
cat > "$D/fakebin/gh" <<'SH'
#!/usr/bin/env bash
if [ "${1:-}" = api ]; then
  path= program=
  while [ "$#" -gt 0 ]; do
    case "$1" in -H) shift 2 ;; --jq) program=$2; shift 2 ;; *) path=$1; shift ;; esac
  done
  [ -f "$DEMO_PULLS/${path##*/}.json" ] || exit 1
  exec "$DEMO_JQ" -r "$program" "$DEMO_PULLS/${path##*/}.json"
fi
case " $* " in
  *" headRefOid "*) echo 0123456789abcdef0123456789abcdef01234567 ;;
  *" baseRefName "*) echo fm/task-a-2 ;;
  *" state "*) echo OPEN ;;
esac
SH
chmod +x "$D/fakebin/gh"
pull() { # <n> <base> <head> [<stack#> <pos> <size> <stack-base>]
  local stack=null
  [ "$#" -lt 7 ] || stack="{\"number\":$4,\"position\":$5,\"size\":$6,\"base\":{\"ref\":\"$7\"}}"
  printf '{"number":%s,"merged":false,"base":{"ref":"%s"},"head":{"ref":"%s"},"stack":%s}\n' "$1" "$2" "$3" "$stack" > "$D/pulls/$1.json"
}
check() {
  echo "\$ bin/fm-pr-check.sh $*"
  FM_ROOT_OVERRIDE="$D/root" FM_HOME="$D/home" DEMO_PULLS="$D/pulls" DEMO_JQ="$JQ" \
    PATH="$D/fakebin:/usr/bin:/bin" "$ROOT/bin/fm-pr-check.sh" "$@" 2>&1 | sed "s#$D#<tmp>#g"
  echo "[exit ${PIPESTATUS[0]}]"
  echo "  state/task-a.meta pr lines: $(grep '^pr[=_]' "$D/home/state/task-a.meta" | tr '\n' ' ')"
  echo "  merge poll armed: $([ -e "$D/home/state/task-a.check.sh" ] && echo yes || echo no)"
  echo
}
U=https://github.com/o/r/pull

echo "## A. Scaffold the stacked-chain brief"
echo "\$ bin/fm-brief.sh task-a some-proj --mode direct-PR --stack"
FM_HOME="$D/home" "$ROOT/bin/fm-brief.sh" task-a some-proj --mode direct-PR --stack 2>&1 | sed "s#$D#<tmp>#g"
echo; echo "--- brief.md: rule 1 and definition of done ---"
grep -n '^1\. Never push' "$D/home/data/task-a/brief.md" | sed "s#$ROOT#<fm-root>#g"
sed -n '/^# Definition of done/,$p' "$D/home/data/task-a/brief.md" | sed "s#$ROOT#<fm-root>#g"
echo
echo "## B. --stack is refused outside direct-PR ship briefs"
for args in "t-b some-proj --mode no-mistakes --stack" "t-c some-proj --mode local-only --stack" "t-d some-proj --scout --stack"; do
  echo "\$ bin/fm-brief.sh $args"
  # shellcheck disable=SC2086
  FM_HOME="$D/home" "$ROOT/bin/fm-brief.sh" $args 2>&1 | sed "s#$D#<tmp>#g"; echo "[exit ${PIPESTATUS[0]}]"
done
echo
printf 'window=firstmate:fm-task-a\nendpoint_task_id=task-a\nworktree=%s\nproject=%s\nkind=ship\nmode=direct-PR\n' "$D/wt" "$D/project" > "$D/home/state/task-a.meta"

echo "## C. Worker opened two PRs whose bases chain, but GitHub reports no stack -> nothing recorded"
pull 21 main fm/task-a; pull 22 fm/task-a fm/task-a-2
check task-a $U/21 $U/22
echo "## D. Firstmate tries to record the stacked task as a lone PR -> refused"
check task-a $U/22
echo "## E. After gh stack link, GitHub reports stack #70 -> proven, recorded on the top PR, poll armed"
pull 21 main fm/task-a 70 1 2 main; pull 22 fm/task-a fm/task-a-2 70 2 2 main
check task-a $U/21 $U/22
echo "## F. Relaunch re-arm with the recorded top PR alone -> allowed"
check task-a $U/22
echo "## G. A lower layer alone -> refused, names the recorded PR"
check task-a $U/21
Evidence: Checker verdicts, live (excerpt)
$ bin/fm-stack-check.sh .../repo-b/pull/385 .../pull/378 .../pull/381 .../pull/383
stack ok: #387 base=main size=4: .../pull/385 .../pull/378 .../pull/381 .../pull/383
[exit 0]

$ bin/fm-stack-check.sh .../repo-b/pull/370 .../pull/372 # bases chain, GitHub stack: null
error: .../pull/370 (given position 1 of 2): GitHub reports no stack for this PR
error: .../pull/372 (given position 2 of 2): GitHub reports no stack for this PR
not proven: GitHub does not report these 2 PRs as one native stack in this order
[exit 1]

$ bin/fm-stack-check.sh .../repo-b/pull/385 .../pull/378 .../pull/383 # one layer omitted
error: .../pull/385 (given position 1 of 3): stack #387 holds 4 PRs, but 3 were given
error: .../pull/383 (given position 3 of 3): GitHub places it at position 4 of stack #387
...
[exit 1]
Evidence: fm-pr-check.sh hook (excerpt)
$ bin/fm-pr-check.sh task-a https://github.com/o/r/pull/22 # stacked task, lone PR
error: https://github.com/o/r/pull/22 is one PR of stacked task task-a (recorded: nothing yet); record the stack with every PR bottom to top so bin/fm-stack-check.sh can prove it. Firstmate never merges a stacked PR; the captain merges stacks
[exit 1] (meta unchanged, no poll armed)

$ bin/fm-pr-check.sh task-a https://github.com/o/r/pull/21 https://github.com/o/r/pull/22
stack ok: #70 base=main size=2: https://github.com/o/r/pull/21 https://github.com/o/r/pull/22
armed: state/task-a.check.sh
[exit 0] (meta: pr=https://github.com/o/r/pull/22, poll armed)
- Outcome: ⚠️ 1 info across 1 run (10m34s)

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

⚠️ **Review** - 1 info
  • ℹ️ bin/fm-stack-check.sh:106 - The per-PR gh api read discards stderr (2&gt;/dev/null), so an unauthenticated gh, a 404, a rate limit, and an unsupported API version all collapse into the same 'could not read the PR from GitHub' line. The check still fails closed (exit 1, nothing recorded), so this is only a diagnosability tradeoff: a worker or firstmate hitting an auth/API problem cannot tell it apart from a wrong PR number without re-running gh by hand. Optional follow-up: append gh's first stderr line to the problem message.
⚠️ **Test** - 1 info
  • ℹ️ bin/fm-stack-check.sh - The intent says the zeigmal_cms 377/382 chain is refused with "GitHub reports no stack". Live today GitHub's REST API reports both PRs as native stack fix(wake-lib): derive Linux pid identity from /proc ticks, not ps lstart kunchenguid/firstmate#392 (positions 1/2, size 2, base main), so the checker correctly prints stack ok for them now; the chain has evidently been linked since. The no-stack refusal was instead demonstrated live on another real chained pair in the same repo (370 -> 372, stack: null), which is refused with each PR named. Not a code defect.
  • bash tests/fm-stack-check.test.sh (no FM_HOME exported) - all pass
  • bash tests/fm-brief.test.sh - all pass, including the --stack variant and the --stack refusal cases
  • bash tests/fm-pr-check-security.test.sh - all pass, including 'records a stack only when GitHub proves it, and a stacked task never as a lone PR'
  • bash tests/fm-task-delivery.test.sh - all pass, including fm-spawn reading the stack=native contract line as direct-PR
  • Live read-only bin/fm-stack-check.sh &lt;bottom&gt; ... &lt;top&gt; on real private stacks: an open 2-layer stack, an open 4-layer stack, and a merged stack whose upper PR was retargeted to the stack base all exit 0 with one stack ok: line
  • Live read-only bin/fm-stack-check.sh negative cases: a chained-base pair with stack: null, reversed order, a 4-layer stack given with 3 URLs, and a PR from another stack all exit 1 with per-PR error: lines
  • bin/fm-stack-check.sh usage errors: a single URL, cross-repository URLs and a GitLab URL all exit 2
  • Live gh api -H &#39;X-GitHub-Api-Version: 2026-03-10&#39; repos/&lt;o&gt;/&lt;r&gt;/pulls/&lt;n&gt; --jq &#39;{stack,...}&#39; confirms the REST stack shape the checker reads: number, position, size and base.ref, or null
  • Manual end-to-end in a throwaway FM_HOME (stack-hook-e2e-demo.sh), part 1: real bin/fm-brief.sh task-a some-proj --mode direct-PR --stack brief, and --stack refused with no-mistakes, local-only and scout
  • Manual end-to-end, part 2, with a fixture-backed gh (real jq): real bin/fm-pr-check.sh against that brief refuses chained-only PRs and a lone PR and records nothing
  • Manual end-to-end, part 3: a proven stack is recorded on the top PR with the merge poll armed; re-arming with the recorded top PR alone is allowed; a lower layer alone is refused
  • gh stack init|add|submit|link --help (gh-stack v0.1.0) confirms every command and flag the brief names exists: --base, --auto, --open, and link by PR number or URL
  • Scaffolded ordinary no-mistakes, direct-PR and local-only briefs from base 938dff7 and from the target: they differ only by the temp-home path
  • git diff --quiet 938dff7 affd1db -- bin/fm-pr-merge.sh confirms bin/fm-pr-merge.sh is untouched
✅ **Document** - passed

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

… a stack

PRs whose bases merely point at each other were twice presented as a stack
when GitHub reported none, and stack instructions to workers named neither
how to create a native stack nor how to verify one.

- bin/fm-stack-check.sh proves PRs, given bottom to top, are one native
  GitHub stack: the same stack number on every PR, positions 1..N in order,
  size N, and each base the branch of the PR below (the bottom on the stack
  base; a PR above a merged layer may target the stack base). It reads the
  REST pull request stack field at API version 2026-03-10 and names every PR
  that is outside the stack or out of place.
- bin/fm-brief.sh --stack (direct-PR only) scaffolds the stacked-chain
  variant: build or adopt the stack with gh stack (link for existing PRs,
  keeping their numbers; init/add/submit for new work), local checks in every
  PR body, no no-mistakes, and a done line quoting the passing checker proof.
- bin/fm-pr-check.sh records a stack with every PR bottom to top, only after
  the checker proves it, on the top PR; a stacked-chain task cannot be
  recorded as a lone PR except to re-arm its already-recorded top PR, as
  relaunch does.
Firstmate never merges a stacked PR; stacks are used only in repositories
the captain alone merges. The single-URL refusal for a stacked task no
longer describes a top-PR merge path: it names the PR already recorded,
says how to record the stack, and says the captain merges stacks. The
stack brief now says the captain merges the stack.
@SchokoShake
SchokoShake force-pushed the fm/fm-native-stack-mechanism branch from abc4e26 to 275f75a Compare September 17, 2026 14:47
@SchokoShake
SchokoShake merged commit fdafbfb into main Sep 17, 2026
13 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant