Skip to content

feat(bin): wake firstmate when a monitored PR goes conflicting - #21

Open
sbracewell64 wants to merge 36 commits into
mainfrom
fm/pr-dirty-state-wake
Open

feat(bin): wake firstmate when a monitored PR goes conflicting#21
sbracewell64 wants to merge 36 commits into
mainfrom
fm/pr-dirty-state-wake

Conversation

@sbracewell64

Copy link
Copy Markdown
Owner

Intent

Extend firstmate's armed PR merge monitoring so a monitored open pull request that becomes conflicted wakes firstmate within one poll cycle, instead of waiting for long-cadence rechecks or a maintainer bot's comment. Captain-ordered 2026-07-31; the cost evidence is PR 1267 overtaken six times in a day and PR 1284 sitting conflicted for an hour until a human noticed a bot comment.

Deliberate design decisions, all reviewed and approved by firstmate before implementation, so they are choices rather than oversights:

  1. The conflict signal deliberately rides the EXISTING byte-static validated poll (bin/fm-pr-poll.sh + the private state/.pr-poll sidecar and its hash/inode registration), never a new ad-hoc executable check. Extending within that shape was an explicit constraint.

  2. The poll's single GitHub request now selects state,mergeable,headRefOid instead of state alone, so conflict detection adds ZERO extra forge calls. This was a hard cost constraint and there is a test asserting exactly one 'pr view' call per poll.

  3. GraphQL 'mergeable' (CONFLICTING) is read rather than 'mergeStateStatus' (DIRTY), even though the task text named mergeable_state=dirty. Reason: the two agree on a conflict, but mergeStateStatus rides a preview header and is likelier to be absent on GitHub Enterprise Server, and its non-conflict values (UNSTABLE) also report failing or pending checks, which is a different signal this poll must stay silent about. This is intentional, not a misreading of the requirement.

  4. 'merged' is decided first and alone, so merged and closed pull requests emit byte-identical results to before, and their retirement records are untouched. Preserving those paths exactly was an acceptance criterion.

  5. Deliberately NO retry when GitHub briefly reports mergeability as UNKNOWN while recomputing after a base push. Firstmate explicitly ruled on this: GitHub recomputes on the base push, so a sweep arriving minutes later normally reads a settled value, and one extra cycle on a rare race is cheaper than adding an API call and a timing dependency to a static security-sensitive program. The limitation is documented in docs/architecture.md rather than engineered around. Do not flag the missing retry as a gap.

  6. Dedupe is keyed on the PR head commit, held in a new watcher marker state/.pr-dirty-, with an FM_PR_DIRTY_RESURFACE_SECS=3600 backstop. The head is the key BECAUSE poll silence is ambiguous - clean, unknown, closed, and every error look identical - so silence can never clear the key, while a changed head does mean the branch moved and went conflicting again. This asymmetry is the whole point of the design; a reviewer may be tempted to 'simplify' it to clearing on silence, which would re-wake after every transient network error.

  7. GitLab is deliberately untouched and keeps merge-only detection: plain 'glab mr view' field output carries no conflict field, and reading one would need a JSON processor firstmate deliberately does not depend on. This mirrors the existing precedent that a GitLab task records no pr_head. Not an omission.

  8. Changing the poll's bytes intentionally retires every currently-armed check. This is the established upgrade path, not a regression: migration_needed in bin/fm-pr-check-migrate.sh is content-based, so the next --checks-safe run (which both bootstrap and watcher start already perform) quarantines stale copies and rebuilds each poll from the recorded pr=. Homes see one PR_CHECK_MIGRATION line and lose no armed watch.

  9. One clause was added to AGENTS.md section 8's existing check: bullet naming the conflicted-PR result and the rebase response. Firstmate explicitly approved this one-clause growth of always-loaded doctrine because the captain ruled that conflict resolution is fully delegated to workers. Kept to one clause, no new line, per AGENTS.md size discipline.

  10. The worker-side standing rule ('workers rebase autonomously on any cleanly-reconcilable conflict') is deliberately OUT OF SCOPE here and is being filed as its own task against the brief scaffold. Do not flag its absence from bin/fm-brief.sh.

Tests are colocated in tests/fm-pr-check-security.test.sh per repo convention and exercise behavior through the real poll and the real watcher, never implementation source bytes: the dirty-transition wake, dedupe across repeated sweeps, a new head re-waking, resurface-window expiry, clean/closed silence, unchanged merged retirement, teardown marker cleanup, one-call cost, malformed forge rows, and GitLab never emitting a conflict. The fake gh fixture was updated to answer the new field selection as a tab-separated row.

Verification already performed: full tests/fm-pr-check-security.test.sh green (38 passed); adjacent suites green (109 passed across teardown, watch-triage, wake-queue, pr-merge, watch-checkpoint, guard-stale-banner); bin/fm-lint.sh clean at pinned ShellCheck 0.11.0; bin/fm-doc-audience-check.sh clean; the suite was additionally proven hermetic with sentinel gh/glab/gh-axi shims ahead of the real binaries on the test BASE_PATH (zero escapes); and the poll was verified live against real PRs 1378 and 1330 (conflicted, emitted 'dirty '), 1368 (mergeable, silent), 1358 (merged, emitted 'merged'), and a nonexistent PR (silent).

What Changed

  • bin/fm-pr-poll.sh now selects state,mergeable,headRefOid in its single gh pr view request and emits dirty <head> for an OPEN GitHub pull request the forge reports as CONFLICTING, keeping conflict detection at zero extra forge calls; merged is still decided first and alone, so merged and closed pull requests emit byte-identical results, and malformed or unexpected forge output stays silent. GitLab keeps merge-only detection.
  • bin/fm-watch.sh dedupes conflict wakes per episode with a new state/.pr-dirty-<id> marker keyed on the reported head commit, backstopped by FM_PR_DIRTY_RESURFACE_SECS (3600s default); the marker is written only after fm_wake_append succeeds — the review pass moved that record after the enqueue so a failed queue append re-surfaces the conflict instead of swallowing it. The wake reason carries the PR URL so the worker can be steered to rebase, and a merged result clears the marker. bin/fm-teardown.sh removes the marker with the other PR-poll artifacts.
  • Docs and tests: docs/architecture.md documents the conflict path, the head-keyed dedupe, the transient-UNKNOWN silence, and the GitLab carve-out; docs/configuration.md documents the new knob; AGENTS.md gains one clause on the conflicted-PR check result plus the new .pr-dirty-* watcher-internal entry, and the "merge poll" wording in AGENTS.md/docs/scripts.md was broadened to "PR poll". tests/fm-pr-check-security.test.sh grows to 40 checks covering the dirty-transition wake, dedupe and new-head re-wake, resurface expiry, one-call cost, wake-queue-failure recovery, and teardown cleanup.

Risk Assessment

✅ Low: The conflict signal rides the existing single-call validated poll with merged and closed paths preserved byte-identically, the only prior defect (dedupe marker written before the wake was durably enqueued) is now fixed to match the watcher's own enqueue-then-mark invariant and is covered by a regression test using an already-proven queue-failure fixture, and the remaining new state is one watcher-internal marker cleaned up on merge and teardown.

Testing

Ran the colocated tests/fm-pr-check-security.test.sh (40 checks green, covering the four new conflict tests) and the adjacent tests/fm-teardown.test.sh (33 green) for the modified teardown path, then drove the real arm → poll → watcher → wake-drain chain end to end to capture what firstmate actually sees: a conflicting PR surfaces one dirty &lt;head&gt; &lt;url&gt; wake in the same sweep, the same unrebased head stays silent on the next sweep, a moved head wakes again, and merged still emits its unchanged merged reason while retiring the poll and its conflict marker — all at exactly one gh pr view call per poll. Also demonstrated the documented upgrade path, where a home armed on the previous poll bytes is rebuilt by --checks-safe with one PR_CHECK_MIGRATION line and keeps its watch. No visual artifact applies: this change is a bash watcher and CLI with no rendered surface, so the reviewer-visible evidence is the wake-queue transcript. All checks passed and scratch homes were removed; the worktree is clean.

Evidence: End-to-end conflicted-PR wake transcript (arm → poll → watcher → wake-drain)

