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
3 changes: 2 additions & 1 deletion .agents/skills/project-management/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,12 +75,13 @@ The captain's request to create that local project authorizes this local initial
Run no-mistakes initialization only for `no-mistakes` and `no-mistakes-prod-only` projects:

```sh
cd projects/<name> && no-mistakes init && no-mistakes doctor
(cd projects/<name> && no-mistakes init && no-mistakes doctor) && bin/fm-pr-destination-guard.sh projects/<name>
```

Initialization configures the local gate and does not vendor a no-mistakes skill into the project.
Do not create a commit merely because initialization ran.
If doctor reports an environment, authentication, or daemon problem, resolve that blocker before dispatching work and never restart the shared daemon from a project operation.
`fm-pr-destination-guard.sh` pins gh's pull-request destination for a GitHub-hosted project to its own `origin`, in both the checkout and the no-mistakes gate, and refuses loudly rather than proceeding if that pin cannot be verified; see docs/architecture.md "Pull request destination is pinned, never gh's default" for why a project that is itself a GitHub fork needs this even when its remotes are already correct.

## Remove

Expand Down
9 changes: 9 additions & 0 deletions .no-mistakes.yaml
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
# Per-repo no-mistakes overrides.

# This repo is a real GitHub fork (joliverMI/firstmate, forked from the public
# kunchenguid/firstmate). There is no key in this file, or anywhere else
# no-mistakes reads, that points its PR step's destination at this repo
# instead of the fork parent: `no-mistakes init --fork-url` solves the
# opposite workflow (push to a fork, PR against the recorded origin) and does
# not apply here. The fix is bin/fm-pr-destination-guard.sh, applied outside
# this file's reach; see docs/architecture.md "Pull request destination is
# pinned, never gh's default" before assuming a key here can solve this again.

# firstmate is an agent-orchestration repo: its AGENTS.md installs a fleet-captain
# identity. Disable project-level agent settings/instructions for gate agents so a
# no-mistakes review/fix/document/test/lint/pr/rebase/ci agent never adopts that
Expand Down
2 changes: 2 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ GitHub Actions and Dependabot are exempt so their automation keeps working, but
1. Fork the repo, then clone the parent repo or set your local `origin` back to the parent (`git@github.com:kunchenguid/firstmate.git`).
2. Create a branch and make your changes.
3. Initialize the gate with your fork as the push target: `no-mistakes init --fork-url git@github.com:<you>/firstmate.git` (firstmate expects **no-mistakes v1.31.2+**; without a fork, plain `no-mistakes init` still works for maintainers with push access).
Then run `bin/fm-pr-destination-guard.sh .`, which pins `gh` in both your clone and its gate to resolve pull requests to your clone's own `origin` rather than to a destination it picks for you.
That matters when your `origin` is itself a GitHub fork, because `gh pr create` then defaults the pull request to the fork's parent repository; [`docs/architecture.md`](docs/architecture.md#pull-request-destination-is-pinned-never-ghs-default) owns that mechanism and what the guard verifies.
4. Commit your changes.
5. Push through the gate instead of pushing to `origin`:

