Skip to content
Closed
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
25 changes: 23 additions & 2 deletions .github/workflows/no-mistakes-required.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ on:

permissions:
contents: read
pull-requests: read

# GitHub concurrency groups retain at most one pending run, replacing older
# pending runs even when cancel-in-progress is false. Give body-bearing events
Expand All @@ -28,14 +29,34 @@ jobs:
steps:
- name: Verify no-mistakes signature in PR body
env:
PR_ACTION: ${{ github.event.action }}
PR_BODY: ${{ github.event.pull_request.body }}
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
PR_NUMBER: ${{ github.event.pull_request.number }}
PR_REPO: ${{ github.repository }}
GH_TOKEN: ${{ github.token }}
run: |
set -eu
marker='Updates from [git push no-mistakes](https://github.com/kunchenguid/no-mistakes)'
if printf '%s' "${PR_BODY:-}" | grep -qF -- "$marker"; then
echo "Found no-mistakes signature in PR #${PR_NUMBER} body."
# opened and edited are the body-bearing events: their payload carries the
# exact body version under judgement, so keep evaluating that copy and keep
# every body version independently accountable.
#
# synchronize and reopened do not change the body. Their payload copy is only
# a snapshot taken when the event fired, and a queued run can read it long
# after no-mistakes has adopted the PR and written the signature. Re-read the
# live body for those, otherwise the stale snapshot can complete after the
# passing edited run and leave a compliant PR red with no way to re-trigger.
# A failed read aborts the job under `set -e`, so this stays fail-closed.
if [ "${PR_ACTION:-}" = 'opened' ] || [ "${PR_ACTION:-}" = 'edited' ]; then
body=${PR_BODY:-}
body_source="${PR_ACTION} event payload"
else
body=$(gh api "repos/${PR_REPO}/pulls/${PR_NUMBER}" --jq '.body // ""')
body_source='live PR body'
fi
if printf '%s' "$body" | grep -qF -- "$marker"; then
echo "Found no-mistakes signature in PR #${PR_NUMBER} body (${body_source})."
exit 0
fi
{
Expand Down
1 change: 1 addition & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ Pushing through it runs an AI-driven review/test/lint pipeline in an isolated wo

A GitHub Actions check (`Require no-mistakes`) runs on PRs targeting `main` and fails if the body is missing the deterministic signature that no-mistakes writes.
It evaluates every PR opening and body edit independently, so a later edit cannot replace an earlier pending compliance check.
Head updates and reopens do not change the body, so those runs read the live PR body instead of their own event snapshot; otherwise a run queued at push time could judge the pre-pipeline body and finish after the passing edit run, leaving a compliant PR red.
GitHub Actions and Dependabot are exempt so their automation keeps working, but regular contributor PRs without the signature will not be reviewed or merged.

## Workflow
Expand Down
18 changes: 10 additions & 8 deletions bin/fm-claude-stop-autoarm.sh
Original file line number Diff line number Diff line change
Expand Up @@ -88,17 +88,19 @@ fm_primary_scope_matches "$FM_ROOT" "$STATE" || exit 0

# --- identity: only the lock-owning session's hooks may arm ------------------
# A prior session may have died after leaving its numeric harness pid in .lock.
# Use the shared liveness predicate to recognize only that stale-owner case.
# fm_session_lock_stale_owner is the shared owner of that one question, so this
# script and bin/fm-turnend-guard.sh cannot drift on how the lock file is read.
# Defer the mutating claim until after the unchanged AFK and need gates, so an
# idle or away home remains byte-for-byte inert. Missing or malformed locks are
# uncertainty rather than stale-owner evidence and remain inert.
# idle or away home remains byte-for-byte inert. Anything short of proof that the
# recorded owner is gone stays inert: a live owner, a missing lock, a malformed
# lock, and an unreadable one are all uncertainty rather than stale-owner
# evidence. That is deliberately NOT the guard's live-foreign-owner predicate,
# which additionally requires this process's ancestry to resolve; an empty or
# unresolvable lock must leave this script inert here, never send it down the
# recovery path to claim a lock nobody proved was free.
RECOVER_SESSION_LOCK=0
if ! fm_session_lock_owned_by_self "$STATE"; then
LOCK_PID=$(cat "$STATE/.lock" 2>/dev/null || true)
case "$LOCK_PID" in
''|*[!0-9]*) exit 0 ;;
esac
fm_harness_pid_alive "$LOCK_PID" && exit 0
fm_session_lock_stale_owner "$STATE" || exit 0
RECOVER_SESSION_LOCK=1
fi

