Skip to content
Open
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
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -355,6 +355,7 @@ The worker reports the PR when CI first becomes green rather than waiting for me

For PR-based ship tasks, the ready signal depends on mode: `no-mistakes` reports `done: PR <url> checks green` after CI is green, while `direct-PR` reports `done: PR <url>` after opening the PR.
Run `bin/fm-pr-check.sh <id> <PR url>` - it records `pr=` and the forge's `pr_head=` when available in the task's meta and arms the watcher's merge poll.
`bin/fm-pr-merge.sh` refuses any merge whose recorded evidence commit is not the pull request's live head, and refuses when no evidence commit is recorded; clear either refusal by re-measuring on the pull request's current head and recording that commit with `bin/fm-evidence-record.sh`, never by working around the guard.
Tell the captain the PR's full URL, always the complete `https://...` link rather than a bare `#number`, a concise outcome summary, and the no-mistakes risk level when applicable.
A captain instruction to merge is explicit authority; `yolo` is the only standing routine authority.
For any custom `state/<id>.check.sh` you write yourself, keep it an ordinary single-link mode-`0700` file, print one line only when firstmate should wake, print nothing otherwise, finish before `FM_CHECK_TIMEOUT`, then bind its current bytes with `bin/fm-check-register.sh <id>` before the watcher may execute it.
Expand Down
55 changes: 51 additions & 4 deletions bin/fm-brief.sh
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,16 @@
# declared-external-wait verb (FM_CLASSIFY_PAUSED_VERB, default "paused") from
# "blocked:": pause for a known external wait expected to clear on its own,
# blocked when firstmate must act.
# PR-based ship briefs (no-mistakes and direct-PR) carry an evidence-recording
# section: the worker records the commit each reported verification was measured
# on through bin/fm-evidence-record.sh, and bin/fm-pr-merge.sh refuses to merge
# unless that commit is still the PR head. local-only and scout scaffolds omit it
# because neither reaches that merge path.
# That section must stay ABOVE every line that tells the worker it is finished,
# and each finishing line must name recording as part of its own condition. A
# worker reads the definition of done in order and stops at the first line that
# says it may; an evidence section placed after that line is unreachable in the
# ordinary case, which is exactly how the merge guard's own input went missing.
# Ship tasks include a project-memory section so durable project-intrinsic
# learnings can be committed to AGENTS.md through the project's delivery path;
# it carries the AGENTS.md authoring bar (widely useful knowledge only, pointers
Expand Down Expand Up @@ -348,6 +358,37 @@ echo "scaffolded: $BRIEF (scout; replace {TASK})"
exit 0
fi

