Skip to content

merge: upstream sync round 1 of 7 through kunchenguid/firstmate@4f89f5b5 - #248

Merged
HelloWorldSungin merged 13 commits into
mainfrom
fm/fm-upstream-sync-2026-09-02-round-1
Sep 3, 2026
Merged

merge: upstream sync round 1 of 7 through kunchenguid/firstmate@4f89f5b5#248
HelloWorldSungin merged 13 commits into
mainfrom
fm/fm-upstream-sync-2026-09-02-round-1

Conversation

@HelloWorldSungin

@HelloWorldSungin HelloWorldSungin commented Sep 3, 2026

Copy link
Copy Markdown
Owner

Intent

Round 1 of 7 of the upstream sync. This takes the contiguous upstream first-parent prefix 60bedde5..4f89f5b5 into the fork as ONE merge commit, under the TRACK strategy in docs/fork-divergence.md. Fork base is 0c51a080, measured merge base is 60bedde5, endpoint is exactly kunchenguid/firstmate@4f89f5b5. No cherry-pick, no rebase, no squash, no force-push, no Sync fork button, and no upstream commit later than the endpoint.

Land this with bin/fm-pr-merge.sh <task-id> https://github.com/HelloWorldSungin/firstmate/pull/248 -- --merge. The script defaults to squash, and squashing here destroys the upstream parentage: the merge base would not advance and rounds 2 through 7 would re-present these same eight commits as new. The explicit --merge is load-bearing.

The PR base moved mid-round, and it was reconciled by merge, not rebase