Expand Down
52 changes: 48 additions & 4 deletions bin/fm-session-lock-lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,19 @@ fm_harness_pid_alive() {
fm_harness_process_matches "$comm" "$args"
}

# Print state dir $1's session-lock pid, but only when the lock file holds one
# plain numeric value. A missing, empty, or malformed lock prints nothing and
# fails, so every predicate below treats it as absence of evidence rather than
# as an answer about ownership. This is the only reader of that file here.
fm_session_lock_pid() {
local state=$1 lock_pid
lock_pid=$(cat "$state/.lock" 2>/dev/null || true)
case "$lock_pid" in
''|*[!0-9]*) return 1 ;;
esac
printf '%s\n' "$lock_pid"
}

# True when state dir $1 holds a session lock whose pid is ANY harness ancestor
# of the current process: this script runs inside the session that owns the
# home's fleet lock. Membership is the honest test of that question, because the
Expand All @@ -148,10 +161,7 @@ fm_harness_pid_alive() {
# ancestry that cannot be resolved all fail closed.
fm_session_lock_owned_by_self() {
local state=$1 lock_pid pids pid
lock_pid=$(cat "$state/.lock" 2>/dev/null || true)
case "$lock_pid" in
''|*[!0-9]*) return 1 ;;
esac
lock_pid=$(fm_session_lock_pid "$state") || return 1
pids=$(fm_harness_ancestry_pids) || return 1
while IFS= read -r pid; do
[ "$pid" = "$lock_pid" ] && return 0
Expand All @@ -160,3 +170,37 @@ $pids
EOF
return 1
}

# True only on POSITIVE evidence that a DIFFERENT live harness process holds
# state dir $1's session lock: the lock names a plain pid, that pid is a live
# harness, this process's own harness ancestry RESOLVES, and the lock pid is
# outside it. Ancestry resolution is part of that evidence rather than an
# implementation detail. fm_session_lock_owned_by_self above also reports "not
# mine" when fm_harness_ancestry_pids cannot resolve, so negating it alone would
# read "I cannot tell who owns this" as "someone else owns it" and return a
# false positive in exactly the ambiguous case. Callers that allow an action on
# the strength of foreign ownership must not act on that ambiguity.
fm_session_lock_live_foreign_owner() {
local state=$1 lock_pid pids pid
lock_pid=$(fm_session_lock_pid "$state") || return 1
fm_harness_pid_alive "$lock_pid" || return 1
pids=$(fm_harness_ancestry_pids) || return 1
while IFS= read -r pid; do
[ "$pid" = "$lock_pid" ] && return 1
done <<EOF
$pids
EOF
return 0
}