=== 2. sweep with the PR still mergeable === --- poll output --- --- forge calls made by that poll --- gh pr view kunchenguid#1284 --json state,mergeable,headRefOid -q [.state,.mergeable,.headRefOid]|@tsv --- what firstmate reads on wake (fm-wake-drain.sh) --- (no wake - firstmate is not disturbed) --- conflict episode marker --- (none) === 3. sweep after the PR was overtaken and went CONFLICTING === --- poll output --- dirty 1111111122222222333333334444444455555555 --- forge calls made by that poll --- gh pr view kunchenguid#1284 --json state,mergeable,headRefOid -q [.state,.mergeable,.headRefOid]|@tsv --- what firstmate reads on wake (fm-wake-drain.sh) --- 1785505195 1 check .../state/task-a.check.sh check: .../task-a.check.sh: dirty 1111111122222222333333334444444455555555 kunchenguid#1284 conflict episode marker --- state/.pr-dirty-task-a = 1111111122222222333333334444444455555555 === 4. next sweep, same unrebased conflict (dedupe) === --- poll output --- dirty 1111111122222222333333334444444455555555 --- what firstmate reads on wake (fm-wake-drain.sh) --- (no wake - firstmate is not disturbed) === 5. sweep after the branch moved and conflicted again at a new head === --- what firstmate reads on wake (fm-wake-drain.sh) --- 1785505207 2 check .../task-a.check.sh check: .../task-a.check.sh: dirty 6666666677777777888888889999999900000000 https://github.com/kunchenguid/firstmate/pull/1284&#10;&#10;=== 6. sweep after the PR merged === --- poll output --- merged --- what firstmate reads on wake (fm-wake-drain.sh) --- 1785505208 3 check .../task-a.check.sh check: .../task-a.check.sh: merged --- conflict episode marker --- (none) === 7. armed poll artifacts after merge retirement === task-a.meta


=== 1. arm the merge watch on an open PR (fm-pr-check.sh) ===
●━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
●  WATCHER DOWN - SUPERVISION IS OFF
●  1 task(s) in flight, but no watcher has a fresh beacon (last beat: never, grace 300s).
●  Trust the emitted supervision protocol for this harness; do not use shell & for watcher repair.
●  This is a supervision warning only; the guarded operation WILL still run.
●  repair missing watcher supervision with bin/fm-watch-arm.sh as its own Claude Code background task, never shell &.
●━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
armed: state/task-a.check.sh
armed poll sidecar: github https://github.com/kunchenguid/firstmate/pull/1284 github.com kunchenguid/firstmate 1284 

=== 2. sweep with the PR still mergeable ===
--- poll output (what the watcher check prints) ---
--- forge calls made by that poll ---
  gh pr view https://github.com/kunchenguid/firstmate/pull/1284 --json state,mergeable,headRefOid -q [.state,.mergeable,.headRefOid]|@tsv
--- what firstmate reads on wake (fm-wake-drain.sh) ---
  (no wake - firstmate is not disturbed)
--- conflict episode marker ---
  (none)

=== 3. sweep after the PR was overtaken and went CONFLICTING ===
--- poll output (what the watcher check prints) ---
  dirty 1111111122222222333333334444444455555555
--- forge calls made by that poll ---
  gh pr view https://github.com/kunchenguid/firstmate/pull/1284 --json state,mergeable,headRefOid -q [.state,.mergeable,.headRefOid]|@tsv
--- what firstmate reads on wake (fm-wake-drain.sh) ---
  1785505195	1	check	/tmp/fm-e2e-conflict/home/state/task-a.check.sh	check: /tmp/fm-e2e-conflict/home/state/task-a.check.sh: dirty 1111111122222222333333334444444455555555 https://github.com/kunchenguid/firstmate/pull/1284
--- conflict episode marker ---
  state/.pr-dirty-task-a = 1111111122222222333333334444444455555555

=== 4. next sweep, same unrebased conflict (dedupe) ===
--- poll output (what the watcher check prints) ---
  dirty 1111111122222222333333334444444455555555
--- forge calls made by that poll ---
  gh pr view https://github.com/kunchenguid/firstmate/pull/1284 --json state,mergeable,headRefOid -q [.state,.mergeable,.headRefOid]|@tsv
--- what firstmate reads on wake (fm-wake-drain.sh) ---
  (no wake - firstmate is not disturbed)
--- conflict episode marker ---
  state/.pr-dirty-task-a = 1111111122222222333333334444444455555555

=== 5. sweep after the branch moved and conflicted again at a new head ===
--- poll output (what the watcher check prints) ---
  dirty 6666666677777777888888889999999900000000
--- forge calls made by that poll ---
  gh pr view https://github.com/kunchenguid/firstmate/pull/1284 --json state,mergeable,headRefOid -q [.state,.mergeable,.headRefOid]|@tsv
--- what firstmate reads on wake (fm-wake-drain.sh) ---
  1785505207	2	check	/tmp/fm-e2e-conflict/home/state/task-a.check.sh	check: /tmp/fm-e2e-conflict/home/state/task-a.check.sh: dirty 6666666677777777888888889999999900000000 https://github.com/kunchenguid/firstmate/pull/1284
--- conflict episode marker ---
  state/.pr-dirty-task-a = 6666666677777777888888889999999900000000

=== 6. sweep after the PR merged ===
--- poll output (what the watcher check prints) ---
  merged
--- forge calls made by that poll ---
  gh pr view https://github.com/kunchenguid/firstmate/pull/1284 --json state,mergeable,headRefOid -q [.state,.mergeable,.headRefOid]|@tsv
--- what firstmate reads on wake (fm-wake-drain.sh) ---
  1785505208	3	check	/tmp/fm-e2e-conflict/home/state/task-a.check.sh	check: /tmp/fm-e2e-conflict/home/state/task-a.check.sh: merged
--- conflict episode marker ---
  (none)

=== 7. armed poll artifacts after merge retirement ===
  task-a.meta
Evidence: Upgrade-path transcript: previously-armed poll rebuilt by --checks-safe, no watch lost

=== 1. home armed on the PREVIOUS poll bytes === armed check matches previous release bytes: yes conflict result from the stale poll: ^ empty: the old poll cannot report conflicts === 2. next --checks-safe run (what bootstrap / watcher start performs) === PR_CHECK_MIGRATION: canonical polls rebuilt and armed; resume supervision for this home === 3. armed watch after the upgrade === still armed: yes rebuilt from current poll bytes: yes sidecar: github kunchenguid#1267 github.com kunchenguid/firstmate 1267 conflict result from the rebuilt poll: dirty abcdef0123456789abcdef0123456789abcdef01 merged result from the rebuilt poll: merged


=== 1. home armed on the PREVIOUS poll bytes ===
armed check matches previous release bytes: yes
conflict result from the stale poll: 
  ^ empty: the old poll cannot report conflicts

=== 2. next --checks-safe run (what bootstrap / watcher start performs) ===
  PR_CHECK_MIGRATION: canonical polls rebuilt and armed; resume supervision for this home

=== 3. armed watch after the upgrade ===
still armed: yes
rebuilt from current poll bytes: yes
sidecar: github https://github.com/kunchenguid/firstmate/pull/1267 github.com kunchenguid/firstmate 1267 
conflict result from the rebuilt poll: dirty abcdef0123456789abcdef0123456789abcdef01
merged result from the rebuilt poll: merged
Evidence: Scenario driver: conflicted-PR end-to-end
#!/usr/bin/env bash
# End-to-end: a monitored open PR goes conflicting -> firstmate is woken within
# one poll cycle. Uses the real fm-pr-check.sh (arm), fm-pr-poll.sh (poll),
# fm-watch.sh (watcher) and fm-wake-drain.sh (what firstmate reads at wake).
set -u
ROOT=$1
DIR=$2
rm -rf "$DIR"; mkdir -p "$DIR/home/state" "$DIR/home/data" "$DIR/home/config" "$DIR/fakebin"
BASE_PATH=/usr/bin:/bin:/usr/sbin:/sbin
URL=https://github.com/kunchenguid/firstmate/pull/1284
HEAD_A=1111111122222222333333334444444455555555
HEAD_B=6666666677777777888888889999999900000000

# Stand-in forge CLI: answers exactly the field selection the poll asks for.
cat > "$DIR/fakebin/gh" <<'SH'
#!/usr/bin/env bash
printf 'gh %s\n' "$*" >> "$FM_TEST_GH_LOG"
case " $* " in
  *" state,mergeable,headRefOid "*)
    printf '%s\t%s\t%s\n' "${FM_TEST_GH_STATE:-OPEN}" "${FM_TEST_GH_MERGEABLE:-MERGEABLE}" "${FM_TEST_GH_HEAD:-0000000000000000000000000000000000000000}" ;;
  *" headRefOid "*) printf '%s\n' "${FM_TEST_GH_HEAD:-0000000000000000000000000000000000000000}" ;;
esac
SH
chmod +x "$DIR/fakebin/gh"

printf 'window=fm-task-a\nendpoint_task_id=task-a\nkind=ship\n' > "$DIR/home/state/task-a.meta"

banner() { printf '\n=== %s ===\n' "$*"; }

banner "1. arm the merge watch on an open PR (fm-pr-check.sh)"
FM_HOME="$DIR/home" FM_ROOT_OVERRIDE="$ROOT" FM_TEST_GH_LOG="$DIR/gh.log" \
  PATH="$DIR/fakebin:$BASE_PATH" "$ROOT/bin/fm-pr-check.sh" task-a "$URL"
printf 'armed poll sidecar: '; tr '\n' ' ' < "$DIR/home/state/task-a.pr-poll"; echo