# Every PR-based ship mode carries the same evidence-recording contract, because
# bin/fm-pr-merge.sh refuses to merge unless the recorded commit equals the pull
# request's live head. The worker is the only party that knows which commit its
# figures came from, and the pipeline can commit after the worker's last action,
# so the contract is stated as "record at measurement time, re-record after every
# re-measurement" rather than "measure the final head", which cannot win that
# race. local-only ships no PR and scouts ship no change, so neither carries it.
#
# The command has to run exactly as printed from the worker's pane, so it is
# shell-quoted like every other absolute path this scaffold emits and it carries
# the home this brief was scaffolded against. A crew pane inherits no FM_HOME,
# and the recorder falls back to its own root, so an unbound command would
# record into a different firstmate home than the one the merge guard reads -
# the same reason $STATE/$ID.status is baked in absolute above.
EVIDENCE_RECORD_CMD="FM_HOME=$(shell_quote "$FM_HOME")"
if [ "$STATE" != "$FM_HOME/state" ]; then
EVIDENCE_RECORD_CMD="$EVIDENCE_RECORD_CMD FM_STATE_OVERRIDE=$(shell_quote "$STATE")"
fi
EVIDENCE_RECORD_CMD="$EVIDENCE_RECORD_CMD $(shell_quote "$FM_ROOT/bin/fm-evidence-record.sh") $(shell_quote "$ID")"
IFS= read -r -d '' EVIDENCE_SECTION <<EOF || true
## Record the commit your evidence was measured on
Whatever you report as verification - a full-suite figure, a targeted test result, an exploit that stays blocked, a benchmark - firstmate merges on the strength of it, so it must name the commit it describes.
Immediately after each such run, from this worktree, record it:
\`\`\`
$EVIDENCE_RECORD_CMD "\$(git rev-parse HEAD)" '<what you measured, one line>'
\`\`\`
Record it again after EVERY re-measurement, and after anything that moves your branch head - a review fix round, a documentation commit, a rebase onto a newer base - because your earlier figures then describe a commit that is no longer the head.
The merge refuses when the recorded commit is not the pull request's head, and it refuses when nothing is recorded, so an unrecorded measurement stops the task rather than shipping unverified.
EOF
EVIDENCE_SECTION=${EVIDENCE_SECTION%$'\n'}

# Ship task: shape Setup / Rule 1 / Definition of done by this task's explicit
# delivery mode, validated above. The generated DOD opens with the fixed
# "Delivery contract: mode=<mode>" line that bin/fm-spawn.sh checks against its own
Expand All @@ -360,8 +401,11 @@ case "$MODE" in
# Definition of done
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.
The task is complete only when committed on your branch AND the commit your reported verification was measured on is recorded.

$EVIDENCE_SECTION

When it is implemented and committed, push your branch and open a PR with \`gh-axi\`, record the commit your reported verification was measured on as above, then 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 @@ -385,7 +429,10 @@ EOF
IFS= read -r -d '' DOD <<EOF || true
# Definition of done
Delivery contract: mode=no-mistakes
The task is complete only when committed on your branch.
The task is complete only when committed on your branch AND the commit your reported verification was measured on is recorded.

$EVIDENCE_SECTION

When you believe it is complete, append \`done: {summary}\` to the status file and stop.
Firstmate will then instruct you to run /no-mistakes to validate and ship a PR.

Expand All @@ -400,7 +447,7 @@ Two firstmate-specific rules layer on top of that guidance:
When the decision comes back, feed it to the gate with \`no-mistakes axi respond\` and let the pipeline apply it - do not route the question to "the user" or implement the fix yourself.
- Avoid \`--yes\`: it would silently bypass firstmate's authority check and any required captain escalation.

After /no-mistakes reports CI green (the CI-ready return point - do not wait for it to keep monitoring in the background until merge), append \`done: PR {url} checks green\` and stop. You are finished.
After /no-mistakes reports CI green (the CI-ready return point - do not wait for it to keep monitoring in the background until merge), re-record the commit your reported verification was measured on as above - the pipeline has almost certainly committed since you last measured - and only then append \`done: PR {url} checks green\` and stop. You are finished.
EOF
;;
esac
Expand Down
97 changes: 97 additions & 0 deletions bin/fm-evidence-record.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
#!/usr/bin/env bash
# Record the exact commit a task's reported verification evidence was measured
# on, into that task's durable metadata as evidence_head=<sha> plus an optional
# one-line evidence_note=<what was measured>. Re-running replaces the previous
# record, so a re-measurement is recorded the same way as the first one.
#
# bin/fm-pr-merge.sh refuses to merge unless evidence_head equals the pull
# request's live head. A validation pipeline can commit after the worker's last
# measurement - a fix round, a documentation step, a rebase onto a newer base -
# which silently turns a reported suite figure or exploit result into a
# description of an earlier commit. Recording the measured commit is what makes
# that staleness detectable instead of a manual comparison someone has to
# remember to perform.
#
# Run it from the task worktree immediately after the verification run it
# describes, and run it again after every re-measurement:
# bin/fm-evidence-record.sh <task-id> "$(git rev-parse HEAD)" 'full suite 4208 pass; injection exploit blocked'
#
# The note is one line of at most 200 characters and is quoted into the refusal
# message so a stale merge says what has to be re-measured. Its text is not
# restricted to ASCII: an em dash or an accented word is ordinary in a note and
# is accepted as written. A note carrying a newline, a tab, or any other control
# character is refused rather than trimmed, because a silently trimmed note is a
# silently wrong instruction.
# Usage: fm-evidence-record.sh <task-id> <commit-sha> [one-line note]
set -eu

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}"
FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}"
STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}"

usage() {
awk '
NR == 1 { next }
/^#/ { sub(/^# ?/, ""); print; next }
{ exit }
' "$0"
}

case "${1:-}" in
-h|--help) usage; exit 0 ;;
esac

# shellcheck source=bin/fm-pr-lib.sh
. "$SCRIPT_DIR/fm-pr-lib.sh"
# shellcheck source=bin/fm-wake-lib.sh
. "$SCRIPT_DIR/fm-wake-lib.sh"

# An interrupted write must leave the state directory exactly as it found it:
# no half-written temp beside the metadata, and no per-task lock another
# process would have to reclaim through stale-owner recovery. This mirrors
# bin/fm-pr-check.sh, the other writer of this same record.
trap fm_pr_evidence_cleanup EXIT
trap 'exit 1' HUP INT TERM

if [ "$#" -lt 2 ] || [ "$#" -gt 3 ]; then
echo "error: invalid evidence record request" >&2
echo "usage: fm-evidence-record.sh <task-id> <commit-sha> [one-line note]" >&2
exit 2
fi
ID=$1
RAW_HEAD=$2
NOTE=${3-}

if ! fm_pr_task_id_valid "$ID"; then
echo "error: invalid evidence record request" >&2
exit 2
fi
HEAD_SHA=$(printf '%s' "$RAW_HEAD" | tr '[:upper:]' '[:lower:]')
if ! fm_pr_head_valid "$HEAD_SHA"; then
echo "error: '$RAW_HEAD' is not a full commit SHA; pass \"\$(git rev-parse HEAD)\"" >&2
exit 2
fi
if ! fm_pr_evidence_note_valid "$NOTE"; then
echo "error: the note must be a single line of at most 200 characters with no control characters" >&2
echo " non-ASCII text such as an em dash is accepted; a line break, a tab, or an escape is not" >&2
exit 2
fi

# Task-derived paths are constructed only after the canonical ID validation.
META="$STATE/$ID.meta"
if [ ! -f "$META" ] || [ -L "$META" ]; then
echo "error: task metadata is unavailable" >&2
exit 1
fi

if ! fm_pr_evidence_write "$META" "$HEAD_SHA" "$NOTE"; then
echo "error: could not record the evidence commit for task $ID" >&2
exit 1
fi

if [ -n "$NOTE" ]; then
printf 'recorded: %s evidence measured on %s (%s)\n' "$ID" "$HEAD_SHA" "$NOTE"
else
printf 'recorded: %s evidence measured on %s\n' "$ID" "$HEAD_SHA"
fi
137 changes: 134 additions & 3 deletions bin/fm-pr-lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,111 @@ fm_pr_head_valid() {
[[ "$head" =~ ^[0-9a-f]{40}$|^[0-9a-f]{64}$ ]]
}

# evidence_head=<sha> records the exact commit a task's reported verification
# evidence was measured on, and optional evidence_note=<one line> records what
# that measurement was. bin/fm-evidence-record.sh is the only writer and
# bin/fm-pr-merge.sh the only reader: a merge is refused unless evidence_head
# equals the pull request's live head, so a suite figure or exploit result
# measured before a later fix, documentation, or rebase commit can never be
# presented as the merging head's result.
FM_PR_EVIDENCE_HEAD=
FM_PR_EVIDENCE_NOTE=
FM_PR_EVIDENCE_TMP=
FM_PR_EVIDENCE_LOCK=
FM_PR_EVIDENCE_LOCK_HELD=0

# fm_pr_evidence_note_valid <note>: a note is one line of at most 200
# characters carrying no control character, so it can never split the key=value
# record it is stored in nor drive the terminal a refusal is printed to.
# The text itself is not restricted to ASCII, because workers routinely write an
# em dash or an accented word and a guard that refuses valid work is worked
# around rather than fixed. The bound counts characters, which is what the
# refusal claims it counts: matching is byte-wise under LC_ALL=C, so a UTF-8
# sequence is folded to its single leading byte by dropping continuation bytes
# before the length is taken.
fm_pr_evidence_note_valid() {
local note=${1-}
local LC_ALL=C
local characters
if [[ "$note" =~ [[:cntrl:]] ]]; then
return 1
fi
characters=${note//[$'\x80'-$'\xbf']/}
[ "${#characters}" -le 200 ]
}

# fm_pr_evidence_read <meta>: load the task's evidence record into
# FM_PR_EVIDENCE_HEAD and FM_PR_EVIDENCE_NOTE. Returns 0 with an empty head when
# no record exists, and non-zero when the metadata is unreadable or the recorded
# head is malformed, so a corrupt record is never mistaken for a matching one.
fm_pr_evidence_read() {
local meta=${1-} head note
FM_PR_EVIDENCE_HEAD=
FM_PR_EVIDENCE_NOTE=
[ -f "$meta" ] && [ ! -L "$meta" ] || return 1
head=$(grep '^evidence_head=' "$meta" | tail -1 || true)
[ -n "$head" ] || return 0
head=${head#evidence_head=}
fm_pr_head_valid "$head" || return 1
note=$(grep '^evidence_note=' "$meta" | tail -1 || true)
note=${note#evidence_note=}
fm_pr_evidence_note_valid "$note" || return 1
FM_PR_EVIDENCE_HEAD=$head
FM_PR_EVIDENCE_NOTE=$note
}

# fm_pr_evidence_cleanup: remove any in-progress evidence temp and release the
# per-task metadata lock if this process still holds it. Callers install it as
# their EXIT trap, so an interrupted write leaves neither a stray temp file in
# the state directory nor a lock another process has to reclaim through
# stale-owner recovery. Releasing is idempotent: the held flag is cleared here,
# so the normal path and the trap together release exactly once.
fm_pr_evidence_cleanup() {
[ -z "$FM_PR_EVIDENCE_TMP" ] || rm -f -- "$FM_PR_EVIDENCE_TMP"
FM_PR_EVIDENCE_TMP=
if [ "$FM_PR_EVIDENCE_LOCK_HELD" = 1 ]; then
fm_lock_release "$FM_PR_EVIDENCE_LOCK" || true
FM_PR_EVIDENCE_LOCK_HELD=0
fi
}

# fm_pr_evidence_write <meta> <sha> [note]: atomically replace the task's
# evidence record, preserving every other metadata line, and re-read the result
# so a partially written record can never be reported as recorded. Requires
# bin/fm-wake-lib.sh for the shared per-task metadata lock.
fm_pr_evidence_write() {
local meta=$1 head=$2 note=${3-} dir base rc=0
fm_pr_head_valid "$head" || return 1
fm_pr_evidence_note_valid "$note" || return 1
[ -f "$meta" ] && [ ! -L "$meta" ] || return 1
[ "$(fm_pr_file_link_count "$meta")" = 1 ] || return 1
dir=${meta%/*}
base=${meta##*/}
[ "$dir" != "$meta" ] || dir=.
FM_PR_EVIDENCE_LOCK=$(fm_meta_lock_path "$meta") || return 1
fm_lock_acquire_wait "$FM_PR_EVIDENCE_LOCK"
FM_PR_EVIDENCE_LOCK_HELD=1
FM_PR_EVIDENCE_TMP=$(mktemp "$dir/.${base}.fm-evidence.XXXXXX") \
|| { fm_pr_evidence_cleanup; return 1; }
while :; do
[ -f "$meta" ] && [ ! -L "$meta" ] && [ "$(fm_pr_file_link_count "$meta")" = 1 ] || { rc=1; break; }
fm_pr_regular_destination_or_absent "$meta" || { rc=1; break; }
{ grep -vE '^evidence_head=|^evidence_note=' "$meta" || true; } > "$FM_PR_EVIDENCE_TMP" || { rc=1; break; }
printf 'evidence_head=%s\n' "$head" >> "$FM_PR_EVIDENCE_TMP" || { rc=1; break; }
if [ -n "$note" ]; then
printf 'evidence_note=%s\n' "$note" >> "$FM_PR_EVIDENCE_TMP" || { rc=1; break; }
fi
chmod 0600 "$FM_PR_EVIDENCE_TMP" || { rc=1; break; }
mv -f -- "$FM_PR_EVIDENCE_TMP" "$meta" || { rc=1; break; }
FM_PR_EVIDENCE_TMP=
break
done
fm_pr_evidence_cleanup
[ "$rc" = 0 ] || return 1
fm_pr_evidence_read "$meta" || return 1
[ "$FM_PR_EVIDENCE_HEAD" = "$head" ] && [ "$FM_PR_EVIDENCE_NOTE" = "$note" ]
}

fm_pr_file_mode() {
if [ "$(uname)" = Darwin ]; then
stat -f %Lp "$1" 2>/dev/null
Expand Down Expand Up @@ -285,6 +390,32 @@ fm_pr_regular_destination_on_device_or_absent() {
[ ! -e "$path" ] || [ "$(fm_pr_file_device "$path")" = "$device" ]
}

# fm_pr_meta_key_line <line>: true when a metadata line is a well-formed
# `key=value` record line, where the key is a bare identifier. Every metadata
# key firstmate writes has that shape, so the test admits a key this parser has
# never seen while still rejecting a line that is not a record line at all.
fm_pr_meta_key_line() {
local LC_ALL=C
[[ "${1-}" =~ ^[A-Za-z_][A-Za-z0-9_]*= ]]
}

# What this parse tolerates after pr=, and what it still refuses.
#
# Tolerated: any well-formed `key=value` line whose key this parser does not
# recognise. Enumerating the permitted keys was tried and was wrong three times
# running - the evidence recorder, bin/fm-spawn.sh's traceparent and relaunch
# transaction, and bin/fm-decision-hold.sh's review record each append after
# pr=, and nothing stops a fourth writer appearing. Every such writer was a
# latent refusal of a merge on valid work, found only when a correct merge was
# wrongly blocked. Position in the record carries no meaning for keys this
# function does not read, so an unrecognised key is not evidence of corruption.
#
# Still refused, loudly: a line that is not a record line at all (no `key=`
# prefix, an empty line, or a fragment left by a truncated write), a second pr=
# line, a pr= whose URL does not parse, and a pr_head= that is not a commit.
# Those are the shapes a value carrying an embedded newline would inject, and
# they remain the reason this positional check exists. Do not relax this into
# accepting any line: the `key=value` shape is the whole remaining guard.
fm_pr_metadata_identity_parse() {
local file=$1 line value pr_count=0 seen_pr=0 post_pr_invalid=0
FM_PR_META_PROVIDER=
Expand Down Expand Up @@ -315,10 +446,10 @@ fm_pr_metadata_identity_parse() {
fm_pr_head_valid "$value" || post_pr_invalid=1
fi
;;
x_request=*|x_request_ts=*|x_followups=*|x_platform=*|x_reply_max_chars=*)
;;
*)
[ "$seen_pr" -eq 0 ] || post_pr_invalid=1
if [ "$seen_pr" -eq 1 ] && ! fm_pr_meta_key_line "$line"; then
post_pr_invalid=1
fi
;;
esac
done < "$file"
Expand Down
Loading