# True only on POSITIVE evidence that state dir $1's session lock names an owner
# that is gone: the lock holds a plain pid and that pid is not a live harness.
# A missing or malformed lock is absence of evidence rather than a dead owner,
# so it fails here and callers stay inert instead of claiming a lock that only
# looks unowned. This deliberately needs no ancestry, because a dead pid is not
# this live process whether or not the ancestry walk succeeds.
fm_session_lock_stale_owner() {
local state=$1 lock_pid
lock_pid=$(fm_session_lock_pid "$state") || return 1
! fm_harness_pid_alive "$lock_pid"
}
86 changes: 83 additions & 3 deletions bin/fm-turnend-guard.sh
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,24 @@
# (default 3) consecutive blocks per session - safely below Claude Code's
# hard 8-consecutive-block override - then allow one loud attended
# fail-open only for an already verified failure episode.
# That bound counts BLOCKS, not epochs: a stalled auto-arm stops rewriting
# state/.claude-autoarm-epoch, and an epoch-gated counter would then never
# advance, leaving the documented ceiling unbounded in the stalled case it
# exists to cover. The allow paths still account per epoch, so one event
# epoch yields exactly one recovery turn.
#
# Session-lock ownership, --claude mode: a session that does not hold this
# home's session lock must not arm, drain, or repair supervision (AGENTS.md
# section 3), and bin/fm-claude-stop-autoarm.sh refuses to arm from it on the
# same predicate. Blocking it would demand a repair it may never perform, so a
# --claude stop is ALLOWED with an advisory whenever fm_session_lock_live_foreign_owner
# reports positive evidence that a different LIVE harness process holds
# state/.lock. Incomplete evidence is never that proof: a missing lock, a
# malformed lock, a dead owner, or an ancestry this process cannot resolve all
# keep the ordinary blocking path. The advisory states only what was verified
# and takes its recovery step from fm-supervision-instructions.sh, because the
# lock owner is not guaranteed to have a next turn and the away supervisor owns
# the watcher under away mode.
set -u

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
Expand Down Expand Up @@ -86,6 +104,8 @@ done
. "$SCRIPT_DIR/fm-supervision-lib.sh"
# shellcheck source=bin/fm-primary-scope-lib.sh
. "$SCRIPT_DIR/fm-primary-scope-lib.sh"
# shellcheck source=bin/fm-session-lock-lib.sh
. "$SCRIPT_DIR/fm-session-lock-lib.sh"

# Read the whole turn-end hook payload once; never block on unreadable/absent
# stdin.
Expand Down Expand Up @@ -184,11 +204,61 @@ if [ "$CLAUDE_MODE" -eq 0 ]; then
block_stop
fi

# A session that does not hold this home's session lock is forbidden from
# arming, draining, or repairing supervision (AGENTS.md section 3), and
# bin/fm-claude-stop-autoarm.sh refuses to arm from it for the same reason.
# Blocking such a session therefore demands a repair it may never perform: the
# auto-arm never claims, its epoch never advances, and the guard re-blocks every
# turn forever. Allow with an advisory instead, but only on the shared library's
# positive evidence that a DIFFERENT live harness process owns the lock. That
# predicate is the single owner of the decision, and it refuses whenever the
# evidence is incomplete: a missing lock, a malformed lock, a dead owner, or an
# unresolvable harness ancestry all keep the ordinary blocking path, because
# "this is not my lock" and "I cannot tell whose lock this is" must not collapse
# into the same answer when the second one would let a stop through.
#
# The advisory reports only what was verified. It must not promise that the lock
# owner restores supervision: an idle lock owner has no next turn, which is the
# incident this allow exists for, and under away mode the away supervisor owns
# the watcher instead. The authoritative recovery step therefore comes from
# fm-supervision-instructions.sh with the same --afk and --x-mode inputs
# block_stop uses, rather than a second hand-written AFK-aware sentence.
json_escape() {
printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' | tr '\n' ' '
}

if fm_session_lock_live_foreign_owner "$STATE"; then
afk=0
[ -e "$STATE/.afk" ] && afk=1
x_mode=0
[ -f "$CONFIG/x-mode.env" ] && x_mode=1
repair=$("$SCRIPT_DIR/fm-supervision-instructions.sh" --afk "$afk" --x-mode "$x_mode" --repair-line 2>/dev/null \
|| printf '%s\n' 'repair missing watcher supervision according to the session-start operating block')
printf '{"systemMessage":"FIRSTMATE SUPERVISION IS OWNED BY ANOTHER SESSION: this session does not hold this home'"'"'s session lock, so it must not arm, drain, or repair supervision. Supervision is not running for this home right now, and this turn is allowed to end only because this session is the one that cannot repair it. Stay read-only here. Recovery belongs to whoever holds the lock: %s"}\n' "$(json_escape "$repair")"
exit 0
fi

# --- --claude cooperative path -----------------------------------------------
# The Stop-owned auto-arm fires on the same Stop event. Give it a brief bounded
# window to prove it owns recovery for this event epoch before consuming one of
# Claude's bounded continuations.
# Set when an accounting call in THIS invocation actually moved the count, so
# the block path below can tell "the epoch was already counted for this stop"
# apart from "nothing counted it", and advance exactly once per stop either way.
BUDGET_ADVANCED=0