sweep() { # <label> <state> <mergeable> <head>
  local label=$1
  rm -f "$DIR/home/state/.last-check"
  : > "$DIR/gh.log"
  banner "$label"
  printf -- '--- poll output (what the watcher check prints) ---\n'
  FM_TEST_GH_LOG="$DIR/gh.log" FM_TEST_GH_STATE=$2 FM_TEST_GH_MERGEABLE=$3 FM_TEST_GH_HEAD=$4 \
    PATH="$DIR/fakebin:$BASE_PATH" bash "$DIR/home/state/task-a.check.sh" | sed 's/^/  /'
  printf -- '--- forge calls made by that poll ---\n'
  sed 's/^/  /' "$DIR/gh.log"
  : > "$DIR/gh.log"
  perl -e 'my $pid=fork; die unless defined $pid; if(!$pid){exec @ARGV} local $SIG{ALRM}=sub{kill "TERM",$pid; waitpid $pid,0; exit 124}; alarm 12; waitpid $pid,0; alarm 0; exit($?>>8)' \
    env FM_HOME="$DIR/home" FM_ROOT_OVERRIDE="$ROOT" FM_CHECK_INTERVAL=0 FM_CHECK_TIMEOUT=5 \
      FM_POLL=0.02 FM_HEARTBEAT=999999 FM_SIGNAL_GRACE=0 FM_TEST_GH_LOG="$DIR/gh.log" \
      FM_TEST_GH_STATE=$2 FM_TEST_GH_MERGEABLE=$3 FM_TEST_GH_HEAD=$4 \
      PATH="$DIR/fakebin:$BASE_PATH" "$ROOT/bin/fm-watch.sh" >/dev/null 2>&1
  printf -- '--- what firstmate reads on wake (fm-wake-drain.sh) ---\n'
  out=$(FM_HOME="$DIR/home" FM_ROOT_OVERRIDE="$ROOT" PATH="$DIR/fakebin:$BASE_PATH" \
    "$ROOT/bin/fm-wake-drain.sh" 2>/dev/null)
  if [ -n "$out" ]; then printf '%s\n' "$out" | sed 's/^/  /'; else printf '  (no wake - firstmate is not disturbed)\n'; fi
  printf -- '--- conflict episode marker ---\n'
  if [ -e "$DIR/home/state/.pr-dirty-task-a" ]; then
    printf '  state/.pr-dirty-task-a = %s\n' "$(cat "$DIR/home/state/.pr-dirty-task-a")"
  else printf '  (none)\n'; fi
}

sweep "2. sweep with the PR still mergeable" OPEN MERGEABLE "$HEAD_A"
sweep "3. sweep after the PR was overtaken and went CONFLICTING" OPEN CONFLICTING "$HEAD_A"
sweep "4. next sweep, same unrebased conflict (dedupe)" OPEN CONFLICTING "$HEAD_A"
sweep "5. sweep after the branch moved and conflicted again at a new head" OPEN CONFLICTING "$HEAD_B"
sweep "6. sweep after the PR merged" MERGED CONFLICTING "$HEAD_B"
banner "7. armed poll artifacts after merge retirement"
ls "$DIR/home/state" | grep -E 'task-a|pr-dirty' | sed 's/^/  /' || printf '  (all task-a poll artifacts retired)\n'
Evidence: Scenario driver: upgrade path
#!/usr/bin/env bash
# End-to-end upgrade path: a home whose PR poll was armed with the PREVIOUS
# fm-pr-poll.sh bytes must, on the next --checks-safe run (which bootstrap and
# watcher start both perform), rebuild the poll from the recorded pr= and keep
# watching - now with conflict reporting - rather than lose the armed watch.
set -u
ROOT=$1
DIR=$2
OLD_POLL=$3
rm -rf "$DIR"; mkdir -p "$DIR/home/state" "$DIR/home/data" "$DIR/home/config" "$DIR/fakebin"
BASE_PATH=/usr/bin:/bin:/usr/sbin:/sbin
URL=https://github.com/kunchenguid/firstmate/pull/1267
HEAD=abcdef0123456789abcdef0123456789abcdef01
cat > "$DIR/fakebin/gh" <<'SH'
#!/usr/bin/env bash
case " $* " in
  *" state,mergeable,headRefOid "*)
    printf '%s\t%s\t%s\n' "${FM_TEST_GH_STATE:-OPEN}" "${FM_TEST_GH_MERGEABLE:-MERGEABLE}" "${FM_TEST_GH_HEAD:-0000000000000000000000000000000000000000}" ;;
  *" headRefOid "*) printf '%s\n' "${FM_TEST_GH_HEAD:-0000000000000000000000000000000000000000}" ;;
esac
SH
chmod +x "$DIR/fakebin/gh"
printf 'window=fm-task-a\nendpoint_task_id=task-a\nkind=ship\npr=%s\n' "$URL" > "$DIR/home/state/task-a.meta"

banner() { printf '\n=== %s ===\n' "$*"; }
banner "1. home armed on the PREVIOUS poll bytes"
FM_HOME="$DIR/home" FM_ROOT_OVERRIDE="$ROOT" PATH="$DIR/fakebin:$BASE_PATH" \
  "$ROOT/bin/fm-pr-check.sh" task-a "$URL" >/dev/null 2>&1
cp "$OLD_POLL" "$DIR/home/state/task-a.check.sh"
chmod 0600 "$DIR/home/state/task-a.check.sh"
printf 'armed check matches previous release bytes: %s\n' \
  "$(cmp -s "$DIR/home/state/task-a.check.sh" "$OLD_POLL" && echo yes || echo no)"
printf 'conflict result from the stale poll: %s\n' \
  "$(FM_TEST_GH_STATE=OPEN FM_TEST_GH_MERGEABLE=CONFLICTING FM_TEST_GH_HEAD=$HEAD \
     PATH="$DIR/fakebin:$BASE_PATH" bash "$DIR/home/state/task-a.check.sh" || true)"
printf '  ^ empty: the old poll cannot report conflicts\n'

banner "2. next --checks-safe run (what bootstrap / watcher start performs)"
FM_HOME="$DIR/home" FM_ROOT_OVERRIDE="$ROOT" PATH="$DIR/fakebin:$BASE_PATH" \
  "$ROOT/bin/fm-pr-check-migrate.sh" --checks-safe 2>&1 | sed 's/^/  /'

banner "3. armed watch after the upgrade"
printf 'still armed: %s\n' "$([ -f "$DIR/home/state/task-a.check.sh" ] && echo yes || echo no)"
printf 'rebuilt from current poll bytes: %s\n' \
  "$(cmp -s "$DIR/home/state/task-a.check.sh" "$ROOT/bin/fm-pr-poll.sh" && echo yes || echo no)"
printf 'sidecar: '; tr '\n' ' ' < "$DIR/home/state/task-a.pr-poll"; echo
printf 'conflict result from the rebuilt poll: %s\n' \
  "$(FM_TEST_GH_STATE=OPEN FM_TEST_GH_MERGEABLE=CONFLICTING FM_TEST_GH_HEAD=$HEAD \
     PATH="$DIR/fakebin:$BASE_PATH" bash "$DIR/home/state/task-a.check.sh")"
printf 'merged result from the rebuilt poll: %s\n' \
  "$(FM_TEST_GH_STATE=MERGED PATH="$DIR/fakebin:$BASE_PATH" bash "$DIR/home/state/task-a.check.sh")"
Evidence: tests/fm-pr-check-security.test.sh run log
ok - raw-byte parser accepts canonical URLs and rejects the complete adversarial matrix
ok - GitLab merge requests are followed on any instance and never wake falsely
ok - validated merged polls notify once and retire before the next watcher cycle
ok - merged poll retirement preserves every persistent secondmate lifecycle artifact
ok - queue, receipt, and every fixed-path removal crash point recover without loss or repeated execution
ok - open/red, closed-unmerged, malformed, and forge errors remain armed until an exact merged transition
ok - replacement, nonterminal, tampered, and custom results receive no deletion authority
ok - queue failure and untrusted receipts preserve canonical poll evidence
ok - GitHub and GitLab exact merged results share one retirement path
ok - PR and teardown entrypoints reject invalid arguments before every side effect
ok - valid direct and merge flows record exact metadata and reject multiline head metadata
ok - rejected metacharacter bytes remain inert at generation and watcher time
ok - static poll is silent except for one merged line on a mergeable pull request and remains watcher-bounded
ok - conflict reporting emits one dirty line for an open conflicting pull request and nothing else
ok - a conflicted pull request wakes once per episode and leaves clean, closed, and merged outcomes unchanged
ok - a conflict lost to a wake queue failure re-surfaces on the next sweep
ok - teardown retires the conflict episode marker with the rest of the poll
ok - interrupted atomic preparation cleans private temporaries and publishes nothing
ok - concurrent watchers observe only complete private poll publications
ok - post-rename poll validation faults revoke both names and allow a clean retry
ok - migration creates and validates private state before watcher exclusion
ok - migration pauses older watchers and acquires exclusion before its first scan or marker
ok - poll, marker, diagnostic, and quarantine paths refuse symlinks and directories
ok - marker and diagnostic rename errors and signals fail closed and recover durably on retry
ok - post-rename marker, diagnostic, and obligation faults are revoked and reconstructed on retry
ok - quarantine type and mode faults fail closed and recover only when a retry can validate them
ok - canonical and ambiguous failure obligations block every retry until all task artifacts are repaired
●━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
●  WATCHER DOWN - SUPERVISION IS OFF
●  1 task(s) in flight, but no watcher has a fresh beacon (last beat: never, grace 300s).
●  Trust the emitted supervision protocol for this harness; do not use shell & for watcher repair.
●  This is a supervision warning only; the guarded operation WILL still run.
●  repair missing watcher supervision with bin/fm-watch-arm.sh as its own Claude Code background task, never shell &.
●━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
ok - ambiguous migration recovery accepts an explicitly validated replacement poll
ok - ambiguous repair rejects copied, metadata- or task-mismatched, forged, and partial poll publications
ok - all live, marker, diagnostic, X, custom-check, obligation, and teardown boundaries require single-link files
ok - canonical publication failure remains incomplete until a later clean retry rebuilds the poll
ok - legacy reserved obligations and delimiter-bearing task IDs retry without ambiguity
ok - migration never executes legacy checks, preserves X mode, quarantines ambiguity, and is idempotent
ok - historical X shims migrate only from the exact single-link mode-0755 identity
ok - direct registration refreshes authenticated v1 X shims across marker states
ok - bootstrap runs the non-executing migration at the locked session boundary
ok - bootstrap isolates incomplete poll migration from unrelated recovery sweeps
ok - watcher signals promptly stop custom checks and clean private state
ok - returned custom check descendants are drained on installed and fallback timeout paths
ok - teardown removes safe poll artifacts and refuses quarantine-directory symlinks without traversal

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