Expand Down
11 changes: 9 additions & 2 deletions bin/fm-brief.sh
Original file line number Diff line number Diff line change
Expand Up @@ -360,7 +360,13 @@ case "$MODE" in
Delivery contract: mode=direct-PR
This task ships **direct-PR**: you raise the PR yourself, without the no-mistakes pipeline.
The task is complete only when committed on your branch.
When it is implemented and committed, push your branch and open a PR with \`gh-axi\`, then append \`done: PR {url}\` to the status file and stop.
When it is implemented and committed, push your branch, then open the PR with its destination named in the very same command that creates it. Never let a tool choose that destination: \`gh pr create\` defaults to a fork's parent rather than the fork itself, \`gh repo view\` with no repository argument picks a remote by gh's own preference order (\`upstream\` before \`origin\`), and \`gh-axi\` drops an empty \`--repo\` and falls back to that same default - so a destination that is merely computed earlier, in some other command, fails open onto the parent.
First write the PR title and body into files, each with a quoted heredoc delimiter so the shell expands and executes nothing inside them. Text you author is never safe to inline: an apostrophe in a title ends the quoted string and kills the whole line with a syntax error before any of it runs, and a markdown body naming commands or paths in backticks would be executed before \`gh-axi\` ever saw it, its output substituted into the body.
\`cat > "\$(git rev-parse --absolute-git-dir)/fm-pr-title.txt" <<'FM_PR_TITLE'\` … your title, verbatim … \`FM_PR_TITLE\`
\`cat > "\$(git rev-parse --absolute-git-dir)/fm-pr-body.md" <<'FM_PR_BODY'\` … your body, verbatim … \`FM_PR_BODY\`
Then name the destination and create the PR as one command, so the create call cannot run at all unless the destination was determined with certainty:
\`set -- --title "\$(cat "\$(git rev-parse --absolute-git-dir)/fm-pr-title.txt")" --body-file "\$(git rev-parse --absolute-git-dir)/fm-pr-body.md"; OWNER_REPO=\$("$FM_ROOT/bin/fm-pr-destination-guard.sh" . --print-destination); case \$? in 0) gh-axi pr create --repo "\$OWNER_REPO" "\$@" ;; 3) gh-axi pr create "\$@" ;; *) exit 1 ;; esac\`
Your words go only into those two files; the create command itself is fixed text - run it exactly as written, as one command line, and do not edit or split it. Both files are named by your own worktree's git directory, so they are yours alone - crewmates run concurrently on one host under one user, and a fixed path in a shared directory would let another task's title or body silently replace yours between the write and the create. That guard mode reads this repo's own \`origin\` remote, makes no network call, and never prints a guess: exit 0 means it printed \`owner/repo\` and \`--repo\` carries that verified value straight into the create call - that flag being set from the guard's own value IS the destination guarantee, so there is no separate comparison left to make by eye; exit 3 means this repo is not on a GitHub host, where gh's fork-parent default cannot apply, so the PR is created without a destination override as it always was; any other exit means the destination is undetermined on a repo where the hazard is real, so nothing is created - append \`blocked: {its exact error}\` to the status file and stop. Otherwise append \`done: PR {url}\` to the status file and stop.
Do NOT run /no-mistakes. The configured merge authority decides whether to merge the PR; firstmate relays the outcome.
EOF
;;
Expand All @@ -379,7 +385,8 @@ EOF
;;
*) # no-mistakes
SETUP2="
2. Run \`no-mistakes doctor\`; if it reports the repo is not initialized here, run \`no-mistakes init\`."
2. Run \`no-mistakes doctor\`; if it reports the repo is not initialized here, run \`no-mistakes init\`.
3. Always run \`$FM_ROOT/bin/fm-pr-destination-guard.sh .\`, whether or not step 2 just ran \`no-mistakes init\`. It pins this repo's pull-request destination to its own \`origin\` (never gh's ambient default) and verifies the pin in both this checkout and its no-mistakes gate. Treat a non-zero exit as a blocker: append \`blocked: {its exact error}\` and stop rather than starting \`/no-mistakes\` unpinned."
RULE1='1. Never push to the default branch. Never merge a PR.'
IFS= read -r -d '' DOD <<EOF || true
# Definition of done
Expand Down
10 changes: 8 additions & 2 deletions bin/fm-home-seed.sh
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,10 @@
# refuses a home with project clones or project-registry entries, so it
# never converts populated homes in place. The charter brief
# is copied to data/charter.md, newly cloned no-mistakes projects are
# initialized, an ignored .fm-secondmate-parent binding is published before
# the .fm-secondmate-home identity marker, and data/secondmates.md is updated.
# initialized and have their pull-request destination pinned by
# bin/fm-pr-destination-guard.sh, an ignored .fm-secondmate-parent binding
# is published before the .fm-secondmate-home identity marker, and
# data/secondmates.md is updated.
# Seeding is transactional: on validation, clone, init, or registry failure,
# generated briefs, new homes, new project clones, and registry edits are
# rolled back. Treehouse-acquired homes are returned only when the rollback
Expand Down Expand Up @@ -726,6 +728,10 @@ initialize_no_mistakes_project() {
echo "error: failed to initialize no-mistakes for $project at $dst" >&2
return 1
}
"$SCRIPT_DIR/fm-pr-destination-guard.sh" "$dst" || {
echo "error: could not pin the pull-request destination for $project at $dst" >&2
return 1
}
}

write_registry() {
Expand Down
28 changes: 27 additions & 1 deletion bin/fm-pr-check.sh
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
# live only in a private sidecar and are never interpolated into shell source.
# A GitHub pull request URL and a GitLab merge request URL are both accepted,
# including a merge request on a self-hosted GitLab instance.
# A GitHub pull request whose owner/repository is not the task's own project
# origin is refused rather than recorded; see the check itself for why that
# backstop exists and when it stays silent.
# Usage: fm-pr-check.sh <task-id> <pr-url>
set -eu

Expand Down Expand Up @@ -41,6 +44,30 @@ if [ ! -f "$META" ] || [ -L "$META" ] || [ "$(fm_pr_file_link_count "$META")" !=
exit 1
fi

WT=$(grep '^worktree=' "$META" | tail -1 | cut -d= -f2- || true)

# Refuse a GitHub PR reported for a repository other than this task's own
# project. This is a defense-in-depth backstop, not the primary defense
# (bin/fm-pr-destination-guard.sh pins gh's own PR-creation resolution so this
# never fires in the ordinary case): by the time a URL reaches here the PR may
# already exist, so this cannot recall it, but it stops firstmate from
# recording, tracking, or arming a merge watch for the wrong repository - see
# docs/architecture.md "Pull request destination is pinned, never gh's
# default". Silently skipped, not refused, when the task's own origin cannot
# be determined (no recorded worktree, or a non-GitHub remote): this check
# only ever blocks on a positive, confirmed mismatch.
if [ "$PROVIDER" = github ] && [ -n "$WT" ] && [ -d "$WT" ]; then
OWN_ORIGIN=$(git -C "$WT" config --get remote.origin.url 2>/dev/null || true)
if [ -n "$OWN_ORIGIN" ] && fm_pr_github_remote_owner_repo "$OWN_ORIGIN"; then
OWN_REPO_ID=$(fm_pr_lower "$FM_PR_REMOTE_OWNER/$FM_PR_REMOTE_REPO")
PR_REPO_ID=$(fm_pr_lower "$FM_PR_OWNER/$FM_PR_REPO")
if [ "$OWN_REPO_ID" != "$PR_REPO_ID" ]; then
echo "error: PR $URL targets $FM_PR_OWNER/$FM_PR_REPO, not this task's own project $FM_PR_REMOTE_OWNER/$FM_PR_REMOTE_REPO; refusing to record or arm it" >&2
exit 1
fi
fi
fi

# A prior exact merged result may have queued its durable wake immediately
# before interruption.
# Finish only its identity-bound receipt before publishing a replacement poll.
Expand Down Expand Up @@ -71,7 +98,6 @@ fi
# bin/fm-teardown.sh reads the head from the forge at teardown rather than from
# metadata and falls back to its provider-agnostic content check, and
# bin/fm-review-diff.sh resolves the head from the remote when none is recorded.
WT=$(grep '^worktree=' "$META" | tail -1 | cut -d= -f2- || true)
PR_HEAD=
if [ "$PROVIDER" = github ] && [ -n "$WT" ] && [ -d "$WT" ] && command -v gh >/dev/null 2>&1; then
if REMOTE_HEAD=$(cd "$WT" && gh pr view "$URL" --json headRefOid -q .headRefOid 2>/dev/null) \
Expand Down
Loading
Loading