# Account one observation of the current auto-arm epoch against this session's
# block budget. Pass 1 as $1 to advance even when the epoch is unchanged.
#
# The allow paths call this with epoch idempotence ON, so one event epoch yields
# exactly one recovery turn. The genuine block path calls it with idempotence
# OFF, because there the count IS the bound: an auto-arm that stalls stops
# rewriting state/.claude-autoarm-epoch, and an epoch-gated counter then never
# advances, so the documented "bounded to FM_CLAUDE_TURNEND_BLOCK_BUDGET
# consecutive blocks" ceiling silently became unbounded in exactly the stalled
# case the bound exists to cover.
budget_account_current_epoch() {
local advance_same_epoch=${1:-0}
local current_epoch outcome old_session old_count old_epoch tmp initialized
fm_lock_try_acquire "$BUDGET_LOCK" || return 1
current_epoch=$(sed -n 's/^epoch=\([0-9][0-9]*\) .*/\1/p' "$STATE/.claude-autoarm-epoch" 2>/dev/null || true)
Expand All @@ -204,10 +274,12 @@ budget_account_current_epoch() {
esac
if [ "$old_session" = "$SESSION_ID" ]; then
COUNT=$old_count
if [ -n "$current_epoch" ] && [ "$old_epoch" = "$current_epoch" ]; then
if [ "$advance_same_epoch" -eq 0 ] && [ -n "$current_epoch" ] \
&& [ "$old_epoch" = "$current_epoch" ]; then
:
else
COUNT=$((COUNT + 1))
BUDGET_ADVANCED=1
fi
fi
fi
Expand All @@ -223,6 +295,7 @@ budget_account_current_epoch() {
;;
*) COUNT=1 ;;
esac
BUDGET_ADVANCED=1
fi
tmp="$BUDGET_FILE.tmp.$$"
if ! printf 'session=%s\ncount=%s\nepoch=%s\n' "$SESSION_ID" "$COUNT" "$current_epoch" > "$tmp" 2>/dev/null \
Expand Down Expand Up @@ -357,8 +430,15 @@ if autoarm_owns_recovery; then
fi

# The auto-arm genuinely failed to establish: consume the bounded re-block
# budget before considering the verified one-time attended fail-open.
budget_account_current_epoch || block_stop
# budget before considering the verified one-time attended fail-open. This
# consumption is per BLOCK, not per epoch, so a stalled auto-arm that stops
# advancing its epoch still exhausts the bound instead of re-blocking forever.
# An allow path that already accounted for this epoch during the same stop has
# consumed the stop's one advance, so forcing a second one here would double-
# count a single blocked stop.
if [ "$BUDGET_ADVANCED" -eq 0 ]; then
budget_account_current_epoch 1 || block_stop
fi
terminal_fail_open
terminal_status=$?
if [ "$terminal_status" -eq 0 ]; then
Expand Down
2 changes: 1 addition & 1 deletion docs/scripts.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ The shared no-mistakes gate refusal for fleet lifecycle entrypoints is summarize
| `fm-ensure-agents-md.sh` | Ensure a project's real `AGENTS.md`, its `CLAUDE.md` symlink, and the canonical self-governance section |
| `fm-guard.sh` | Warn on primary-checkout tangles, pending queued wakes, and stale watcher liveness |
| `fm-primary-scope-lib.sh` | Shared marker-or-plain-checkout primary-home predicate for tracked hooks |
| `fm-session-lock-lib.sh` | Shared session-lock harness identity (ancestry walk and holder liveness) for fm-lock.sh and the Claude Stop auto-arm |
| `fm-session-lock-lib.sh` | Shared session-lock harness identity (ancestry walk, holder liveness, and the self/live-foreign/stale owner predicates) for fm-lock.sh, the Claude Stop auto-arm, and the `--claude` turn-end guard |
| `fm-claude-stop-autoarm.sh` | Claude Stop `asyncRewake` hook owning tokenless watcher continuity with single-flight exit-2 rewake (docs/watcher-continuity.md) |
| `fm-turnend-guard.sh` | Shared primary turn-end guard predicate so no turn ends blind (docs/turnend-guard.md) |
| `fm-turnend-guard-grok.sh` | Grok Stop-hook adapter for the primary turn-end guard |
Expand Down
Loading