🔧 **Review** - 1 issue found → auto-fixed ✅
  • ⚠️ bin/fm-watch.sh:814 - pr_dirty_wake_due() records the dedupe marker (line 566) before the wake is enqueued, but fm_wake_append at line 817 exits the watcher on failure. If the queue append fails (lock failure, full/read-only disk), the conflict wake is lost while state/.pr-dirty-<id> already holds that head, so the restarted watcher suppresses the same conflict for up to FM_PR_DIRTY_RESURFACE_SECS (1h by default) — the exact failure mode the design's own comment guards against in the other direction ("a marker that cannot be written surfaces rather than suppresses"). Every other wake path in this file inverts the order deliberately: fm-watch.sh:305 appends then clears .stale-since-, :427 appends then writes .stale-/.paused-, :874 appends for all pending signals and only then advances .seen- markers, with scan_signals' comment at line 456 stating the invariant explicitly ("a watcher killed mid-cycle never swallows a signal"). Fix by splitting the decision from the record: have pr_dirty_wake_due only compare marker content and age, and write the marker after fm_wake_append succeeds, alongside the existing rm -f at line 819.

🔧 Fix: record PR conflict episode only after wake enqueued
✅ Re-checked - no issues remain.

✅ **Test** - passed

✅ No issues found.

  • bash tests/fm-pr-check-security.test.sh — 40 checks green, including test_static_poll_conflict_contract, test_conflicted_pr_wakes_once_per_conflict_episode, test_conflict_episode_survives_wake_queue_failure, test_teardown_removes_conflict_episode_marker
  • bash tests/fm-teardown.test.sh — 33 checks green (adjacent suite for the modified bin/fm-teardown.sh)
  • Manual end-to-end scenario: real bin/fm-pr-check.sh arms a watch, real bin/fm-watch.sh sweeps against a stand-in gh returning OPEN/MERGEABLE, OPEN/CONFLICTING (same head twice, then a new head), and MERGED, with real bin/fm-wake-drain.sh printing the wake firstmate consumes after each sweep
  • Per-sweep forge-call log asserted to contain exactly one gh pr view ... --json state,mergeable,headRefOid invocation
  • Manual upgrade-path scenario: poll armed with the base-commit fm-pr-poll.sh bytes, then bin/fm-pr-check-migrate.sh --checks-safe run to show the rebuilt, still-armed poll now reporting conflicts
🔧 **Document** - 1 issue found → auto-fixed ✅
  • ℹ️ AGENTS.md:318 - AGENTS.md:318 and the docs/scripts.md fm-pr-check.sh row still call the armed artifact "the watcher's merge poll" / "a static merge poll", which now under-describes it (it also reports conflicts). I deliberately left both: docs/architecture.md is the authoritative owner of the conflict behavior and already states it, the poll's own scripts.md row was updated, and growing AGENTS.md further would violate its size discipline and this change's stated one-clause budget. Flagging as a judgment call in case you prefer the name broadened to "PR poll" in those two spots.

🔧 Fix: rename merge poll to PR poll in armed-check docs
✅ Re-checked - no issues remain.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

kunchenguid and others added 30 commits July 27, 2026 12:28
* feat: add verified pi-signed adapter

* no-mistakes(review): Correct pi-signed maintainer verification date

* no-mistakes(review): Correct remaining pi-signed verification dates

* no-mistakes(review): Preserve authoritative pi-signed runtime identity

* no-mistakes(document): Document pi-signed shared adapter semantics

* no-mistakes: apply CI fixes
* fix(pi): rearm watcher across same-process session transitions

Pi emits session_shutdown for ordinary /new, /resume, and /fork replacement
as well as terminal quit. The primary watcher extension latched a module-level
stopping flag on every shutdown, so a replacement session in the same process
could not arm monitoring until Pi restarted.

Own arm authority per session generation so only the active live generation
may start, stop, or rearm the child. Replacement sessions can arm again without
restarting Pi, stale prior-generation callbacks cannot mutate the active cycle,
and real quit still blocks late rearm.

* no-mistakes(review): Preserve Pi generation isolation and exit cleanup

* no-mistakes(document): Correct Pi watcher transition documentation
* Consume quota-axi pace signals in dispatch profile array selection.

Add quota-array-dispatch as the single owner of the pace-aware candidate
choice, keep AGENTS.md to the intake boundary and load trigger, and cover
the acceptance cases with sanitized schemaVersion 3 fixtures.

* no-mistakes(review): Stop and report genuine quota dispatch ties

* no-mistakes(document): Document quota pace freshness and uncertainty
…nguid#1171)

* fix(grok): adapt Stop continuation to runtime capability

* no-mistakes(review): Reject ambiguous Grok Stop payloads

* no-mistakes(review): Reject duplicate Grok fields and accept spaced tmux sessions

* no-mistakes(review): Enforce exact tmux cleanup selectors

* no-mistakes(test): Fix historical tmux fixture and validate Grok Stop

* no-mistakes: apply CI fixes
* fix(brief): make DOD scaffolding parse-safe on stock macOS Bash 3.2

fm-brief.sh built each Definition-of-done block and the not-enabled
Herdr declaration with `VAR=$(cat <<EOF ... EOF)`. On Bash 3.2 (macOS
/bin/bash) the lexer scans for the command substitution's closing `)`
textually and tracks quote state through the heredoc body, so a single
apostrophe, unbalanced quote, or unbalanced paren in that prose breaks
parsing of the whole script. Every ship-brief scaffold (no-mistakes,
direct-PR, local-only) failed with `unexpected EOF while looking for
matching )`. Bash 4+ parses it fine, so the breakage stayed invisible
everywhere except stock macOS.

Replace all four command-substitution heredocs with
`IFS= read -r -d '' VAR <<EOF || true`. That removes the `$(...)`
wrapper and the entire defect class regardless of future prose, and
preserves the variable expansion the direct-PR and local-only bodies
need. `read` keeps the heredoc's trailing newline that `$(...)` used to
strip, so trim one newline to keep every generated brief byte-identical
to prior output.

