Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ Shared tracked material is `AGENTS.md`, `README.md`, `CONTRIBUTING.md`, `.tasks.
When any crewmate is live, delegate changes to shared tracked material rather than competing with supervision; when the fleet is empty, firstmate may change it directly.
This repo is a shared template, while `.env`, `data/`, `state/`, `config/`, `projects/`, and `.no-mistakes/` are captain-private and gitignored.
Ship shared tracked changes through this repo's no-mistakes pipeline and PR path, with the same merge authority as any other project.
Firstmate repo tasks land into this home's local `main` via `bin/fm-merge-local.sh` once approved, while the outward-facing PR remains open; `docs/configuration.md` owns which remote that PR opens on.
Firstmate repo tasks land through `bin/fm-merge-local.sh` once approved; [`docs/configuration.md`](docs/configuration.md#landing-remote-git-origin) owns the configured-fork sync and recorded GitHub PR read-back.
Never add an agent name as a commit co-author.
Use `gh-axi` for GitHub, `chrome-devtools-axi` for browser work, and compatible `lavish-axi` for visual decisions or reports; consult current help rather than memorizing flags.

Expand Down
127 changes: 127 additions & 0 deletions bin/fm-merge-local.sh
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,21 @@
# merge, so a captain approval must be recorded as an `answer --release` before
# this entrypoint is invoked. The lock ends when the fast-forward returns;
# docs/captain-hold-lifecycle.md owns the accepted merge-to-cleanup residual.
#
# For firstmate's own repository (bin/fm-self-repo-lib.sh), the landing also
# updates the fork: after the local fast-forward it pushes local default to the
# landing remote `origin` only after bin/fm-landing-remote.sh proves the remap
# and every effective origin push URL names that same remote, as a plain
# fast-forward, never forced and never anywhere else, then reads the remote
# branch back. When the task records a GitHub pr=, that PR must then read back
# merged through the same forge read bin/fm-pr-merge.sh uses, retried a bounded
# number of times while the forge catches up. A checkout with no `upstream`
# remote has no configured fork, so it reports that nothing was pushed.
# Exit status: 0 landed (and any configured fork was synced and proved);
# 3 landed locally but the fork sync or PR read-back was not proved - the local
# landing stays, and the message names why and the exact command to finish;
# any other non-zero value means nothing was landed.
# Project landings never push: local-only projects have no remote by design.
# Usage: fm-merge-local.sh <task-id>
set -eu

Expand Down Expand Up @@ -84,6 +99,7 @@ fi

PROJ=$(grep '^project=' "$META" | cut -d= -f2-)
MODE=$(grep '^mode=' "$META" | cut -d= -f2- || true)
PR_URL=$(grep '^pr=' "$META" | tail -n 1 | cut -d= -f2- || true)

if [ "$MODE" != "local-only" ] && ! fm_is_firstmate_repo "$PROJ" "$FM_ROOT" "$FM_HOME"; then
echo "error: task $ID is mode=$MODE on $PROJ, not local-only; merge PR tasks with bin/fm-pr-merge.sh <id> <PR url> after approval" >&2
Expand Down Expand Up @@ -159,3 +175,114 @@ after=$(git -C "$PROJ" rev-parse --short "$DEFAULT")
# Opt-in fleet activity ledger (docs/fleet-ledger.md); off costs one file test.
[ ! -e "${FM_CONFIG_OVERRIDE:-$FM_HOME/config}/fleet-ledger" ] || FM_HOME=$FM_HOME FM_STATE_OVERRIDE=$STATE "$SCRIPT_DIR/fm-fleet-ledger.sh" merged "$ID" local || true
echo "merged $BRANCH into local $DEFAULT ($before -> $after) in $PROJ"

# Firstmate's own repository also updates its landing remote, so the fork on
# the forge holds exactly what this home runs. Project landings stop here.
fm_is_firstmate_repo "$PROJ" "$FM_ROOT" "$FM_HOME" || exit 0

FORK_REMOTE=origin
LOCAL_SHA=$(git -C "$PROJ" rev-parse "refs/heads/$DEFAULT")
FINISH=$(printf 'git -C %q push %s refs/heads/%s:refs/heads/%s' "$PROJ" "$FORK_REMOTE" "$DEFAULT" "$DEFAULT")
# Never wait on a credential prompt nobody will answer.
export GIT_TERMINAL_PROMPT=0

# Report an unsynced fork and exit 3. The local landing above stays as it is.
fork_not_synced() { # <why> [<next-step line>...]
echo "fork not updated: local $DEFAULT landed at $after, but $FORK_REMOTE/$DEFAULT was not updated: $1" >&2
shift
local line
for line in "$@"; do
echo "$line" >&2
done
exit 3
}

if ! git -C "$PROJ" remote | grep -Fx upstream >/dev/null 2>&1; then
echo "no fork is configured in $PROJ because there is no upstream remote; nothing was pushed"
exit 0
fi
if ! upstream_url=$(git -C "$PROJ" config --local --get remote.upstream.url 2>&1) \
|| [ -z "$upstream_url" ]; then
fork_not_synced "upstream is present but has no configured URL" \
"Repair the remotes, then finish with: $FINISH"
fi
if ! git -C "$PROJ" remote get-url "$FORK_REMOTE" >/dev/null 2>&1; then
fork_not_synced "$FORK_REMOTE is absent from this remapped checkout" \
"Repair the remotes, then finish with: $FINISH"
fi
# bin/fm-landing-remote.sh owns whether origin is the landing remote rather
# than the parent we forked from; any doubt keeps the push from happening.
if ! verify_output=$("$SCRIPT_DIR/fm-landing-remote.sh" verify --repo "$PROJ" 2>&1); then
fork_not_synced "$FORK_REMOTE is not proven to be our fork: $verify_output" \
"Repair the remotes as described, then finish with: $FINISH"
fi
if ! push_urls=$(git -C "$PROJ" remote get-url --push --all "$FORK_REMOTE" 2>&1) \
|| [ -z "$push_urls" ]; then
fork_not_synced "could not resolve where $FORK_REMOTE would push: $push_urls" \
"Repair remote.$FORK_REMOTE.pushurl, then finish with: $FINISH"
fi
while IFS= read -r push_url; do
if ! push_verify=$("$SCRIPT_DIR/fm-landing-remote.sh" verify --ours "$push_url" --repo "$PROJ" 2>&1); then
fork_not_synced "$FORK_REMOTE would push to $push_url rather than its verified fetch URL: $push_verify" \
"Remove or correct remote.$FORK_REMOTE.pushurl, then finish with: $FINISH"
fi
done <<PUSH_URLS
$push_urls
PUSH_URLS
if ! remote_line=$(git -C "$PROJ" ls-remote "$FORK_REMOTE" "refs/heads/$DEFAULT" 2>&1); then
fork_not_synced "could not read $FORK_REMOTE/$DEFAULT: $remote_line" \
"Finish with: $FINISH"
fi
REMOTE_SHA=${remote_line%%[[:space:]]*}
if [ -n "$REMOTE_SHA" ] && [ "$REMOTE_SHA" != "$LOCAL_SHA" ]; then
if ! git -C "$PROJ" cat-file -e "$REMOTE_SHA^{commit}" 2>/dev/null \
&& ! fetch_output=$(git -C "$PROJ" fetch --quiet "$FORK_REMOTE" "refs/heads/$DEFAULT" 2>&1); then
fork_not_synced "could not fetch $FORK_REMOTE/$DEFAULT: $fetch_output" \
"Finish with: $FINISH"
fi
if ! git -C "$PROJ" merge-base --is-ancestor "$REMOTE_SHA" "$LOCAL_SHA" 2>/dev/null; then
fork_not_synced "it is not a fast-forward: $FORK_REMOTE/$DEFAULT is at ${REMOTE_SHA:0:8}, which local $DEFAULT does not contain" \
"See the missing commits with: git -C $(printf '%q' "$PROJ") log --oneline $LOCAL_SHA..$REMOTE_SHA" \
"Bring them into local $DEFAULT through a reviewed task, then finish with: $FINISH"
fi
fi
if [ "$REMOTE_SHA" != "$LOCAL_SHA" ]; then
# A plain refspec without "+": the remote itself refuses anything but a
# fast-forward, even if its branch moved after the check above.
if ! push_output=$(git -C "$PROJ" push --quiet "$FORK_REMOTE" "refs/heads/$DEFAULT:refs/heads/$DEFAULT" 2>&1); then
fork_not_synced "the push failed: $push_output" \
"Finish with: $FINISH"
fi
if ! remote_line=$(git -C "$PROJ" ls-remote "$FORK_REMOTE" "refs/heads/$DEFAULT" 2>&1) \
|| [ "${remote_line%%[[:space:]]*}" != "$LOCAL_SHA" ]; then
fork_not_synced "the push returned, but $FORK_REMOTE/$DEFAULT does not read back as ${LOCAL_SHA:0:8}: $remote_line" \
"Check it, and if needed finish with: $FINISH"
fi
echo "pushed local $DEFAULT to $FORK_REMOTE/$DEFAULT (${REMOTE_SHA:0:8} -> ${LOCAL_SHA:0:8})"
else
echo "$FORK_REMOTE/$DEFAULT already at ${LOCAL_SHA:0:8}"
fi

# A recorded PR is proved merged by the same forge read the merge path uses.
[ -n "$PR_URL" ] || exit 0
if ! fm_pr_url_parse "$PR_URL" || [ "$FM_PR_PROVIDER" != github ]; then
echo "$FORK_REMOTE/$DEFAULT now holds local $DEFAULT; PR state was not checked because '$PR_URL' is not a GitHub pull request"
exit 0
fi
# The forge marks a PR merged shortly after its head reaches the base branch,
# so the read is retried a bounded number of times.
readback_attempt=1
while :; do
FM_PR_RECORD_STATE=
FM_PR_RECORD_MERGED=
fm_pr_github_read_record "$FM_PR_OWNER" "$FM_PR_REPO" "$FM_PR_NUMBER" || true
[ "$FM_PR_RECORD_MERGED" != true ] || break
if [ "$readback_attempt" -ge 5 ]; then
echo "error: $FORK_REMOTE/$DEFAULT now holds local $DEFAULT, but $PR_URL does not read back as merged (state=${FM_PR_RECORD_STATE:-unreadable})" >&2
echo "Re-check that PR on the forge; the fork itself is up to date." >&2
exit 3
fi
sleep 3
readback_attempt=$((readback_attempt + 1))
done
echo "verified: $PR_URL is merged"
15 changes: 7 additions & 8 deletions bin/fm-pr-merge.sh
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
# host and path, so any instance works and no host is hardcoded. A Gerrit change
# is refused outright: that adapter is read-only, and the refusal at the parse
# below owns why.
# Firstmate's own repository is local-authoritative, so this script refuses its
# tasks before any forge read or write and directs them to bin/fm-merge-local.sh.
#
# Merge method on GitHub defaults to --squash when the caller passes none of
# --squash, --merge, --rebase, or --method after the optional -- separator.
Expand Down Expand Up @@ -421,6 +423,11 @@ if [ "$FM_BACKLOG_META_SPAWN_GEN" != "$MERGE_EXPECTED_SPAWN_GEN" ]; then
echo "error: task $ID changed incarnation while waiting to merge; refusing" >&2
exit 1
fi
PROJ=$(grep '^project=' "$META" | cut -d= -f2- || true)
if [ -n "$PROJ" ] && [ -d "$PROJ" ] && fm_is_firstmate_repo "$PROJ" "$FM_ROOT" "$FM_HOME"; then
echo "error: task $ID is Firstmate's local-authoritative repository; use bin/fm-merge-local.sh $ID after approval instead of merging its PR" >&2
exit 1
fi

# Reading the merge request state needs both tools. Report them together and
# before anything is recorded, so a missing tool is a named prerequisite rather
Expand Down Expand Up @@ -1489,11 +1496,3 @@ case "$outcome_rc" in
printf 'actionable: merged %s but could not record the outcome for supervision\n' "$URL" >&2
;;
esac

# Firstmate's own repository is local-authoritative: the outward PR stays open on
# origin, and the proved merge above is followed by the guarded fast-forward into
# this home's local main. Reached only after the forge confirmed the merge landed.
PROJ=$(grep '^project=' "$META" | cut -d= -f2- || true)
if [ -n "$PROJ" ] && [ -d "$PROJ" ] && fm_is_firstmate_repo "$PROJ" "$FM_ROOT" "$FM_HOME"; then
"$SCRIPT_DIR/fm-merge-local.sh" "$ID"
fi
9 changes: 5 additions & 4 deletions bin/fm-self-repo-lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,15 @@
# Firstmate ships work on itself, so its tracked code root (FM_ROOT) and its
# operational home (FM_HOME) both turn up as a task's project directory. Four
# decisions branch on that fact and must agree exactly:
# bin/fm-merge-local.sh accepts a PR-mode task for the local fast-forward
# bin/fm-pr-merge.sh follows a merged PR with that same local landing
# bin/fm-merge-local.sh accepts a PR-mode task for the local fast-forward,
# then syncs any configured fork
# bin/fm-pr-merge.sh refuses a forge merge for the local-authoritative repo
# bin/fm-fleet-sync.sh leaves the checkout alone (upstream sync is manual)
# bin/fm-spawn.sh refreshes a task worktree from the LOCAL default
# branch instead of fetching origin
# A project that counts as firstmate for one of them and not another is how a
# worker gets reset onto a remote tip the fleet never reviewed, or how a merged
# firstmate PR silently fails to reach the running tree. One predicate here
# worker gets reset onto a remote tip the fleet never reviewed, or how a
# Firstmate landing silently fails to reach its fork. One predicate here
# keeps a later fix from reaching three call sites and missing the fourth.
#
# Comparison is by resolved PHYSICAL path, so a symlinked home, a trailing
Expand Down
3 changes: 2 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -399,7 +399,7 @@ When a selected delivery path calls for a diff, `bin/fm-review-diff.sh` refreshe
A GitLab merge request and a Gerrit change expose no such ref, so a task recording one of those diffs the local branch under that same warning, which is its current content.
Where a no-mistakes pipeline stores evidence in the repo, it publishes that PR-viewable validation evidence to an orphan evidence branch that shares no history with code branches, so it never enters the crew branch or the default branch.
This repo uses that setting, and its own `.no-mistakes/` directory remains local state that stays gitignored and is rejected by CI if tracked; [`configuration.md`](configuration.md) owns the setting.
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 the forge CLI.
PR-based task merges on remote-authoritative projects go through `bin/fm-pr-merge.sh`, which records `pr=` and any available `pr_head=` through `bin/fm-pr-check.sh` before calling the forge CLI; Firstmate's local-authoritative repository is refused before any forge read or write and lands through `bin/fm-merge-local.sh`.
The helper requires a full canonical URL and rejects malformed URLs or repo override flags before recording merge state.
A `https://github.com/<owner>/<repo>/pull/<n>` URL requires `gh` and `jq`, is merged only after live reads confirm the pull request is open, not a draft, mergeable, conflict-free, every unwaived check is green at the current head, and every unwaived check the base branch requires has reported at that head, then `gh pr merge` binds that verified head with `--match-head-commit`.
A required check that never reported is absent from the checks list rather than red; [`bin/fm-pr-merge.sh`](../bin/fm-pr-merge.sh)'s header owns required-context sources, producer identity, partial-read refusals, and attended check waivers.
Expand Down Expand Up @@ -428,6 +428,7 @@ The project-owned quality-gate contract and the receipt a hardened run must emit
[`bin/fm-quality.sh`](../bin/fm-quality.sh) runs those commands, enforces the contract's bounds including its wall-clock one, and writes each phase's receipt; its header owns the round shape, the outcome-to-exit-code table, and the read-only mode that reports a standard task's scores without gating anything.
A task recorded `quality=hardened` is not done until that receipt exists and passes, so `bin/fm-crew-state.sh` filters every `done` verdict through that script's own status verdict and leaves every other posture's line exactly as it was.
`local-only` tasks and Firstmate's own repository tasks land into the local default branch through `bin/fm-merge-local.sh`.
[`configuration.md`](configuration.md#landing-remote-git-origin) owns the configured-fork sync that applies only to Firstmate's own repository.
After the forge accepts firstmate's merge request, the merge path persists the resolved away or attended authority bound to the task's canonical PR identity; while an away record exists any green merge runs under away authority, while a quiet record keeps attended authority, and which merge the captain's away words meant is the supervision session's reading.
A later merged poll consumes only that matching persisted value; with no match it records the landing as external rather than consulting a live away-posture record that may have been archived or replaced.
[`bin/fm-merge-authority-lib.sh`](../bin/fm-merge-authority-lib.sh)'s header owns resolution, private atomic persistence, identity-checked consumption, and retirement, while only the merge path gates on the answer.
Expand Down
7 changes: 7 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,13 @@ Given `--ours` it is an identity check on `origin`; with no `--ours` it asserts
`bin/fm-bootstrap.sh` runs the second form against the firstmate primary at every session start and relays a refusal as one `LANDING_REMOTE:` line, so a checkout that drifted back toward the parent - or that was never remapped at all - is surfaced there rather than discovered by a branch, a push, or a PR that went to the wrong repository.
A clone with neither an `upstream` nor a `fork` remote never had a parent to be remapped away from, so the check passes silently for it.

In a remapped checkout, every approved Firstmate landing updates this remote.
`bin/fm-merge-local.sh` pushes the local default branch to the same branch on `origin` as a plain fast-forward only after `verify` passes and every effective push URL identifies `origin`'s verified fetch URL.
A checkout without an `upstream` remote has no configured fork, so the local landing succeeds without a push.
If a configured fork cannot be proved or synchronized, the local landing remains and the command exits non-zero with the reason and the exact push command to finish after repairing the remote.
When the task records a GitHub PR, the command also confirms that GitHub reads it as merged.
The script header owns the exact checks, read-back retries, messages, and exit statuses.

## Calm preference (config/calm)

The Pi Calm extension and the Claude Code Calm mod share the local, gitignored `config/calm` preference under the effective Firstmate home.
Expand Down
4 changes: 2 additions & 2 deletions docs/scripts.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ The shared no-mistakes gate lifecycle boundary is summarized in [architecture.md
| `fm-forge-detect.sh` | Propose a clone's forge binding from its origin remote for project-add intake, never recording it |
| `fm-quality.sh` | Run a project's quality phase under its own bounds, write its receipt, report one outcome |
| `fm-quality-receipt.sh` | Validate a quality-gate receipt against the D2 schema, or print that schema |
| `fm-merge-local.sh` | Fast-forward a `local-only` project or Firstmate's own repository local default branch after approval |
| `fm-merge-local.sh` | Fast-forward a `local-only` project or Firstmate's own repository local default branch after approval, then sync Firstmate's configured fork |
| `fm-review-diff.sh` | Review a crewmate branch or resolved PR head against the authoritative base |
| `fm-marker-lib.sh` | Compatibility entry point for the from-firstmate carrier owned by `fm-operational-input.sh` |
| `fm-task-inbox-lib.sh` | Single owner of durable steering-inbox records, acknowledgement, doorbells, and the delivery-attempt ladder |
Expand Down Expand Up @@ -144,7 +144,7 @@ The shared no-mistakes gate lifecycle boundary is summarized in [architecture.md
| `fm-pr-poll.sh` | Provide the byte-static watcher program for validated pull-request, merge-request, and Gerrit-change poll sidecars |
| `fm-contributions.sh` | Observe owned publications, retain exact-head judgments, measure required actors, and wake on maintainer signals |
| `fm-pr-check.sh` | Record validated task `pr=` and `pr_head=` values, then atomically arm a static merge poll; refuses GitHub drafts and persistent secondmate records (see [architecture.md](architecture.md)) |
| `fm-pr-merge.sh` | Record PR metadata, merge a task's canonical full GitHub or GitLab URL, refuse a Gerrit change because firstmate never submits one, then refuse an outcome it cannot prove landed or queued |
| `fm-pr-merge.sh` | Record PR metadata and merge a remote-authoritative task's canonical full GitHub or GitLab URL, while refusing Firstmate's local-authoritative repository and Gerrit changes before forge access |
| `fm-pr-state.sh` | Read-only: print one line per GitHub pull-request blocker it can see, reporting on checks that have reported rather than verdicting merge-readiness |
| `fm-pr-reviewers.sh` | Read-only: suggest reviewers from GitHub's own author mapping of recent commits on a pull request's changed files, never requesting one |
| `fm-merge-outcome-lib.sh` | Publish a confirmed merge's durable, role-routed supervision outcome |
Expand Down
Loading
Loading