Fork main advanced from 0c51a080 to b9b86fe3 (fork PR #246) while this round ran, and the branch stopped merging cleanly. The new base is brought in as a MERGE commit (1ff2d4b7), never a rebase: rebasing would replay and linearize the upstream merge this branch exists to create, which is the one thing the round must not do.

No upstream commit enters through that reconciliation. git log --oneline 0c51a080..origin/main is exactly one commit, #246, which carries no upstream content. The round's upstream endpoint is still exactly 4f89f5b5.

One path conflicted, bin/fm-claude-stop-autoarm.sh, and only in its header comment. Both bullets are kept, each matched to what the merged code actually does rather than to which side looked newer:

The suites both sides touch pass on the reconciled head: fm-claude-stop-autoarm, fm-turnend-guard, fm-guard-stale-banner, fm-control, fm-backend-zellij.

Applicability: every upstream first-parent change in this round

# Upstream change Outcome
1 22fa6ed9 kunchenguid#3140 markerless remote secondmate reconciliation Taken cleanly. bin/fm-secondmate-reconcile.sh, bin/fm-send.sh, bin/fm-bearings-snapshot.sh, docs/remote-secondmates.md and two suites auto-merged with no fork hunk in range.
2 99c1a0dc kunchenguid#3147 busy declared pause handed to the away-mode daemon Conflicted, resolved keeping both. See bin/fm-watch.sh below.
3 524994c8 kunchenguid#3121 stale submodule pin named behind a pooled slot refusal Taken cleanly. bin/fm-spawn.sh plus a new suite; the fork's spawn hunks (dashboard event emitters, agy gates) are elsewhere in the file.
4 d63b0e2f kunchenguid#3154 stale captain outcome re-emissions Taken cleanly. .pi/extensions/fm-branch-supervision.ts, bin/fm-operational-input.sh and their docs and suites auto-merged.
5 5953e9b5 kunchenguid#3155 declared wait kept on the pause cadence Conflicted, resolved keeping both. See .agents/skills/afk/SKILL.md, bin/fm-supervise-daemon.sh, docs/architecture.md and docs/configuration.md below.
6 10b93b2c kunchenguid#3156 Claude auto-arm recovery from hung claims Taken cleanly, with one fork test adapted. bin/fm-claude-stop-autoarm.sh, bin/fm-turnend-guard.sh and bin/fm-wake-lib.sh auto-merged whole; tests/fm-dashboard-events.test.sh needed adapting, below.
7 7ee0c192 kunchenguid#3064 verify the real GitHub merge outcome Taken whole, and it CONTRADICTS open fork PR #241. See the contract section below. AGENTS.md, docs/scripts.md and tests/fm-pr-merge.test.sh conflicted and were resolved.
8 4f89f5b5 kunchenguid#3184 duplicate captain outcome reports (endpoint) Taken cleanly. .pi/extensions/fm-branch-supervision.ts and its suite auto-merged.

Nothing in this round was dropped, and nothing later than 4f89f5b5 entered it: git log --first-parent 60bedde5..4f89f5b5 is exactly these eight, and the merge commit's second parent is 4f89f5b5e235469d32c037b7792d6dba5bdc272d.

Mechanical proof that neither side lost content: for every file the merge touches, every line either side ADDED against the merge base is present in the merge result, except lines I deliberately rewrote to combine the two intents. Those exceptions are exhaustively:

  • bin/fm-pr-merge.sh: 0 absent from either side (206 fork-added lines and 375 upstream-added lines all present).
  • bin/fm-supervise-daemon.sh, bin/fm-watch.sh, .agents/skills/afk/SKILL.md, AGENTS.md, docs/architecture.md, docs/configuration.md, docs/scripts.md: only the combined sentences, plus upstream's pause_secs=${FM_PAUSE_RESURFACE_SECS:-...} read that the fork's per-iteration pause_resurface_window supersedes, and the daemon's 0) busy arm that fix(bin): keep a declared wait on the pause cadence under a busy pane or enriched wedge kunchenguid/firstmate#3155 exists to delete.
  • tests/fm-pr-merge.test.sh: only the fork's matrix letters, which are re-lettered (ax) to (bf) after upstream's (aw); every case body from both sides is present.
  • tests/fm-dashboard-events.test.sh: only the three lines of the normalization I adapted, described below.

The merge-proof contract contradiction - DECIDED, and it must not be re-litigated

7ee0c192 (kunchenguid#3064) and open fork PR #241 give two opposite answers to one question, and only one can survive. This collision was predicted at round-1 intake, surfaced from this PR, and decided by firstmate on 2026-09-03: the fork keeps the stricter contract.

A future round must not silently resolve this by taking upstream's side. The merged code in this PR carries upstream's permissive read, because a faithful merge of this prefix is what round 1 is, and that merged code is NOT evidence that the fork chose upstream's contract. The decision, its three reasons, and its retirement condition are recorded in docs/fork-divergence.md under "Stricter merge-proof contract". A collision re-litigated every round because nobody wrote the decision down is the exact failure mode that ledger exists to stop.

Upstream (in this merge): a merge-queue entry IS a proved outcome. bin/fm-pr-merge.sh prints verified: <url> is queued (...) and exits 0, and a queue-required refusal actively recommends --auto as the retry (retry with: ... -- --auto --<method>).

Fork PR #241 (open, fm/fm-stale-base-check-before-merge at acc67921): "GitHub's live PR state must prove that neither a merge queue nor auto-merge can defer execution before the merge command runs", and "A queued or pending result is a refusal". It refuses --auto outright.

The behaviour this merge actually has, proven by reading the merged function rather than by reasoning about intent - bin/fm-pr-merge.sh, the github) arm:

    if [ "$FM_PR_GITHUB_MERGED" = true ]; then
      printf 'verified: %s is merged (state=%s, merged=%s, isInMergeQueue=%s)\n' ...
    elif [ "$FM_PR_GITHUB_QUEUED" = true ]; then
      printf 'verified: %s is queued (state=%s, merged=%s, isInMergeQueue=%s)\n' ...
      exit 0
    else
      github_report_forge_output "$merge_output"
      github_report_unmerged_outcome
      exit 1
    fi

A merge-queue enqueue is accepted as verified: queued, exit 0. Upstream's contract is what is merged here. The fork's work-item lifecycle block below that arm still runs only on a proved landing, because the queued path exits before reaching it.

AGENTS.md section 7 states neither contract today, and this merge gives it upstream's: the sentence now reads "so merge metadata and any eligible explicitly recorded work-item lifecycle are handled and an unproved merge is refused instead of reported as landed" - the fork's clause and upstream's, both kept.

The decision and why, recorded here and in the ledger:

  1. This fleet has no merge queue. Fork main carries no branch protection and no rulesets, verified against the API on 2026-08-14, so there is no queue for an enqueue to be deferred into. Upstream's design accommodates a workflow this fork does not have, so adopting it buys nothing while widening what counts as proof.
  2. Reporting an unproved merge as landed has actually happened here, and the standing practice built on that incident is to read the landed commit back rather than trust a merge return. Accepting a queued enqueue as proof moves against the discipline the rest of the fleet is built on.
  3. Conforming to the more protective option needs nobody's permission; relaxing one does. Upstream's design is the relaxation, so it is the option that would have needed a captain decision. Keeping the stricter behaviour does not.

Retirement condition: if this fork ever adopts branch protection with a merge queue, re-examine the divergence rather than defend it, because reason 1 stops holding the moment a real queue exists.

What happens next, and what does NOT happen in this PR: fork PR #241 carries the stricter implementation. It is open and held on a separate matter, and this sync round deliberately does not rebase or re-express it. The reconciliation happens when #241 lands against this merged base. Until then bin/fm-pr-merge.sh and AGENTS.md section 7 state upstream's contract, and the ledger entry says so explicitly rather than claiming a difference the code does not yet have.

tests/fm-pr-merge.test.sh conflicted for the same reason and is resolved to assert what the merged code actually does. No assertion was weakened or deleted - see the test evidence below.

Per-file reasoning for every changed contract file

The eight paths that conflicted textually, plus the one that needed the same treatment without conflicting.

AGENTS.md

Both sides edited the guarded-merge sentence in section 7. The fork's clause is the work-item lifecycle; upstream's is the unproved-merge refusal. They are compatible, so both are kept in one sentence. The hard-rule block did not conflict this round: hard rule 2 ("Never write to a third-party upstream repository") is still rule 2 and tests/fm-agents-hard-rules.test.sh passes.

.agents/skills/afk/SKILL.md

Same bullet, edited additively on both sides. Upstream adds that a declared wait is tracked "whether its pane reads idle or busy", that this outranks an enriched possible-wedge reason, and that the window ages against the crew's own latest status line. The fork adds that the window is its own (self-widening) and that each unchanged recheck doubles it up to FM_PAUSE_RESURFACE_MAX_STREAK. All five facts are kept; the only line dropped is the fork's earlier phrasing of the sentence upstream rewrote.

bin/fm-supervise-daemon.sh

The load-bearing resolution. kunchenguid#3155 exists to stop pane busy state ending a declared wait, so housekeeping's 0) arm (busy -> drop the marker) is deleted, exactly as upstream requires, and the fork's $streak_file cleanup moves onto the surviving 2) (endpoint gone) arm - so the streak still shares the marker's lifetime and cannot be orphaned. Upstream's fixed pause_secs=${FM_PAUSE_RESURFACE_SECS:-...} read is dropped in favour of the fork's per-iteration pause_resurface_window "$(pause_streak_count ...)", which already sits inside the loop. The header, the env table, and the two block comments are combined so upstream's "the crew's own status line is the authority" and the fork's run-progress hold and self-widening cadence are both stated once.

bin/fm-watch.sh

kunchenguid#3147 restructures busy_turn_bound_check so the declaration is read BEFORE the afk branch, and adds a declaration-keyed one-shot handoff to the away-mode daemon. That whole branch is adopted verbatim; the fork's sixth <hold-count-file> parameter (the run-progress wedge hold) is threaded back into the signature, the local list, and the wedge_timer_check call below the new branch. Every one of the eight wedge_timer_check and two busy_turn_bound_check call sites still passes six arguments. The header keeps the fork's "before any such wake arrives" wording, which the fork applies consistently at three places, with upstream's afk clause folded in.

docs/architecture.md

Two paragraphs, both additive on both sides. The fork's run-progress hold rationale and dead-agent short-circuit exclusion sit beside upstream's away-mode handoff paragraph; upstream's "declared-wait" vocabulary and its two new status-line-authority sentences are taken, with the fork's crew_wedge_progress clause kept on the away-mode sentence.

docs/configuration.md

Upstream changed only FM_PAUSE_RESURFACE_SECS; the other three lines in the conflicted block are base text the fork had rewritten (FM_STALE_ESCALATE_SECS, FM_BUSY_TURN_MAX_SECS, FM_SECONDMATE_WAKE_STALL_SECS) plus two fork-only keys (FM_PAUSE_RESURFACE_MAX_STREAK, FM_SECONDMATE_WAKE_IDLE_STALL_SECS). The fork's block is kept whole and upstream's ageing clause is folded into the one line it actually changed.

docs/scripts.md

One table row, one clause from each side, both kept.

tests/fm-pr-merge.test.sh

Reconstructed rather than hand-patched, because git interleaved two large additive rewrites. Upstream's file is the base, the fork's helper functions and FM_CONFIG_OVERRIDE are re-inserted, and the fork's matrix entries are re-lettered (ax)-(bf) after upstream's (aw). The result is verified by test-function inventory and by assertion count, below.

tests/fm-dashboard-events.test.sh - conflicting in substance, not in text

Git merged this cleanly and the result was WRONG, which is why the round runs the full suite rather than trusting a clean merge. kunchenguid#3156 adds the claim identity as a second line of state/.claude-autoarm-epoch, and that identity is a process start time plus a home-scoped command line - two separate processes cannot share it. The fork's emitter-equivalence case compares two homes' ledgers with only owner_pid= and updated_at= normalized, so it started failing on a difference that is not a difference in what the auto-arm DECIDED:

- linux-starttime=308908885 cmdline-hex=...6175746f61726d2d616374696f6e61626c652d62617265...
+ linux-starttime=308908895 cmdline-hex=...6175746f61726d2d616374696f6e61626c652d77697468...

The identity line is now normalized too, and because normalizing it could hide a claim that recorded NO identity, its presence is asserted separately before the comparison runs. The sed is anchored per line (1s/, 2s/), so a differing outcome, epoch or marker on line 1 still fails; verified by driving the inputs apart by hand.

One attributed test fix, and it is NOT part of the merge

Commit 1f1c69fe, a separate non-merge commit after the merge commit and the ledger commit. The merge commit itself carries only the merge and its conflict resolutions. This is called out with the same visibility as an upstream applicability row because a reviewer must not have to guess where it came from.

Status: PRE-EXISTING, exposed by running the full suite under load. NOT introduced by this merge. Proven, not asserted:

  • tests/fm-pi-watch-extension.test.sh is not in this merge's changed-file set (git diff --name-only 0c51a080 42765150 does not list it).
  • Its blob is identical at the fork parent and at the merge commit: 0c51a080:tests/fm-pi-watch-extension.test.sh and 42765150:tests/fm-pi-watch-extension.test.sh are both 5ad729bb10af7089c6aa79d462288937f54a5028. Upstream's blob (c7b708d0) equals the merge base's, so upstream never touched the file either; the fork diverged it before 60bedde5.
  • The exact racing lines are present verbatim at 0c51a080:2181-2185.
  • .pi/extensions/fm-primary-pi-watch.ts, the extension the suite drives, is untouched by this merge; the only .pi/ files it changes are fm-branch-supervision.ts and lib/fm-operational-input.ts, which belong to a different suite.

The defect. test_pi_process_exit_cleanup_stops_arm_child waits up to 20s for the cleanup log to record TERM, then asserts immediately that the child is gone. The child writes that log line from inside its own TERM trap, BEFORE its exit 0:

trap 'printf "%s\n" "$$" >> "$FM_CLEANUP_LOG"; exit 0' TERM

So the line proves the signal arrived, not that the process was reaped. Between the two the trap body must finish, bash must tear down, and the kernel must release the pid. The fix gives the death its own bounded wait, on the same 20s budget and the same sleep 0.02 shape as the delivery wait directly above it.

Observed: not ok - Pi arm child 537033 survived process-exit cleanup, during the full-suite sweep only. The same suite passed standalone before the sweep and eight consecutive times after the fix.

Disconfirming check, so this is a fix and not a false pass. The fixture's trap was edited to log TERM and deliberately NOT exit, and the case still failed: not ok - Pi arm child 1273671 survived process-exit cleanup. The assertion still catches a genuinely surviving child; only the race is gone.

Family: this is a residual instance of #158, not of the watcher stop-path signal-delivery defect. #158 was remediated by fork PRs #166 ("make watcher arm assertions event-driven") and #167 ("add event-bounded path absence helper"), which swept wall-clock assertions into event-bounded ones - in this very file, among others. This case is the same defect class in the same file, missed by that sweep. It is not the same underlying thing as a bash signal-delivery defect in the watcher stop path: the delivery assertion PASSED here, so the trap parsed, fired, and wrote its line. Only reaping had not completed. These should not be fixed twice under two names, and they are not the same name.

Deliberately not done: tests/lib.sh has fm_test_wait_absent from #167 for path absence, but no sibling for process death. Adding one is the natural follow-up if more process-death races surface. This round keeps the local loop that matches the sibling wait five lines above it rather than widening a shared test surface inside a merge PR.

Divergence survival - behaviour, not file presence

Every active entry in docs/fork-divergence.md was re-verified by running the suite that pins it against the merge result, not by checking that its files are still there.

Ledger entry Evidence on the merge result
Agy crew adapter tests/fm-agy-adapter.test.sh passes.
Pinned ShellCheck download retry budget tests/fm-lint.test.sh passes.
LLM quota sidecar tests/fm-quota-sidecar.test.sh passes.
Scout completion gate reopened by a firstmate steer tests/fm-send-strict.test.sh passes; bin/fm-send.sh took kunchenguid#3140's remote-delivery change with the reopen intact.
Pre-move crash fixture tests/fm-backlog-handoff.test.sh passes.
Watcher restart hand-over tests/fm-watcher-lock.test.sh passes.
Run-progress wedge hold tests/fm-watch-triage.test.sh and tests/fm-daemon.test.sh pass; busy_turn_bound_check still carries the sixth holds_file argument through to wedge_timer_check beside kunchenguid#3147's new branch. Ledger updated with this second collision.
Herdr pre-Enter footer read on a native working baseline tests/fm-backend-herdr.test.sh passes.
Remote job worker descendant reaping tests/fm-remote-job-orphan-reap.test.sh passes.
Pi away-mode supervision standby tests/fm-pi-watch-extension.test.sh passes.
Fork-local no-mistakes compliance-gate event scope tests/fm-no-mistakes-required-gate.test.sh passes; .github/workflows/no-mistakes-required.yml still has types: [opened, synchronize, reopened] with no edited, on the per-PR concurrency group.
Upstream tracking mechanism docs/fork-divergence.md and the sync tooling are untouched by upstream this round.
Repository-local validation evidence .no-mistakes.yaml store_in_repo re-read directly and still false. This file does not conflict textually, so it is re-checked every round rather than trusted to a clean merge.
Upstream-read-only posture in shared tracked docs tests/fm-agents-hard-rules.test.sh passes; hard rule 2 is still rule 2. CONTRIBUTING.md has no restored instruction to clone the parent or point origin at kunchenguid/firstmate. README.md takes only kunchenguid#3154's one-line Calm wording change; the fork's install-source divergence (#172) is untouched.
GBrain per-home knowledge memory tests/fm-gbrain-capture.test.sh and tests/fm-recall.test.sh pass. tests/fm-gbrain-readonly-e2e.test.sh is its declared opt-in gate skip (FM_GBRAIN_LIVE_E2E=1), unchanged by this round.
Fleet dashboard and agent-event instrumentation tests/fm-dashboard-events.test.sh passes after the ledger-normalization adaptation described above; that adaptation is now recorded in the ledger so a future round does not restore the un-normalized comparison.

Ledger changes this round

Commit c9b761e9:

Added "Stricter merge-proof contract" (commit db69da0d), recording firstmate's 2026-09-03 decision on the kunchenguid#3064 collision, its three reasons, the gap between that decision and the code this round merges, and the condition that would retire it.

No entry was retired. Every conflict was resolved by keeping both intents or by taking upstream's contract; the one deliberate divergence added is the decision above, which the code catches up to when #241 lands.

Parked branches

docs/fork-divergence.md "Parked branches outside the fork baseline" lists five deliberately unlanded branches: fm/fm-afk-injection-wedge, fm/fm-crew-state-blind-during-fix-round, fm/fm-parked-decision-stale-noise, fm/fm-subagent-model-routing-guard, fm/fm-vault-drift-check. None entered this round. The branch is exactly 0c51a080 plus one merge of 4f89f5b5 plus one ledger commit, so nothing else could have: git log --first-parent 0c51a080..HEAD is two commits.

Test and lint: baseline versus final

Baselines were established on BOTH merge parents in isolated temporary clones before the merge commit existed, so every failure in the final run is attributed to a parent or to this merge. Command in all three trees: bin/fm-test-run.sh --all --exclude-family real-herdr-gated, and CI=true bin/fm-lint.sh for the full canonical lint set.

Lint

Tree Result
Fork parent 0c51a080 clean - ShellCheck 0.11.0, actionlint 1.7.12, 3 workflow files valid
Upstream parent 4f89f5b5 clean
Merge result clean

Tests

Tree Suites run Failed Gate-skipped
Fork parent 0c51a080 201 2 29
Upstream parent 4f89f5b5 153 7 23
Merge result at 1ff2d4b7 201 2, both pre-existing on the fork parent 29

The full run is on 1ff2d4b7. The pushed head is db69da0d, which adds exactly one commit on top: the merge-proof decision ledger entry, a docs/fork-divergence.md-only change with no code or test in it. CI on the pushed head is the check of record for that commit, and it is reported below.

No regression. The merge result's failure set is exactly the fork parent's failure set. Nothing new is red, and nothing that was green went red.

The merge does not inherit the upstream parent's failures either. Five suites are red on 4f89f5b5 and green on the fork parent - fm-bootstrap, fm-on, fm-public-followup, fm-remote-doctor, fm-session-start - and all five are green on the merge result, so the fork's own wiring survives rather than being displaced by upstream's tree.

An earlier full run on 1f1c69fe (this head minus the base reconciliation) gave the same two failures, plus one suite, tests/fm-captain-hold-lifecycle.test.sh, that was interrupted by an external signal rather than failing: exit 130 (SIGINT) after 1316ms against a normal 32s, its last two cases reported ok, no not ok line, and no trailing completion marker - exactly the missing-final-line signal that convention exists to make visible. It was re-run three times green on that head, and it is green in the final run above.

Attribution of every failure in the final run

Two lines are red in the final run. Neither is a regression introduced by this merge; both are red on the fork parent too.

Suite Verdict
tests/fm-pi-branch-extension.test.sh Pre-existing, red on BOTH parents. #221, detailed below.
tests/fm-test-run.test.sh Pre-existing, red on BOTH parents. #220, detailed below.

The honest claim for this PR is "no regression against the fork parent", not "all green". A green-sounding claim that a spot check falsifies costs more than the accurate one, so the two known failures are named rather than papered over.

tests/fm-pi-branch-extension.test.sh - #221, fixed upstream, arriving in round 2

Fails identically on the fork parent 0c51a080, the upstream parent 4f89f5b5, and this merge, with the same assertion in all three: Calm-off ToolExecutionComponent rendering differs from Pi stock. It is a pi-version drift on this host, not a repo-red state - fork CI is green on main, and the most recent CI run on the fork's own fm/fm-stale-base-check-before-merge branch passed.

It is fixed upstream and arrives complete in sync round 2 as change f66be0f8. The upstream reconciliation proved that on this host: at d22318ea the extension renders identically to stock Pi 0.84.2 for both a 2-line and a 12-line fixture, where our HEAD differs on both. The fix rides the whole pending .pi/extensions range and is not cherry-pickable, which is why it is round 2 work and deliberately not this round's.

So this red line is a tracked item with a landing date, not an unexplained blemish, and nothing in round 1 should be held for it.

tests/fm-test-run.test.sh - #220, host environment

ruby is required to parse .github/workflows/ci.yml as YAML. The suite hard-fails when ruby is absent instead of skipping, so it is red on any host without a system ruby. Pre-existing on the fork parent, untouched by this round, and green in CI where ruby is present.

tests/fm-pr-merge.test.sh - proof the reconstruction lost nothing

The file was rebuilt from upstream's version plus the fork's additions, so it needs stronger evidence than "it passes". Assertion counts across the four trees:

Tree ok - assertions
Merge base 60bedde5 35
Fork parent 0c51a080 53
Upstream parent 4f89f5b5 61
Merge result 79

53 + 61 - 35 = 79 exactly. Every assertion unique to either side is present once, none is duplicated, and none is lost. The test-function inventory agrees: the only name absent relative to the union of both parents is test_records_pr_and_head_before_merging, which upstream RENAMED to test_verified_merge_records_pr_and_head with its four assertions byte-identical, and which upstream then supplemented with test_metadata_recorded_before_merge_call proving pr= is recorded before the forge call that can land the merge.

The same inventory check was run on every other suite the merge touches. One other name is absent: test_arming_claim_is_never_reclaimed, which kunchenguid#3156 deliberately removes because its premise ("an owner still arming is never reclaimed, however long the cycle runs") is exactly the hung-claim behaviour that change fixes. Upstream replaces it with eleven cases including test_arming_claim_with_fresh_beacon_is_never_reclaimed, test_fresh_arming_claim_with_stale_beacon_is_never_reclaimed and test_stuck_generation_claim_is_superseded_and_rearms, so the surviving half of the old guarantee is pinned more precisely than before. No assertion was weakened or deleted by this merge.

Pushed head and mergeability

  • Pushed head: db69da0d4368cb80d6ce15d1c8c87752f4828a27
  • Second parent of the merge commit: 4f89f5b5e235469d32c037b7792d6dba5bdc272d (kunchenguid/firstmate@4f89f5b5)
  • First parent: 0c51a080d8e7d0963465c9395eef7170704c1045 (fork main at dispatch)
  • git log --first-parent 0c51a080..HEAD is five commits: the upstream merge, the ledger update, the one attributed test fix, the base reconciliation merge, and the merge-proof decision ledger entry. No other commit can have entered.
  • Base at review time: b9b86fe3 (fork PR fix(bin): arm supervision for pending wake queues and bind zellij endpoints to the recorded tab #246)
  • Mergeability: clean. git merge-tree --write-tree origin/main HEAD reports no conflicts against origin/main at b9b86fe3, re-checked after the push.

The one red check, by design

This round ships direct-PR, not through no-mistakes. The no-mistakes pipeline rebases onto origin/main, which would replay and linearize a merge-only branch and destroy the upstream parentage this PR exists to create. The fork's compliance workflow still requires a no-mistakes signature, so PR must be raised via no-mistakes is expected to be RED on this PR and only that check. Every other required check must pass; if any other check is red, this PR is not ready.

CI result on this PR: 16 passed, 1 failed, and the 1 is the expected one

gh-axi pr checks 248 --repo HelloWorldSungin/firstmate:

Check Result
PR must be raised via no-mistakes fail, by design (see above)
Lint pass
Test coverage guard pass
Behavior portable parallel 1, 2 pass
Behavior portable serial 1 through 8 pass
Behavior tests (Herdr) pass
Stock macOS Bash snapshot compatibility pass
Repo invariants pass
Behavior timing aggregate pass

Every other required check passes. This also empirically confirms the two local failures are host-local, not repo-red: tests/fm-pi-branch-extension.test.sh and tests/fm-test-run.test.sh both run in the portable serial lane, and all eight serial shards are green in CI.

How to land it

bin/fm-pr-merge.sh <task-id> https://github.com/HelloWorldSungin/firstmate/pull/248 -- --merge

Not squash. A squash would commit the merge-result tree as a single-parent commit, the merge base against upstream would stay at 60bedde5, and rounds 2 through 7 would each re-present these eight commits.


🤖 Generated with Claude Code

https://claude.ai/code/session_01DH3SATKCsSPd7BDfj9DBJ2

kunchenguid and others added 13 commits August 26, 2026 18:06
…#3140)

* fix(bin): stop dropping reconcile nudges for markerless remote secondmates

A persistent remote secondmate's parent-side state/<id>.meta never carries
spawn_gen: bin/fm-spawn.sh's spawn_remote_secondmate() is its sole writer and
never writes one, because that incarnation identity does not apply to a
remote route. fm-secondmate-reconcile.sh's row filter required a non-empty
spawn_gen matching an identifier regex, so every such row was silently
dropped before the per-row loop ever saw it: no sent/stale/failed line, no
cooldown record, nothing sent, and no trace of why.

Give a legitimately markerless persistent remote secondmate a safe substitute
identity - its recorded remote_host - instead of weakening the spawn_gen
check for rows that do have a generation:

- bin/fm-secondmate-reconcile.sh: carry host through the row projection for
  both fm-fleet-snapshot.v1 and fm-bearings.v1 documents, and admit an empty
  spawn_gen instead of filtering the row out. A new revalidate_identity()
  compares the sampled spawn_gen against current metadata when one was
  sampled (unchanged), or the sampled host against the metadata's
  remote_host when none was sampled and the metadata still carries no
  spawn_gen of its own. A row with neither a spawn_gen nor a host has no
  safe identity at all and fails loudly instead of vanishing, exactly the
  visibility the original bug lacked.
- Rows now join on the ASCII unit separator rather than @TSV: bash's
  IFS-whitespace read collapses consecutive tabs, which would have silently
  dropped a legitimately empty field again.
- bin/fm-bearings-snapshot.sh: thread host through the secondmate_reconcile
  projection so the fm-bearings.v1 path (the one bearings itself feeds to
  the reconcile hook) carries the same substitute identity.
- tests/fm-secondmate-reconcile.test.sh: end-to-end coverage through the real
  remote transport (fm-on.sh + fm-remote-secondmate-control.sh against a
  genuinely seeded remote home) for a markerless mate nudged once per
  cooldown window, a stale/replaced remote route refused exactly like the
  existing local spawn_gen case, and a row with no identity at all failing
  loudly rather than being swallowed.

* no-mistakes(review): Enforce markerless remote host identity during final delivery

* no-mistakes(document): Document markerless remote reconciliation safety
…decorated (kunchenguid#3147)

* fix(watch): hand a busy declared pause to the away-mode daemon undecorated

While away mode is active the daemon owns triage and the watcher reverts to
one-shot, handing over plain wake identities the daemon classifies itself. The
busy-turn bound was the one stale path that did not: with afk active it ran the
wedge timer, so the daemon received a wake already decorated as a possible wedge.

That decoration outranks the daemon's own verdict. handle_wake escalates an
enriched wedge reason before its pause classification can apply, so a crew that
declared the wait itself - a `paused:` external wait or a verified captain-held
transfer holding a live foreground call - was wedge-escalated once per
FM_STALE_ESCALATE_SECS for as long as the wait lasted, the escalation count
climbing into demand-deep-inspection on a pane nobody needed to inspect.
Measured on the pre-fix tree, five consecutive re-arms produced five escalations.

busy_turn_bound_check now reads the declaration before the afk branch: away mode
hands off the plain window identity, one-shot per distinct stale hash, leaving
normal-mode pause bookkeeping unwritten because the daemon owns it there. The
daemon then classifies the wait itself and self-handles it on the long cadence.
Normal-mode behavior is unchanged, and lifting the declaration still restores the
busy-pane wedge escalation on the same pane.

The regression covers all three: the undecorated handoff with no wedge timer or
escalation counter, the one-shot on re-arm that the escalation ladder used to
climb, and the restored wedge escalation once the declaration is lifted.

* no-mistakes(review): key afk busy-pause handoff on declaration, clear wedge state

* no-mistakes(document): docs: scope away-mode busy-bound handoff to declared waits

* no-mistakes(document): docs: note afk busy-bound handoff in watcher header

---------

Co-authored-by: Talon Stark <talonstark@gmail.com>
…unchenguid#3121)

* fix(bin): explain a pooled slot's stale submodule refusal

A pool slot whose submodule pin moved is refused with "is not clean; refusing
to discard uncommitted work", while the operator's own `git status` in that slot
reads clean. The message names no submodule, no pin, and no remedy, so the
refusal is unreadable and the slot looks wedged for no reason. That is the
failure that jammed three slots in a row when a submodule pin moved.

The refusal itself was never the bug and is unchanged: the gate still refuses,
and still touches nothing. It now distinguishes the one case it can prove and
says what it found - the submodule, the pin the slot has, the pin the base
records, and the command that clears it.

The diagnosis is deliberately conservative, because ` M <path>` alone cannot
tell a stale pin from real work. An entry is reported as stale only when every
reported entry is a gitlink whose submodule is internally clean and whose
recorded pin actually differs. A submodule holding uncommitted work, untracked
files, or an unpushed commit therefore keeps the original uncommitted-work
refusal, even when its pin is also stale - the remedy command would be wrong
there, and the conservative refusal is the safe answer.

Nothing is converged, synced, initialized, or deleted. There is no new failure
path: a slot that launched before still launches, a slot that refused before
still refuses, and projects that configure a submodule `ignore` are read exactly
as before. Paths are read with core.quotePath=false so a non-ASCII submodule is
named rather than falling back to the unreadable message.

Tests keep the reproductions that prove the message is accurate: the stale-pin
diagnosis (which fails against the previous refusal), work inside a submodule
still refused as uncommitted work, and a stale pin carrying real work refused
conservatively rather than called stale. Each asserts the slot is left untouched.

* no-mistakes(review): require remote containment before calling a submodule pin stale

* fix(bin): stop printing a remedy the containment check cannot stand behind

The stale-pin diagnosis printed `git submodule update --checkout` as the command
that clears the slot. The containment check behind it reads local refs only and
never fetches, because this gate has to stay usable offline. A remote-tracking
ref that has gone stale - its upstream branch deleted or force-pushed, and never
pruned - still reads as containment, so a commit that is really unpushed can look
contained and that command would move the submodule off it.

Naming the submodule and both pins is the whole point of the diagnosis: it turns
"is not clean", on a slot whose own `git status` reads clean, into a statement of
which submodule drifted and where it drifted from. The operator can choose the
remedy from that, seeing the whole picture. Printing an instruction that rests on
a judgement which can be fooled is worse than printing none, so it is dropped.

The limitation is now stated where it applies, in the script header and beside
the check itself, rather than left for a reader to discover.

No fetch is added: the gate stays offline-safe by design. Nothing else changes -
the same conditions are refused, the slot is still never touched, and a submodule
carrying real work or an unpushed commit still keeps the conservative
uncommitted-work refusal.

* no-mistakes(review): bound submodule containment probe to first commit
* fix(pi): type captain supervision outcomes so main relays them

A captain-relevant branch outcome reached main as a bare user message with
no marker of origin or required action, written in main's own captain-facing
voice, landing in a tail that often already held several such notes. Pi keeps
only a custom message's content when it builds the provider request, so
customType and display never reach the model and content was the only place
that identity could live.

Main could not tell an incoming outcome from its own earlier answer and
sometimes re-emitted that answer instead of relaying the outcome, losing it.
Measured against real Pi 0.84.1 on openai-codex/gpt-5.6-sol: 6 failures in 24
turns, rising to 3 in 6 once one stale answer was already in the tail, which
is how one captain conversation saw six identical messages in a row. The same
scenario with the outcome typed failed 0 times in 14 turns.

Wrap only the captain-verdict note in the branch-outcome operational kind
owned by bin/fm-operational-input.sh. Delivery is otherwise unchanged: still
display: false, still one triggerTurn follow-up, so the turn remains the
single captain-visible outcome and no hidden note is ever shown twice.
Routine notes stay plain because their renderer reads the glyph off the front
of that same string. An outcome that cannot be encoded degrades to the same
instruction as plain text rather than being lost, matching this file's stated
failure direction.

The existing assertions could not catch this: they pin the sendMessage
options and never look at what main receives. Add a portable regression that
classifies the delivered payload with the real protocol executable, and a
live guard that runs the real Pi SDK's own convertToLlm to prove content is
the entire model-visible payload.

* no-mistakes(document): Document typed Pi captain outcomes
… or enriched wedge (kunchenguid#3155)

* fix(bin): keep a busy pane from retiring a still-declared wait's window

The away-mode daemon's pause re-surface recheck (housekeeping step 2b) read a
busy pane as "the crew resumed" and dropped the declared-wait marker, without
re-reading that the crew's own latest status line still declared the wait.

That inference is not safe, because a declared wait can legitimately hold a pane
busy: a worker sitting on a long foreground call keeps that call live for as long
as the wait lasts. The marker is then cleared while the declaration still stands,
and migrate_watcher_pause_markers recreates it with a fresh timestamp on the very
next tick, so the window restarts forever and the wait never matures into its one
bounded recheck.

Away mode makes that terminal. Since the watcher half landed, a busy pane under a
declared wait is handed to the daemon exactly once per declaration and never woken
again while the declaration stands (bin/fm-watch.sh, busy_turn_bound_check), so
this recheck is the only thing left that can re-surface the pane at all. Measured
end to end on a throwaway state root, away mode active, a pi pane busy past
FM_BUSY_TURN_MAX_SECS, status still `paused:`, over six PAUSE_RESURFACE_SECS
windows: 0 captain-facing rechecks before this change, 6 after - one per window,
with the marker reset each time.

The fix drops only the busy arm of the 2b probe, leaving it an endpoint-readability
check: exit code 2 still means the capture failed, so the endpoint is gone and the
marker goes. The loop head above already drops the marker the moment the status
line stops declaring the wait, so nothing else is needed to end the routing, and
the reconcile path runs before the probe ever reads a pane.

tests/fm-daemon.test.sh: test_housekeeping_paused_resumed_cleared pinned the old
inference on purpose - its fixture's status line still read `paused:` while the
pane was busy, and its comment read "A pause whose pane became busy again (the
crew resumed)". Its fixture now resumes the way a crew actually resumes, by
appending a non-declaring status line, and it asserts its own busy verdict first
so it cannot silently decay into the idle-pane case that
test_housekeeping_paused_unpaused_cleared already covers. What it pins is now the
inverse guard: a busy pane must not GATE the clear either, so an over-correction
that kept the marker alive whenever the pane is busy would fail it.

test_housekeeping_busy_declared_wait_matures_its_window is the new regression, over
both declaration forms. It asserts the busy verdict, then that ticks inside the
window neither escalate nor let the marker be recreated with a fresh timestamp,
then exactly one recheck past the window named for the right human and never a
wedge, then silence on the next tick inside the reset window. It fails on
unmodified main with "produced 0 escalations past its window, expected exactly
one".

Refs kunchenguid#3149

* fix(bin): let a declared wait outrank an enriched wedge escalation

handle_wake classifies a stale wake through classify_stale, which returns a
`pause` verdict for a crew whose latest status line declares an external wait or
a verified captain-held transfer. It then threw that verdict away whenever the
wake reason matched `idle *s, possible wedge, escalation *`, so the watcher's
enriched wedge decoration outranked the crew's own declaration and a healthy
declared wait was escalated once per FM_STALE_ESCALATE_SECS for as long as the
wait lasted.

The enriched reason earns its precedence over the daemon's cheaper status-log
absorption honestly - it carries the watcher's escalation count and its explicit
"do not re-absorb on the run-step/pane state alone" demand. A `pause` verdict is
not run-step or pane state. It is the crew's own declaration that this pane waits
by design, which is precisely the question the wedge timer cannot answer for
itself, so it is the one verdict that decoration must not override.

The two classifications genuinely disagree in steady state rather than only in a
race: a crew that declares `paused:` while its no-mistakes run is still attributed
to its code reads `working` to the watcher's pause_state_class, so the watcher
takes the wedge timer while the daemon's classify_stale reads the status log and
correctly returns `pause`.

The wait stays bounded, not silenced. Absorbing to the pause action records the
declared-wait marker and drops wedge aging, and housekeeping (2b) then owns the
re-surface, so the pane still reaches the captain - once per PAUSE_RESURFACE_SECS
as an explicit "recheck whether the wait still holds", instead of once per
FM_STALE_ESCALATE_SECS as a possible wedge. Measured on a throwaway state root
over five wedge cadences for one declared wait: 5 escalations climbing to
demand-deep-inspection before this change, 0 after, with the one bounded recheck
still delivered.

tests/fm-daemon.test.sh: test_stale_diagnostic_wedge_survives_busy_housekeeping's
`paused` case pinned the old precedence on purpose, asserting exactly one
escalation carrying the demand-deep-inspection payload. That case now asserts the
pause cadence instead - no escalation inside the window, pause tracking recorded -
while the `working` and `prior-terminal` cases keep asserting the enriched wedge
verbatim, so the override itself is still pinned everywhere it is correct.

test_enriched_wedge_under_declared_wait_uses_pause_cadence is the new regression.
It asserts the fixture's own classifier verdict is a pause first, so the case
cannot go vacuous, then drives four consecutive wedge-cadence deliveries in both
the plain and demand-deep-inspection forms through the real handle_wake and
housekeeping pair, then matures the window for exactly one awaiting-external
recheck, then lifts the declaration and requires the same enriched wedge to
escalate again unchanged. It fails on unmodified main at the first delivery.

Refs kunchenguid#3149

* no-mistakes(review): align afk skill recheck wording with still-declared contract

* no-mistakes(document): daemon doc comments: pause window ages on declaration

---------

Co-authored-by: Talon Stark <talonstark@gmail.com>
* fix(bin): make Claude auto-arm continuity self-heal past a hung claim

On a Claude primary, a Stop-hook auto-arm process that hung mid-arm held
the single-flight owner lock with its epoch ledger frozen at
outcome=arming, and the abandonment proof read any live lock holder in
arming as legitimately deciding forever. Every later Stop firing exited
0 at the lock, the turn-end guard kept deferring to the hung owner as
recovery under way, and the watcher was never auto-re-armed again for
the rest of the session - supervision survived only on manual arms and
lapsed between them (the 2026-08-26 watcher flap).

Corrections layered onto the lock-held-across-arm shape each reopened
the same concurrency class one level down, so this replaces the claim
machinery wholesale with a generation-based optimistic design:

- The epoch ledger's monotonic sequence IS the claim generation; the
  two-line entry (classic epoch record plus the claimant's MANDATORY
  pid-identity) is the claim. Every firing defers to a live OPEN claim:
  outcome arming, owner alive, identity recomputes and matches, and not
  stuck (entry and watcher beacon both older than the guard grace).
- A finished, dead, identity-mismatched, identityless, or stuck claim
  is superseded by simply taking the next generation - no signalling or
  revocation of a steady-state predecessor.
- No mutex is held across arming or output; the owner lock survives
  only as a micro-mutex around individual ledger writes. A superseded
  owner goes completely silent: ownership is re-verified before every
  arm invocation, episode-state mutation, ledger write, and
  continuation.
- The irrevocable commit point of a translation is the exit status
  (the harness delivers the collected stderr only on exit 2), so the
  owned terminal ledger write is the atomic commit: the winning
  generation exits 2 unconditionally after it, a refused one exits 0
  silently even after printing, and the once-per-episode failure notice
  commits in the same owned critical section as the winning failed
  write. Two bounded residuals are documented accepted intent: an owner
  dying between its owned write and its own exit, and a hung old-build
  owner resuming during the one legacy upgrade window.
- The pre-generation lock-holding claim shape keeps defer-or-reclaim
  behavior through a legacy shim: a live identity-verified stuck owner
  is retired via TERM (with a queued TERM sufficient when the owner is
  stopped) before its lock is removed, an unverified or identityless
  pid is never signalled but never blocks a proven-abandoned reclaim,
  and the lock's identity evidence is grafted into the ledger
  (mtime-preserving) so pid-reuse protection survives the lock.
- The guard reads the same predicates for recovery ownership and its
  terminal fail-open (which re-checks for a live open claim under the
  held locks before committing the attended alarm), with ledger reads
  anchored to line 1 so the identity line can never confuse them.

Behavioral regression coverage exercises all three edge classes through
the real hook and guard - a live open claim defers with no lock held, a
stuck claim is superseded and the home re-arms, and an end-to-end run
with a genuinely hung owner shows a concurrent firing deferring
promptly mid-arm, a later firing superseding the stuck owner, and the
superseded owner exiting silently without a second translation - plus
the identityless/reused-pid loopholes, the superseded-owner arm
boundary, and the legacy TERM, SIGSTOP, and signal-free reclaim paths.

* no-mistakes(review): Refuse auto-arm commits when notice marker creation fails

* no-mistakes(review): Make episode reset atomic with generation ownership

* no-mistakes(document): Update auto-arm generation and commit documentation
…n unproved merge (kunchenguid#3064)

* fix(pr): verify GitHub merge outcome

* no-mistakes(review): Captain, fixed forge-only merge verification, queue guidance, metadata propagation

* no-mistakes(document): Correct forge-specific merge documentation

* no-mistakes(review): Captain: forge-only queue fix, focused tests pass

* no-mistakes(review): Captain: suppress closed-state guidance and prove parent regression

* no-mistakes(review): Captain: remove history proof; retain executable regressions

* no-mistakes(document): Clarify GitHub recording timing in architecture docs

* no-mistakes(document): Clarify outcome-aware PR merge recording documentation

* no-mistakes: apply CI fixes

* Revert "no-mistakes: apply CI fixes"

This reverts commit c326cfa.

The automatic CI repair round removed the up-front `gh` prerequisite check
while keeping the `gh` dependency: `bin/fm-pr-merge.sh` still calls
`gh api graphql` for the outcome read and `gh api` for the branch-rules read.
That left the same hard requirement without the clear named error, and review
immediately raised a new finding for exactly the failure the check prevents -
`gh-axi pr merge` landing the merge while the follow-up read fails, so the PR
metadata is never recorded.

The check is also symmetric with the GitLab arm directly above it, which
already refuses up front when `glab` or `jq` is missing, on the stated
principle that a missing tool should be a named prerequisite rather than a
merge that is armed and then refused for an unexplained reason.

The workflows this round was chasing sit at `action_required` because this is
a fork pull request; no code change can turn them green.

* fix(pr): keep PR bookkeeping when a merge outcome read fails

On the GitHub path a merge call that returned success was followed by
`github_read_outcome || exit 1`, so a transient API failure, rate limit,
or network blip during the read dropped out of the script before
`record_pr_metadata` ever ran. The merge could have landed while `pr=`
went unrecorded and the merge poll was never armed - bookkeeping lost on
a real merge. The failure path just above already recorded metadata
before exiting, so the error path was more careful than the success one.

Record the PR before that refusal. Recording arms the later merge poll
and is not a success claim, which is the same reasoning that keeps
`record_pr_metadata` on the gh-axi failure path. The refusal itself is
unchanged: exit stays non-zero and the message still names the concrete
observed state. Metadata is withheld only when the read succeeds and
proves the pull request neither merged nor queued.

Pin it with a case that stubs `gh api graphql` into failure after a
successful `gh-axi pr merge`, asserting both the non-zero exit and the
recorded metadata.

* no-mistakes(review): Aggregate queue rules and report conflicts explicitly

* fix(pr): keep the merge abstraction reachable and its bookkeeping intact

Two holes remained in the outcome-verified GitHub merge path, both on
installations where gh-axi is present but gh is not.

The verification preflight refused before bin/fm-pr-merge.sh ever reached
the configured gh-axi merge abstraction, so an installation without gh
could no longer merge at all. gh-axi now performs the merge unconditionally
and the queue-aware gh read became an optional enrichment: with gh on PATH
its GraphQL view still separates merged from queued, and without gh the
gh-axi view still proves a landed merge while every outcome it cannot prove
refuses.

The PR metadata recording sat behind the outcome read, so a merge that
landed before that read failed lost pr= and its merge poll. Recording now
happens once, before either forge call, which arms the poll without
claiming a landed outcome and leaves teardown a PR identity to verify
against no matter how the read ends.

Rebasing onto main also restored the durable merge-outcome reporting and
the GitLab landed-state confirmation that the conflict resolution dropped.

Tests pin each fix through the executable interface: the merge abstraction
is reached and verified with gh absent, a failed fallback read keeps its
bookkeeping, and a mock that snapshots the task meta during the forge call
proves pr= is recorded before the merge can land.

* no-mistakes(review): fix(pr): de-dup queue methods, fall back on failed gh read, refresh contracts

* no-mistakes(review): fix(pr): quote forge output and explain armed auto-merge on refusal

* no-mistakes(review): fix(pr): claim auto-merge armed only when the forge accepted it

* no-mistakes(review): fix(pr): tell the operator what each GitHub refusal could not observe

* no-mistakes(review): fix(pr): gate every forge-acceptance claim on a successful merge

* no-mistakes(document): align merge docs with verified GitHub outcome contract
* fix(pi): stop reporting one merge to the captain twice

The supervision branch's captain-outcome note told main, unconditionally,
that the note "is not your own earlier output" and to relay it now. When
main had already reported the same event, that assertion was false and the
order turned the correct response - saying nothing new - into a mechanical
re-report, so the captain saw one merge reported twice in 16 seconds.

Two independent changes, both needed:

- The relay instruction is now conditional. It still names itself as a
  supervision outcome so main cannot mistake it for its own earlier answer
  (the silent loss that instruction exists to prevent), and it now lets
  main stay quiet about an outcome it has already given the captain.

- The merge case is closed at its source rather than left to that judgment.
  One merge reaches a home on two independent paths by design - main's own
  permanently main-owned merge poll, and the branch's task-local status
  wake - and main's captain-facing text only reaches the branch's mirror at
  main's turn end, so the branch can escalate before it could possibly see
  the captain was already told. bin/fm-pr-merge-notified.sh answers that
  question from bin/fm-pr-lib.sh's canonical merge-notification marker, so
  the answer holds regardless of mirror timing. A captain outcome naming an
  already-published merge is delivered as the ordinary rendered note
  instead of opening a follow-up turn: still appended, still visible, still
  recorded with the verdict the branch decided, minus the wasted turn.

Any error, timeout, or unreadable state relays the outcome. A duplicate
announces itself; a lost outcome does not.

Regression coverage drives the real delivery path in both directions: a new
outcome must still reach the captain in exactly one follow-up turn even
beside an unrelated published merge, and an already-published merge must
open no second turn while a different PR in the same task still does. The
merge path's real producer and this new consumer are exercised end to end
in tests/fm-pr-merge.test.sh.

Pi-only by construction: the delivery path lives in .pi/extensions, so no
other harness loads it, and the new script only reads existing markers.

* no-mistakes(review): Document accepted latest-marker suppression residual

* no-mistakes(review): Recheck ownership before merge outcome delivery

* no-mistakes(document): Document merge-outcome suppression exception

* refactor(pi): drop the source-level merge suppression, keep the envelope fix

The captain reviewed this branch and judged the source-level duplicate
suppression overly complicated for the problem it solved, and asked for
the change to be reduced to the envelope wording alone.

Remove the mergeIntoMain downgrade path, bin/fm-pr-merge-notified.sh, and
every test and document that existed only for it. What remains is the
conditional captain-outcome instruction: main is told to stay quiet about
an outcome it has already reported and to relay anything else, which
covers the duplicate without a second mechanism.

The silent-loss protection is untouched - the note is still typed,
self-describing, and delivered as one invisible follow-up turn - and the
behavioral tests still assert that, now requiring both halves of the
conditional instruction.

* no-mistakes(ci): Clarified in code comments and owned documentation that this is intentionally an M1-only, model-facing conditional relay fix—not source-level suppression—addressing Greptile’s mistaken scope expectation without changing runtime behavior. Net diff remains 3 files and 27 insertions. Verified with fm-pi-branch-extension tests, fm-lint, doc audience check, and git diff --check; all passed

* no-mistakes(ci): Strengthened the runtime delivery test to verify the captain outcome retains its required self-description and outcome text. Verified with `bash tests/fm-pi-branch-extension.test.sh`, `bin/fm-lint.sh`, `bin/fm-doc-audience-check.sh`, and `git diff --check`; all passed. The outer pipeline can now commit and attest the new head
Take the contiguous upstream first-parent prefix 60bedde..4f89f5b as one
merge, per the TRACK strategy in docs/fork-divergence.md. Eight upstream
changes: markerless remote secondmate reconciliation (kunchenguid#3140), the busy
declared pause handed to the away-mode daemon (kunchenguid#3147), the stale submodule
pin named behind a pooled slot refusal (kunchenguid#3121), stale captain outcome
re-emissions (kunchenguid#3154), a declared wait kept on the pause cadence (kunchenguid#3155),
Claude auto-arm recovery from hung claims (kunchenguid#3156), the real GitHub merge
outcome verified instead of reported unproved (kunchenguid#3064), and duplicate captain
outcome reports (kunchenguid#3184).

Eight paths conflicted and were resolved on meaning, keeping both intents
wherever they were compatible:

- AGENTS.md section 7: the guarded merge sentence now carries both the
  fork's work-item lifecycle clause and upstream's unproved-merge refusal.
- .agents/skills/afk/SKILL.md, docs/architecture.md, docs/configuration.md:
  upstream's busy-pane declared-wait routing and status-line ageing beside
  the fork's self-widening recheck window and run-progress hold.
- bin/fm-supervise-daemon.sh: upstream's contract that pane busy state does
  not end a declared wait, so housekeeping's `0)` busy arm is dropped and
  the fork's streak record moves to the surviving `2)` arm; the per-iteration
  pause_resurface_window replaces upstream's fixed read.
- bin/fm-watch.sh: upstream's away-mode handoff branch adopted whole inside
  busy_turn_bound_check, keeping the fork's sixth hold-count parameter on
  the wedge_timer_check call below it.
- docs/scripts.md: both clauses of the fm-pr-merge.sh row.
- tests/fm-pr-merge.test.sh: every case from both sides, 79 assertions
  against 53 on the fork parent, 61 on the upstream parent and 35 at the
  merge base, so nothing was dropped or duplicated.

tests/fm-dashboard-events.test.sh needed the same treatment without a
textual conflict: kunchenguid#3156 adds the claim identity as a second line of
state/.claude-autoarm-epoch, which two separate processes cannot share, so
the emitter-equivalence comparison normalizes it beside the owner pid and
stamp and asserts its presence separately rather than comparing vacuously.

This merge does not resolve the merge-proof contract question that kunchenguid#3064
opens against the still-open #241; the merged
behaviour is upstream's and the PR body records the contradiction.
Add the watcher's live declared-wait routing and self-widening recheck
cadence (#144, #51), which bin/fm-watch.sh
collided on this round while going unrecorded since its introduction.

Record kunchenguid#3147's second collision with the run-progress
wedge hold, and note under the dashboard entry that kunchenguid#3156 added a
run-varying claim identity to the auto-arm epoch ledger, so a future round
does not restore the un-normalized emitter-equivalence comparison.
… trap

test_pi_process_exit_cleanup_stops_arm_child waited up to 20s for the
cleanup log to show TERM had arrived, then asserted immediately that the
child was gone. The child writes that log line from inside its own TERM
trap, before its exit, so the line proves the signal arrived and not that
the process is reaped. Between the two the trap body has to finish, bash
has to tear down, and the kernel has to release the pid.

Observed on this host during a full-suite sweep: "Pi arm child 537033
survived process-exit cleanup", while the same suite passes standalone.

Give the death its own bounded wait on the same budget as the delivery
wait above it. The assertion is unchanged in strength: with the fixture's
trap edited to log TERM and deliberately not exit, the case still fails.
Fork main advanced from 0c51a08 to b9b86fe while this round ran, so the
PR base moved and no longer merged cleanly. Bring the new base in as a
merge rather than rebasing: the round's whole purpose is to advance the
merge base against upstream, and a rebase would replay and linearize the
upstream merge this branch exists to create.

No upstream commit enters here. git log 0c51a08..origin/main is exactly
one commit, fork PR #246, which carries no
upstream content.

One path conflicted, bin/fm-claude-stop-autoarm.sh, and only in its header
comment. Both bullets are kept, each matched to what the merged code
actually does:

- Need: #246's expanded list (process-event sources and a pending wake
  queue), because fm_supervision_needed now reads FM_SUP_QUEUE_PENDING as
  a need input in bin/fm-supervision-lib.sh.
- Single-flight: upstream kunchenguid#3156's generation-claim model, because
  fm_autoarm_claim_open and fm_autoarm_claim_next in bin/fm-wake-lib.sh
  are what the merged code calls; #246's lock-admits-one-owner wording
  describes a mechanism this merge already replaced.

The suites both sides touch pass: fm-claude-stop-autoarm, fm-turnend-guard,
fm-guard-stale-banner, fm-control, fm-backend-zellij.
…ence

Firstmate decided on 2026-09-03, on this round's PR, that the fork keeps a
merge proof a deferred execution does not satisfy, against upstream
kunchenguid#3064 accepting a merge-queue entry as verified.

The entry carries the reasoning rather than only the verdict, so the
collision is not re-derived every round: this fleet has no merge queue for
an enqueue to be deferred into, reporting an unproved merge as landed has
actually happened here, and conforming to the more protective option needs
nobody's permission while relaxing one does.

It also records what the code does TODAY, which is upstream's permissive
read, because round 1 merged kunchenguid#3064 faithfully. The stricter implementation
is fork PR #241, held on a separate matter, and
the reconciliation happens when that lands against this merged base. A
future round must not read the merged code as evidence the fork chose
upstream's contract.

Retirement condition: if this fork ever adopts branch protection with a
merge queue, re-examine the divergence rather than defend it.
@HelloWorldSungin
HelloWorldSungin merged commit 2b872e8 into main Sep 3, 2026
16 of 17 checks passed
@HelloWorldSungin
HelloWorldSungin deleted the fm/fm-upstream-sync-2026-09-02-round-1 branch September 3, 2026 02:31
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.

5 participants