Guard the structure, not one historical phrase: a new test rejects any
heredoc nested in a command substitution anywhere in fm-brief.sh, where
the old assertion pinned a single apostrophe phrase and so missed the
reintroduction. Extend the stock-macOS Bash CI job from parsing one
script to the whole maintained shell surface (bin/*.sh,
bin/backends/*.sh, tests/*.sh), matching bin/fm-lint.sh's canonical file
set so parse scope and lint scope cannot drift apart.

* no-mistakes(review): Captain: harden Bash structure and inventory guards

* no-mistakes(document): Align stock macOS Bash contributor checks

* no-mistakes(lint): Suppress deliberate SC2016 literal fixture warnings
* fix(test): pin teardown tmux baseline to historical kill selectors

merge-base HEAD main collapses to HEAD after the exact-selector change
lands on the default branch, so the old teardown fixture was accidentally
exercising current exact targets. Resolve a content-historical permissive
tmux adapter from first-parent history and force that post-squash topology
inside the conformance case so main and feature branches keep the same
old-vs-new contract.

* no-mistakes(lint): Suppress intentional literal-pattern ShellCheck warnings
…id#1197)

Cut the runtime skill to the compact pace-aware selection procedure plus
minimum owner pointers. Keep every distinct decision rule and move expanded
acceptance scenarios to deterministic fixture ownership assertions.

Size: 170/1374/10187 -> 63/544/4068 (about 63%/60%/60% reduction).
…1219)

* Inherit config/backend into secondmate homes with deliberate-override preservation

Add backend to the shared inheritable config allowlist so launch, locked
bootstrap, and config-push converge a primary pin into secondmate homes as each
home local future-spawn default. Track last-inherited bytes in a private state
provenance marker so deliberate per-home overrides survive present and absent
primary convergence, keep --backend and FM_BACKEND stronger, and extend the
existing inheritance tests plus docs and skill claims.

* no-mistakes(review): Preserve equal unprovenanced backend overrides

* no-mistakes(review): Preserve symlink overrides and verify spawn precedence

* no-mistakes(review): Snapshot backend inheritance for consistent provenance

* no-mistakes(review): Simplify backend inheritance to primary-authoritative convergence

* no-mistakes(document): Document inherited backend override preservation

* fix: restore primary-authoritative backend inheritance after document regression

The document step reintroduced provenance and deliberate per-home override
semantics after review had simplified config/backend to plain primary-authoritative
allowlist membership. Restore the primary-always-wins path: present overwrites,
absent removes, no provenance marker, and docs/tests match that contract.

* no-mistakes(review): Add divergent backend precedence regression fixtures

* no-mistakes(document): Document backend inheritance contract
* fix(pi): remove Calm's exclusive Pi upper-version ceiling

tests/fm-calm-pi-extension.test.sh gated on a closed PI_COMPAT_VERSIONS
allowlist ("0.81.1 0.82.0") that refused any other installed Pi, and docs
described that range as "supported" rather than verified evidence. The
Calm CHANGELOG shows no API introduced at either version, so there is no
evidence for a real minimum; the presentation adapters already probe the
exact method they patch rather than checking a version.

Replace the allowlist with dated version evidence that never rejects a
newer Pi, and make each presentation adapter degrade independently with
a diagnostic if a future Pi removes its API, instead of the whole Calm
extension failing to load. Rewrite the feasibility doc's "Pi 0.81.1
through 0.82.0" phrasing to state it as verified evidence, not a
ceiling.

* no-mistakes(review): Probe missing Calm adapter exports safely

* no-mistakes(document): Document Calm's unbounded Pi compatibility
…enguid#1204)

* fix(guard): allow session-local todo tools in the primary

The delegation-shape guard denied TaskCreate and TaskUpdate because their
normalized names contain the `task` stem. Those tools write only the harness's
session-local todo list, which has no executor: it spawns no agent, allocates
no worktree, registers no schedule, and starts nothing that outlives the
session. That is not the unaccounted work the guard exists to stop, so the stem
match was a false positive, and the deny text told the primary to run
bin/fm-brief.sh and bin/fm-spawn.sh to create a todo entry.

Add a separately-reasoned PLAN_ONLY_TOOLS exact-name exclusion rather than
widening OBSERVE_ONLY_TOOLS, whose documented contract is tools that only
observe or stop existing work. Both lists stay exact-name so neither can widen
by substring.

Tests cover the two allowed names and six near-miss names that a substring or
shortened-stem widening would release; both mutations were watched red.

* no-mistakes(review): drop session-local todo tools from recommended deny list

* no-mistakes: apply CI fixes
…claude pid (kunchenguid#1206)

* fix(session-lock): resolve Claude bg-spare ancestry to the outermost claude pid

fm_harness_ancestry_pid() previously returned the first ancestor process
whose command matched a verified harness name. Claude Code's Stop hook
fires as a bg-spare worker several levels below the session's actual
lock-owning claude process (hook shell -> claude bg-spare ->
claude bg-pty-host -> claude -> claude(lock)), so the first match was
the bg-spare worker, not the lock owner. fm_session_lock_owned_by_self()
then never matched state/.lock, and the Claude Stop auto-arm silently
treated its own primary session as an unrelated live owner and never
armed the watcher.

The walk now keeps going past a claude-named match, looking for a still
more ancestral claude-named match, and stops the instant a non-match
follows an already-found match (bounding it to a contiguous run rather
than the literal ancestry top, so an unrelated claude-named process
further up the real process tree is never mistaken for part of this
session's own nested chain). Every other harness keeps the original
first-match-wins behavior, since e.g. Pi's shared signed-wrapper
ancestry actually holds the session at the inner engine pid, not an
outer wrapper pid. Hop limit raised from 8 to 16 to cover the deeper
bg-spare chain.

* no-mistakes(review): Add nested-claude-ancestry regression test; fix nudge doc depth claim

* no-mistakes: apply CI fixes
* fix: confirm watcher startup on MSYS

* no-mistakes(review): gate MSYS arm ready timeout, cache uname, harden locale test

* no-mistakes(review): validate OpenCode ready timeout, make uname cache internal
…d#1195)

* fix(spawn): forward firstmate's CLAUDE_CONFIG_DIR to claude crewmates

Crewmate panes are created by a long-lived tmux/herdr daemon that does not
inherit firstmate's current environment. When firstmate runs under a non-default
CLAUDE_CONFIG_DIR (for example a work-vs-personal subscription split), a bare
`claude` in the crewmate pane fell back to the default ~/.claude store and
launched unauthenticated, blocking the crewmate before it could do any work.

fm-spawn now prefixes the claude launch with firstmate's own resolved
CLAUDE_CONFIG_DIR when set, so the crewmate uses the same credential/config
store firstmate is authenticated with. An unset value is the single-store
default and adds no prefix; non-claude harnesses are unaffected.

Adds three tests in fm-spawn-dispatch-profile.test.sh (forwarded-when-set,
omitted-when-unset, non-claude-ignored) and pins CLAUDE_CONFIG_DIR in the test
helper so launch assertions no longer depend on the developer's environment.

* no-mistakes: apply CI fixes
…guid#1233)

* fix: preserve dispatch harness identity

* no-mistakes(review): Fix Grok counterfactual tuple validation

* no-mistakes(document): Scope dispatch authentication to selected tuple

* fix: restore dispatch instruction budget

* no-mistakes(review): Scope dispatch authentication after candidate selection
* fix(bin): handle dash-leading harness process names (#2)

* fix: handle dash-leading harness process names

* no-mistakes(review): Make dash-leading harness regression hermetic

* fix: preserve secondmate reply routes across relative homes

Resolve relative home, data, and state inputs before durable charter generation, and fail when caller-relative directories cannot be resolved.

Use absolute paths at the related spawn, AFK daemon, and X-mode cross-process handoffs so later processes cannot reinterpret them from another working directory.

* no-mistakes(review): Preserve absolute overrides and normalize relative durable paths

* no-mistakes(review): Normalize relative home before deriving durable paths

* no-mistakes(document): Document relative durable-path normalization

* no-mistakes(review): Captain: Ignore inherited CDPATH during relative path normalization

* no-mistakes(lint): Fix empty CDPATH assignments for ShellCheck
* Add internal status skill

* no-mistakes(document): register /status skill in documentation-audiences inventory

* no-mistakes(lint): replace grep|wc -l with grep -c in status skill test

* test: silence literal status skill patterns

* Refactor bearings default to chat-only

---------

Co-authored-by: Kun Chen <3233006+kunchenguid@users.noreply.github.com>
* docs: add captain-approved project operation exception to hard rule 1

Firstmate stays read-only over projects by default, but when the captain
clearly approves a concrete project operation and scope in the moment,
firstmate may perform exactly that approved operation with its own tools.
The approval is never inferred, broadened, or standing, and it does not
relax the existing force, discard, unlanded-work, or merge-authority
boundaries.

* no-mistakes(review): Clarify captain-approved project operation boundaries

* no-mistakes(document): Clarify captain-approved project operation scope

* docs: cover directories and preserve the operation-or-scope alternative

Widen the captain-approved project operation exception in AGENTS.md to
files or directories, and restore the explicit operation-or-scope
alternative that a prior pipeline auto-fix had collapsed into "and".

Rework project-management SKILL.md's Remove section, which previously
told firstmate to refuse project removal until a guarded helper existed;
that helper was never built, so the text directly contradicted the new
instruction-only exception. It now points at the exception plus the
existing removal preflight it still requires unchanged.

Update the one instruction-owners test assertion that hard-coded the
sentence removed above, so the suite tracks current, not obsolete, text.

* docs: add captain-approved project operation exception to hard rule 1

Firstmate stays read-only over projects by default, but when the captain
clearly approves a concrete project operation and scope in the moment,
firstmate may perform exactly that approved operation with its own tools.
The approval is never inferred, broadened, or standing, and it does not
relax the existing force, discard, unlanded-work, or merge-authority
boundaries.

* no-mistakes(review): Clarify captain-approved project operation boundaries

* no-mistakes(document): Clarify captain-approved project operation scope

* docs: cover directories and preserve the operation-or-scope alternative

Widen the captain-approved project operation exception in AGENTS.md to
files or directories, and restore the explicit operation-or-scope
alternative that a prior pipeline auto-fix had collapsed into "and".

Rework project-management SKILL.md's Remove section, which previously
told firstmate to refuse project removal until a guarded helper existed;
that helper was never built, so the text directly contradicted the new
instruction-only exception. It now points at the exception plus the
existing removal preflight it still requires unchanged.

Update the one instruction-owners test assertion that hard-coded the
sentence removed above, so the suite tracks current, not obsolete, text.

* no-mistakes(review): Align project removal preflight with approved exception

* no-mistakes(document): Align project removal documentation with approved exception

* fix: restore removal test byte-for-byte and preserve the default sentence

tests/fm-instruction-owners.test.sh had been changed to assert different
text; restore it byte-for-byte to origin/main. project-management SKILL.md's
Remove section now keeps the exact default "Never issue a raw removal
command from Firstmate." sentence that test still asserts, immediately
followed by the already-approved captain-operation-or-scope exception, so
the default and the exception both stay explicit and consistent.

* no-mistakes(document): Align project-write boundary documentation
…henguid#1275)

* Route project intake through secondmate scopes

* no-mistakes(test): Guard all main-home project registry mutations

* no-mistakes(document): Consolidate secondmate routing documentation

* no-mistakes: apply CI fixes

* Restore new-project routing scope

* no-mistakes(document): Clarify secondmate routing for new-project intake

* no-mistakes: apply CI fixes
)

* fix: scope validation corrections by accepted behavior

* no-mistakes(review): Classify stale delivery evidence as an autonomous correction
…#1282)

* test: remove source-content assertions

* no-mistakes(review): Replace source assertions with runtime behavior coverage

* no-mistakes(review): Isolate Kimi task temp runtime coverage

* no-mistakes(document): Refresh test cleanup documentation

* no-mistakes: apply CI fixes
…#1286)

* fix(watch): bound how long a busy pane may run with no completed turn

A busy pane (backend busy state or the harness's rendered footer) was
unconditional, unbounded proof of liveness in every escalation path, so a
hung foreground tool call behind a busy signature could run for hours
undetected (2026-07 hibit-agent-focus-nonsteal-r1 incident: a catastrophic-
backtracking regex hung one bash call for 25h behind an unchanging
"Working..." footer).

FM_BUSY_TURN_MAX_SECS (default 3600s) now bounds how long a busy pane may
run with no completed turn (state/<id>.turn-ended, or its spawn record
before any turn has completed). Past the bound, busy_turn_over_age routes
the pane through the existing wedge_timer_check, reusing the identical
stale reason, escalation counter, and demand-deep-inspection marker for
human inspection only - never an automatic interrupt, signal, or restart
of the worker or its tool process. A completed turn resets the age.

Reproduced end-to-end against the real installed Pi TUI: a foreground
`sleep 999999` bash call with no timeout renders the actual busy footer,
and two captures ~15s apart show the elapsed counter changing the pane
hash while the same turn stays unfinished. Running the pre-fix watcher
against the real captures showed it never starts a wedge timer no matter
how long the pane stays busy; the fixed watcher starts and escalates the
timer through the same mechanism, while the real hung process remained
untouched and alive throughout.

* no-mistakes(review): fix: parse enriched AFK stale reasons

* no-mistakes(review): fix: preserve enriched wedges during AFK supervision

* no-mistakes(review): fix: route all enriched AFK wedges

* no-mistakes(document): Clarify busy-turn age supervision documentation
…unchenguid#1261)

A name-by-name list of config/ entries silently stops ignoring any new or
home-local file placed there, which makes the working tree read as dirty and
blocks guarded sync paths that refuse to touch a dirty home. AGENTS.md
already documents config/ as captain-private and gitignored as a category;
this makes .gitignore match that contract.
…al coverage (kunchenguid#1304)

The second assertion in fm-gitignore-config.test.sh (added by kunchenguid#1261) greps
.gitignore for a specific spelling of the config/ ignore pattern. It fails
on a semantically equivalent pattern like config/** and does not prove Git
actually ignores anything, per the completed source-content-test audit.

Replace it with a real git check-ignore control test on a generated
unrelated path, and strengthen the existing directory-coverage test with
generated unpredictable direct and nested config/ paths.
)

* Add bounded startup memory curation

* no-mistakes(review): Record reproducible stow verification evidence

* no-mistakes(review): Validate inherited secondmate stow evidence

* no-mistakes(document): Document editable startup-memory budget propagation
* fix(herdr): place workers in the launching agent's exact workspace

Herdr enforces no workspace-label uniqueness, and spawn resolved its
container by taking the FIRST workspace whose label matched the home
label. With two workspaces both labeled "firstmate", a worker launched
from the second one was created in the first, so it appeared in a
different space than the Firstmate the captain was watching.

Reproduced end to end on Herdr 0.7.5 protocol 17 by running the real
bin/fm-spawn.sh inside a launcher pane in the second "firstmate"
workspace: the worker landed in w1 while its launcher was in w2, with an
unrelated third workspace focused throughout, which also rules out any
dependence on the focused workspace.

Placement now binds to the launching process's own Herdr identity. Herdr
injects HERDR_PANE_ID, HERDR_SESSION, and HERDR_SOCKET_PATH into every
process it manages a pane for, and fm_backend_herdr_launcher_identity
resolves that pane's current owning tab and workspace live from Herdr,
cross-checking the pane against its tab and confirming the workspace
exists exactly once in the session. The injected HERDR_TAB_ID and
HERDR_WORKSPACE_ID are creation-time snapshots and are deliberately not
read as current identity. Labels are no longer placement authority.

A claimed parent identity that is unreadable, contradictory, stale, or
from another named session or Herdr server stops the spawn before any
worker endpoint exists, rather than degrading to a label search. A
launcher with no Herdr ancestry has no workspace to inherit and keeps
the per-home labeled container, which must now resolve to exactly one
workspace; two same-labeled candidates refuse instead of adopting
either. A --secondmate launch keeps standing up that home's own
workspace by design.

With presentation spaces enabled, the projected child is created and
bound under that same exact parent and anchors its ordering on it, so a
duplicated home label no longer makes the layout ambiguous. Projection,
focus restoration, restart binding, and quarantine rules are unchanged,
and children are never collapsed into the parent. tmux, Zellij, cmux,
Orca, and the away-mode daemon terminal were each inspected and are not
affected: none resolves a container by searching mutable labels.

tests/fm-backend-herdr-launcher-workspace-e2e.test.sh drives the real
spawn and teardown against an isolated Herdr lab, with its headline case
running fm-spawn.sh inside a real Herdr pane so the identity comes from
Herdr's own injection. The refusal matrix and the ordering anchor are
covered deterministically in tests/fm-backend-herdr.test.sh.

Eight existing real-Herdr suites inherited the developer terminal's own
Herdr pane into their isolated lab sessions, which the new cross-session
check correctly refuses. tests/herdr-test-safety.sh now owns
herdr_forget_inherited_pane and those suites call it, so what they assert
no longer depends on where they were launched from.

Two unrelated fixes found along the way. tests/fm-secondmate-harness.test.sh
had the same class of environment leak through CLAUDECODE, which outranks
PI_CODING_AGENT in bin/fm-harness.sh and made its pi-signed ancestry case
resolve "claude" whenever the suite ran inside Claude Code. And
fm-spawn.sh's usage() printed a fixed line range that had already been
truncating its own help mid-sentence.

* no-mistakes(review): Enforce exact Herdr launcher and projection identity

* no-mistakes(document): Document exact Herdr launcher workspace placement
* feat(calm): replace Pi's working row with an animated ship while Calm is on

While Calm is active and one logical agent run is under way, Calm now hides
Pi's built-in working row and renders a small two-row SSHHIP-derived boat in
its place. When Calm is off, Pi's stock working row is left untouched.

The presentation uses only public Pi extension API: setWorkingVisible(false)
plus a temporary setWidget() component whose render(width) owns the responsive
geometry and whose timer requests a TUI render. Visibility follows agent_start
through agent_settled, so the boat does not flicker between tool calls,
automatic continuations, retries, or compaction inside the same run, and
settle, abort, and failure all reach the same cleanup.

fm-calm.ts stays the sole owner of the presentation choice and the only caller
of setWorkingVisible(); the new lib owns the sprite geometry and widget.

* no-mistakes(review): Guarded Calm-off lifecycle visibility writes; focused tests pass

* no-mistakes(test): Fixed Calm E2E wait to include tmux scrollback

* no-mistakes(document): Document Calm working boat behavior

* no-mistakes: apply CI fixes

* feat(calm): slow the Calm boat, animate blue water, and make the sail directional

The boat now moves one column every 880ms while a bounded fixed-cell water phase
advances every 220ms, so the water ripples several times between boat steps and
the presentation reads as calm. One scheduler drives both clocks and disposing
the widget stops them together; ticks rather than wall-clock timestamps drive
every state change, so tests seek animation time exactly.

Colors are standard ANSI foreground codes instead of theme lookups: blue for
every water cell and yellow for the complete boat, each run closed with a
default-foreground reset so nothing bleeds into padding or later frames. ANSI
bytes never enter geometry, so visible width stays exact.

The mainsail is directional and trails aft of the mast: <| travelling right and
|> travelling left. Direction reverses the moment the boat lands on an endpoint,
so the endpoint frame already shows the new heading and no frame at or after a
bounce shows the previous sail.

* test(calm): wait for the Ctrl+O expansion redraw this block asserts

* docs(calm): record the revised working-presentation verification evidence

* no-mistakes(document): Fix Calm feasibility document EOF whitespace
…henguid#1349)

* fix(dispatch): scope candidate authentication to its own surface

A locally expired timestamp in one credential store was reported to the
captain as a sign-out, including for dispatch candidates that never read
that store. A `harness=pi, model=xai/grok-*` candidate authenticates
through Pi's own xAI credential, but the only Grok quota reading
available was gated on the standalone Grok CLI's separate token, whose
expiry clock drifts independently. The always-loaded intake rule then
turned that unreadable quota into a mandatory captain escalation.

Add `bin/fm-auth-preflight.sh` as the deterministic owner of the parts
that must not depend on agent memory: it resolves a tuple's
authentication surface from quota-axi's own emitted auth sources rather
than from a harness or model name, so another harness's CLI can never
gate a candidate that does not use it. A vendor CLI is launched only
when the tuple's own harness owns the credential store under test and a
non-destructive discovery command is registered for it, which today is
`grok models` alone. That probe runs at most once with stdin closed and
a hard timeout, reads its verdict from the first stdout line because the
command exits 0 either way, treats unrecognized output as indeterminate,
and never invokes login, logout, or the interactive TUI. Quota is read
at most twice, and unknown headroom never makes a candidate ineligible
on its own.

Update the dispatch procedure to match: usable authentication with
unmeasurable headroom stays eligible at lower preference with the
unknown disclosed, and stop-and-report is reserved for unresolved
authentication, an unresolved relationship, or malformed configuration.
Record that Grok's `credits.remaining` is a prepaid balance rather than
window headroom.

Gate quota-axi at 0.1.16 in bootstrap, the first build reporting
per-credential auth sources. A stale install previously passed the
presence check silently, which is why a fix published two days earlier
was still not in effect.

Replace the orphaned quota-array-dispatch fixtures, which encoded a
`provider: "xai"` shape the tool never emits and had no consumer, with
fixtures shaped like real 0.1.16 output that the new suite drives the
script against. The suite asserts the verdict and, separately, which
vendor CLIs were launched, so a Pi/xAI candidate reaching the Grok CLI
fails. Map `tests/fixtures/<dir>` to its consuming suite so a fixture
change selects the right tests instead of refusing.

* refactor(bootstrap): give the quota-axi floor one owner

The floor was stated twice - once in bootstrap's gate and once inline in
the auth preflight - so bumping it needed two edits that could drift.
Move it to bin/fm-quota-axi-lib.sh alongside its rationale, matching the
existing tasks-axi library, and derive the comparison from the constant
so the number appears exactly once. Bootstrap turns a failing check into
the operator diagnostic; the preflight refuses to emit an unscoped
verdict. Map the new library to both consuming suites so a bump re-runs
them, and record that any usable source means the surface authenticates.

* no-mistakes(review): Captain: bound quota checks and removed Python dependency

* no-mistakes(review): Captain: enforce conservative headroom and exact preflight retry

* no-mistakes(review): Captain: preserve OpenCode eligibility without auth-surface guessing

* no-mistakes(review): Captain: reject malformed OpenCode model relationships

* no-mistakes(review): Captain: exempt verified unmodeled tuples from intake escalation

* no-mistakes(document): Updated dispatch authentication documentation

* no-mistakes: apply CI fixes
…nchenguid#1350)

* feat(x-mode): reconcile promised public replies deterministically

A promised final reply in an X or Discord thread was only kept while the
primary remembered it. Compaction or restart erased that memory, so a typed
public-followup obligation could sit at pending-work after its PR merged and
the original thread never got its reply.

Make the promise durable state instead:

- bin/fm-public-followup-emit.sh reports a typed terminal work result (source
  home, work id, generation, outcome, safe deliverables, bounded public-safe
  text) into the owning home's private inbox. The event id is derived from
  that identity tuple, so duplicate reports and restart replay converge with
  no coordination, and nothing ever parses a free-form done: sentence.
- bin/fm-public-followup.sh registers a commitment, reconciles events through
  tasks-axi public-followup, and runs the idempotent delivery sequence
  (begin-delivery with the payload hash, post, record the posted receipt or a
  typed error) against the stored platform and opaque thread binding. A
  delivery interrupted between post and receipt refuses rather than risk a
  second public reply.
- Session start surfaces unresolved commitments from disk, the existing relay
  poll surfaces a new terminal-result set once, and teardown refuses while
  this home still owes a public reply for that exact work.

tasks-axi public-followup remains the only owner of the obligation state
machine, state/x-context/ the only owner of the private request context, and
fm-x-reply.sh the only thing that posts. Its new optional --receipt-file is
the one addition there, so a caller can record how many messages were sent.

A home that never opted into the myfirstmate relay gates out on a single
[ -f "$FM_HOME/.env" ] test: no tasks-axi call, no backlog or context scan,
no output, and no artifact. Evidence in docs/verification/public-followup.md.

* no-mistakes(review): Hardened public-followup reconciliation and ownership guards

* no-mistakes(review): Hardened typed terminal cleanup and receipt reconciliation

* no-mistakes(review): Automated typed-delivery cleanup and strict backlog validation

* no-mistakes(review): Fail-closed parent resolution and registration-safe delivery

* no-mistakes(review): Harden relay gating and validate secondmate bindings

* no-mistakes(review): Use owner-aware single-gate teardown protection

* no-mistakes(document): Correct public-followup documentation drift

* no-mistakes(lint): Quote done literals to fix ShellCheck warnings

* no-mistakes: apply CI fixes
…chenguid#1327)

* feat: add semantic busy-state contract owner and event writer

One owner (bin/fm-busy-lib.sh) for the captain-approved semantic
busy-state redesign: a per-task gen-bound record written only by
bin/fm-busy-event.sh, per-harness trusted-source classification with
explicit source attribution, busy/idle/unknown/dead semantics where
missing, malformed, stale, or untrusted semantic data is unknown -
never idle - and endpoint death is the only process-level override.
The Grok-only rendered-tail fallback and the standalone-Kimi
verification gate live behind the same classifier.

* feat: arm busy-state at spawn and convert Pi to the semantic extension path

fm-spawn arms the busy-state contract for converted adapters and seeds
busy/fm-spawn (the launch brief is a submitted turn). The Pi/pi-signed
per-task extension now reports agent_start -> busy and agent_settled ->
idle confirmed by ctx.isIdle(), covering auto-retries, compaction
retries, tool loops, and queued continuations, while turn_end stays a
wake notification touch. Teardown removes the new record, gen sidecar,
and lock. Live-verified on Pi 0.82.0: seed -> agent-start busy ->
agent-settled idle with the marker still touched.

* feat: convert OpenCode to the semantic session.status plugin path

The per-task plugin (renamed .opencode/plugins/fm-busy-state.js) now
classifies from OpenCode's semantic session.status events - busy and
retry are active, idle is inactive - latched to the worker's own
session so a subagent child session can never clear the worker's busy
state. The session.idle marker touch stays a wake notification.
Teardown removes both the new and the legacy plugin filenames.
Live-verified on OpenCode 1.17.18 in a real TUI pane: seed ->
session-busy -> session-status-idle.

* feat: convert Claude to the full lifecycle hooks path

The per-task settings.local.json now wires UserPromptSubmit -> busy
and Stop, StopFailure, and SessionEnd -> idle, so API-error and
shutdown turn ends can never strand a busy record; Stop keeps the
turn-ended notification touch. A refused (stale-gen) event exits 0 and
stays silent so Claude's own lifecycle is never broken. Live-verified
on Claude Code 2.1.220: UserPromptSubmit fires for the argv launch
prompt, Stop closes each turn, a mid-stream Escape interrupt fires no
closing hook, and the firstmate-controlled idle/fm-interrupt clear
resolves it.

* feat: gate Codex busy state behind verified semantic sources

The approved contract prefers Codex's app-server turn lifecycle with
capability negotiation and sanctions its lifecycle hooks as the
intermediate. Live probes on codex-cli 0.145.0 show neither is usable
for a pane worker: the app-server daemon is unreachable for a TUI
thread and refuses to start outside the managed standalone install,
and firstmate-written project hooks never fired (interactive with
directory trust granted, and exec, both with
--dangerously-bypass-hook-trust) while global hooks fired in the same
runs. Codex therefore classifies unknown codex-unverified behind an
explicit probe rather than falling back to idle or footer text, and
fm-spawn installs no unverified Codex wiring.

* feat: gate standalone Kimi busy state on live verification

Standalone Kimi has no installed binary here, so per the approved
contract its semantic path stays guarded and it classifies unknown
kimi-unverified rather than idle - and never from its locale-sensitive
moon-phase spinner, which the redesign forbids inventing as a state
source. The gate records the preferred source order (Wire prompt
request lifetime, which brackets a turn and reports cancellation, then
the documented hooks including Interrupt because Stop does not fire on
interrupts) and the exact evidence required to open it. Arming without
wiring would seed a busy record nothing could clear, so both land
together behind the same gate.

* feat: route busy consumers through the contract and drop the global OR

The watcher, crew-state reader, and away-mode daemon now decide busy
state through bin/fm-busy-lib.sh: only an exact busy verdict counts as
working, and unknown never becomes working or a silent idle, so a crew
whose semantic state is missing, malformed, stale, or unverified
surfaces instead of being absorbed. Crew-state reports the producing
source in its detail. The watcher's global OR regex default is gone;
Grok keeps its isolated fallback inside the contract. The daemon's
supervisor-pane reader stays rendered-text - that pane is not a
recorded task - but is now scoped to firstmate's own detected harness
instead of every vendor signature. Secondmate pending-reply
observation is deliberately unchanged and documented as a
delivery-confirmation signal, not task state.

* docs: point busy-state documentation at the single contract owner

Adds a maintainer-architecture section naming bin/fm-busy-lib.sh as
the owner of what busy means, with per-adapter sources, the
unknown-never-idle rule, the endpoint-death override, and the two
rendered-text readers that deliberately stay outside the contract.
Replaces the stale regex-first prose in architecture, tmux-backend,
herdr-backend, and configuration; converts the harness-adapters
per-harness rows from UI signatures to the semantic source each
harness uses; and records the live verification evidence, including
why Codex and standalone Kimi stay unknown.

* fix: arm away-launch signal handlers before acquiring the lifecycle lock

fm_afk_launch_main acquired its lock and only then installed the EXIT,
INT, and TERM traps. A signal arriving in that window terminated the
process by default action and left the lock directory behind, which
blocks the next away-mode launch until the stale-owner reclaim path
clears it. The release helper only removes a lock this process owns,
so the handlers are now armed first. The accompanying test also killed
the child whether or not the lock had appeared and sampled cleanup the
instant wait returned; it now requires the lock, then allows a bounded
settle, so it proves the guarantee instead of racing it.

* test: align fleet, Kimi, lifecycle, and detection suites with the contract

The fleet snapshot and wake-daemon lifecycle fixtures now prove a
working crew through its own semantic busy-state record instead of
rendered pane text, which is what those consumers read. The Kimi
watcher test asserts the approved contract directly: a standalone Kimi
task classifies unknown rather than matching its moon-phase spinner,
while Grok's isolated fallback still classifies only Grok. The
pi-signed detection cases clear ambient harness markers, fixing a
pre-existing failure where the running session's own CLAUDECODE
outranked the fixture's marker.

* fix: stop teardown from deleting a project's own Codex hooks file

An intermediate revision wired Codex through a firstmate-written
<worktree>/.codex/hooks.json, and teardown removed it alongside the
other generated wiring. The Codex wiring was dropped when its probes
came back unverified, so that removal now targets a file firstmate
never creates - and a project may legitimately track its own
.codex/hooks.json, which teardown would then delete from a pooled
worktree.

* fix: keep busy-record parsing from disturbing its sourcing caller

The record parser split fields with set -- under a temporary noglob,
which clobbers a sourcing caller's positional parameters and restores
glob expansion even when the caller had disabled it. The watcher, the
daemon, and the crew-state reader all source this library, so it now
reads fields with read -a, which never globs and never touches caller
state.

* docs: state exactly which Claude hook paths were reproduced live

The busy-state record listed all four wired Claude hooks in the source
column, which could read as a claim that every one fired during the
pass. UserPromptSubmit and Stop did; StopFailure and SessionEnd are
wired from hook names confirmed present in the installed binary, but
the abnormal turn ends they cover were not reproduced.

* test: let reset_fakes own the crew-state busy-text fixture lifecycle

The Grok fallback case set FM_FAKE_BUSY_TEXT and cleared it inline, so
the variable's lifetime was owned by one test rather than by the
shared reset that every other fake already uses.

* no-mistakes(review): Fix semantic busy-state lifecycle races

* no-mistakes(review): Make busy-state retirement idempotent

* no-mistakes(review): Enforce semantic state boundaries for status and injection

* no-mistakes(review): Restore harness-scoped away-mode busy guard

* no-mistakes(document): Refresh semantic busy-state documentation

* no-mistakes: apply CI fixes
kunchenguid and others added 6 commits July 30, 2026 21:27
…d#1356)

* fix(calm): resume working boat from frozen column across runs

Keep one extension-owned boat animation for the Pi session so settling
freezes column and direction, the next working period resumes there
without hidden-time jumps, and only a fresh session resets to the left edge.

* no-mistakes(review): Freeze Calm boat from last rendered state

* no-mistakes(document): Document Calm boat continuity contract
* fix(dispatch): judge candidate provider relations instead of rejecting them

Firstmate deterministically dropped supported Pi candidates in the
openai-codex family. bin/fm-auth-preflight.sh resolved a harness=pi tuple's
credential surface by constructing the source id `pi:<model-prefix>`, so
`pi + openai-codex/gpt-5.6-terra` looked for a `pi:openai-codex` source. That
source does not exist, because Pi's Codex family authenticates through the
Codex store quota-axi already lists as `auth-json`/`cli-rpc`. The tuple
returned `eligible=no reason=surface-unresolved` while the Pi catalog listed
the model and the Codex provider reported fresh, usable credentials with 64
effective percent remaining on its all-model scope.

The prefix construction was only ever valid where Pi holds its own credential
(`pi:xai`, `pi:kimi-coding`), which is why every previously configured Pi tuple
resolved and the defect stayed hidden until a Codex-family Pi model was
configured.

Retire dispatch eligibility from deterministic shell. The dispatching first
mate now establishes model support and provider family from each harness's
authoritative catalog, applies quota at the granularity the vendor supplies,
and shows that reasoning. Provider-level and all-model evidence bounds every
model established in that family; a named-model window bounds only its own
model. Missing model-level quota, a missing auth source, unmeasurable headroom,
and unmodeled authentication are disclosed uncertainty. Only concrete
contradictory evidence blocks a candidate.

Replace the preflight with bin/fm-vendor-auth-probe.sh, which keeps the
captain's approved bounded probe envelope without any routing knowledge: it
takes no harness, model, or provider, reads no quota, renders no verdict, and
holds only a fixed-argv safety allowlist. Its behavior suite proves the absent
identity surface, the untouched quota, the uniform exit status, the fixed argv
with stdin closed, and a real bound even when the configured bound is zero.

Also fixed along the way: a zero FM_*_TIMEOUT silently removed the hard bound,
the pinned Grok version had drifted to 0.2.117, and --changed selection refused
outright on any deleted bin/ script.

AGENTS.md section 4 and quota-array-dispatch own the corrected policy,
harness-adapters gets the catalog-responsibility correction, and
docs/verification/dispatch-auth.md records the 2026-07-30 evidence on
Pi 0.82.0, quota-axi 0.1.16, and grok 0.2.117.

* no-mistakes(review): Reject all-zero vendor probe timeouts
An overtaken pull request sat conflicted until someone noticed the
maintainer bot's comment: PR 1284 waited an hour, and PR 1267 was
overtaken six times in a day. Nothing in the armed merge poll reported a
conflict, so the only signal was a long-cadence recheck or a human read.

The poll now reports conflicts on the same validated path as merges. Its
single GitHub request carries state, mergeability, and the head commit
instead of state alone, so a conflicting open pull request emits
"dirty <head>" at no extra forge call. State is decided first and alone,
so merged and closed pull requests keep exactly the result they had
before. GitLab is untouched: plain glab field output carries no conflict
field, and reading one would need the JSON processor firstmate
deliberately does not depend on.

The watcher dedupes by conflict episode, keyed on that head commit, and
its wake carries the pull request URL so the worker can be steered to
rebase without a lookup first. The head is the key because poll silence
is ambiguous - clean, unknown, closed, and every error look identical -
so silence can never clear it, while a changed head does mean the branch
moved and went conflicting again. An untouched conflict re-surfaces no
more often than FM_PR_DIRTY_RESURFACE_SECS. The marker retires with the
rest of the poll artifacts at teardown and on merge.

GitHub can briefly report mergeability as unknown while it recomputes
after a base push. That is silence here and resolves on the following
sweep, rather than adding a retry and a timing dependency to a static
program; GitHub recomputes on the base push, so a sweep arriving minutes
later normally reads a settled value.

Changing the poll's bytes retires every armed check, which the existing
content-based migration already handles: the next --checks-safe run
quarantines the stale copies and rebuilds each poll from the recorded
pr=, so homes see one PR_CHECK_MIGRATION line and lose no armed watch.
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.

9 participants