fix(bin): fail closed on NUL bytes in durable parent bindings - #1847
Merged
Conversation
fm_secondmate_parent_record_parse read the .fm-secondmate-parent record with bash's read, which drops NUL bytes - and different bash generations disagree on the result: 3.2 truncates the value at the NUL while 5.x splices the surrounding bytes together. A NUL-bearing parent_home could therefore resolve to a home the record's bytes never name contiguously, and which home fm-teardown.sh's promised-public-reply resolution read (registration, registry, relay state) - or whether that protection engaged at all - depended on which interpreter ran the cleanup. Reproduced end to end: the same NUL-bearing record cleaned up under bash 5.x by resolving the spliced-together registered parent, while bash 3.2 refused it as unresolved, and a literal truncated path refused under both. Reject any NUL byte in the record before field parsing, putting corrupt records in the same fail-closed bucket as duplicate fields, malformed local bindings, unsupported routes, and symlinked records. The regression test drives the real bin/fm-teardown.sh over the proven clean-cleanup fixture with a NUL spliced mid-path into the recorded parent_home, so before the fix it reproduced the wrong-home cleanup and now it must refuse with the explicit binding refusal.
kunchenguid
force-pushed
the
fm/fm-secondmate-parent-nul-failclosed-r1
branch
from
August 7, 2026 00:48
bff077d to
58253ed
Compare
kaku-san
added a commit
to kaku-san/firstmate
that referenced
this pull request
Aug 7, 2026
…eltas (#12) * feat: gate remote second mates on Herdr readiness (#1639) * feat(bin): gate remote second mates on herdr readiness A remote second mate now always runs on the Herdr backend, whose server belongs to the host's GUI login session and therefore outlives the SSH connections that supervise it. fm-spawn's remote route forces that backend and the host-local control script refuses any other, so the requirement cannot be dropped from either side. fm-remote-doctor.sh becomes the single owner of what "ready" means. It keeps its PATH and tool reporting from #1623 and adds the Herdr, Aqua LaunchAgent, GUI-session, server-reachability, and entrypoint-symlink checks, tagging each gap fixable: or human: with the exact operator step. --fix closes only the automatable gaps - writing and loading the Aqua-scoped dev.firstmate.herdr launch agent, starting the server where no launch agent applies, and recreating the entrypoint symlink - then re-derives every check from the host, so a human gap is never presented as fixed. It never creates a login session, writes an auto-login password, or touches FileVault. Remote seed, remote spawn, and the startup liveness relaunch all run the same check, repair, re-check sequence through one shared library and fail closed with the doctor's own gap text. Recovery inherits the gate because it respawns through the same route. Tests drive the real doctor against a controlled account fixture with a private HOME, a state-backed launchctl, and a fake herdr, and prove the dangerous actions are never attempted. The remote lifecycle suites gain a stateful Herdr CLI fixture and answer the readiness gate at the SSH boundary, so they never inspect or repair the runner's own account. * no-mistakes(review): Validate launch-agent contract and confirm Herdr startup * no-mistakes(review): Validate loaded launch-agent contract before readiness * no-mistakes(review): Refuse legacy remote backends without altering routes * no-mistakes(review): Clarify conditional remote readiness repair sequence * no-mistakes(review): Repair remote readiness before liveness probing * no-mistakes(review): Preserve unknown seeds and reject legacy liveness * no-mistakes(document): docs: clarify remote Herdr backend ownership * fix: isolate remote secondmates in shared Herdr session (#1659) * Pin remote secondmates to fm-remote * no-mistakes(review): Fail closed on legacy remote Herdr endpoints * no-mistakes(review): Isolate fm-remote launch agent from interactive default * no-mistakes(document): Document shared remote Herdr retirement safety * feat: route remote commands through an Aqua job worker (#1660) * feat: run remote commands through Aqua job worker * no-mistakes(review): Enforce remote job deadlines and safe worker shutdown * no-mistakes(review): Refresh stale workers and harden dependency-free supervision * no-mistakes(review): Harden worker ownership recovery and shutdown quarantine * no-mistakes(review): Fix doctor bootstrap, harness repair, and output draining * no-mistakes(review): Probe doctor tools through authenticated worker bootstrap * no-mistakes(review): Refresh stale workers before doctor tool probes * no-mistakes(review): Recover stopped quarantines and extend job deadlines * no-mistakes(review): Separate queue and execution timeout windows * no-mistakes(review): Supervise Linux worker crashes and bind root identity * no-mistakes(review): Resolve authorized Nix profile bin links * no-mistakes(review): Clarify Nix path resolution documentation * no-mistakes(review): Harden PATH safety and nvm selection * no-mistakes(review): Honor nvm system defaults and refresh doctor digest * no-mistakes(review): Keep workers ready during active jobs * no-mistakes(review): Bound pre-execution validation by job timeout * no-mistakes(document): Clarify remote worker documentation * no-mistakes(lint): Fix remote worker ShellCheck diagnostics * no-mistakes: apply CI fixes * no-mistakes: apply CI fixes * no-mistakes: apply CI fixes * fix: clarify remote doctor bootstrap path (#1691) * fix(bin): bound remote SSH dead-peer detection (#1699) * fix(remote): arm SSH dead-peer detection in fm-on.sh A vanished remote host mid-poll (a reboot, a dropped link) left ssh blocked indefinitely on a half-open TCP connection, because fm-on.sh's ssh invocation had no ServerAliveInterval/ServerAliveCountMax. This wedged the remote-reply ferry: fm-procevent.sh's runner blocked inside the ssh child and never reached its own no-result -> claim-release -> reconcile re-arm self-healing path, which otherwise already handles a nonzero exit with empty output correctly. Recovery required a manual retire and re-arm. Arm ServerAliveInterval=15 and ServerAliveCountMax=3 by default (bounded ~45s detection window), both overridable via FM_SSH_ALIVE_INTERVAL and FM_SSH_ALIVE_COUNT_MAX. This is a transport- level fix in fm-on.sh, so it covers every remote command routed through it, not just the reply ferry. The remote sshd answers keepalive probes independently of whatever the remote command is doing, so a legitimately long-but-alive command (a 55s poll, a clone, the doctor) is never falsely killed - only a truly vanished peer trips it, turning that case into a bounded, detectable ssh failure (exit 255) instead of an indefinite hang. Extends tests/fm-on.test.sh with a behavioral regression asserting a bounded, positive ServerAliveInterval/ServerAliveCountMax on the real ssh argv captured through the FM_SSH_BIN process seam, plus coverage that both are env-overridable. * no-mistakes(document): Document SSH dead-peer detection ownership * fix: report stale AXI tools during bootstrap (#1701) * feat(bootstrap): gate stale axi CLIs at the floors firstmate actually uses Add gh-axi 0.1.29 floor so bare --squash PR merges stop failing quietly on older builds. Raise tasks-axi to FM_TASKS_AXI_MIN=0.2.2 (multi-id mv) while keeping feature probes. Keep quota-axi at 0.1.16 after verifying schema 3 and per-model availability already ship there; runway remains optional. * no-mistakes(document): Clarify AXI compatibility documentation ownership * fix: prevent false watcher-down alarms in Claude sessions (#1661) * fix(guard): stop false watcher-down alarm mid-turn under Claude auto-arm bin/fm-guard.sh derived its watcher-health verdict from fm_watcher_healthy, which requires a live watcher process holding the home lock. Under the Claude Stop-hook auto-arm supervision model the watcher is armed at each turn end and exits on its wake, so it runs only between turns. Every guarded command run mid-turn therefore found no live watcher and printed the "WATCHER DOWN - SUPERVISION IS OFF" banner even though supervision was healthy. Because the episode key was derived from the beacon mtime (which the between-turns watcher advances every poll), the full banner re-printed on essentially every command, and the message always blamed a "fresh beacon" that was in fact fresh. Make the pull guard's health check model-aware via a new fm_watcher_supervision_verdict in bin/fm-wake-lib.sh: - Under the auto-arm model a beacon fresh within FM_GUARD_GRACE is healthy even with no live watcher process; only a beacon stale beyond grace (or absent) is a genuine lapse and alarms. - Under every persistent-watcher harness (codex foreground checkpoint, opencode/pi/grok background arm, tmux, unknown) a live identity-matched watcher with a fresh beacon is still required, unchanged. The banner now names the true failing condition, a missing live watcher process versus a genuinely stale beacon, instead of always blaming the beacon, and the once-per-episode dedup keys on that condition rather than the beacon mtime so a genuine lapse announces once and does not re-print each turn. The turn-end guard keeps the strict fm_watcher_healthy check because it fires at the turn boundary, where the auto-arm brings a fresh watcher up and it cooperates with that arm. fm_watcher_healthy itself is unchanged, so the arm layer's start/attach/replace decisions are unaffected. Tests in tests/fm-guard-stale-banner.test.sh cover the auto-arm healthy fresh-beacon-without-a-watcher case, the auto-arm stale-beacon alarm and its stable episode, the true-reason banner wording, and the reason-keyed episode surviving a beacon mtime change; existing persistent-model cases are pinned to that model. * no-mistakes(review): Pin secondmate supervision model to launched harness * no-mistakes(document): Align watcher documentation with model-aware supervision health * test: prevent fixture temporary directory leaks (#1704) * fix(tests): stop fixture-tempdir helper from self-deleting under command substitution fm_test_tmproot is almost always called as `TMP_ROOT=$(fm_test_tmproot prefix)`, which forks a subshell to capture its stdout. The old implementation set its EXIT cleanup trap inside that call, so the trap fired - and deleted the fixture root - the instant the subshell exited, before the real caller's own EXIT trap was ever installed. Every test using the documented call pattern leaked its fixture root on every run; two suites had already independently discovered and worked around this with ad-hoc mktemp calls. Registration now goes through a $$-keyed registry file instead of in-process state, since $$ resolves to the invoking shell's PID even inside the subshell. The real cleanup trap is armed once at source time (always the real caller, never a subshell) for EXIT, INT, and TERM. A best-effort orphan sweep on next source reaps marked fixture roots old enough to be from a killed prior run. Simplifies the two existing ad-hoc workarounds (fm-procevent.test.sh, wake-helpers.sh) back onto the shared helper now that it works correctly. * no-mistakes(review): Preserve live fixtures during orphan reaping * no-mistakes(review): Harden fixture ownership against PID reuse * no-mistakes(review): Secure cleanup registry against path precreation * no-mistakes(review): Make fixture registration transactional * no-mistakes(document): Documentation already matches fixture cleanup behavior * no-mistakes: apply CI fixes * no-mistakes: apply CI fixes * feat(herdr): enable presentation spaces by default (#1708) * feat(herdr): default presentation spaces on with an explicit opt-out Herdr's disposable one-task presentation workspace was opt-in through the presence of local config/herdr-presentation-spaces. It is now on by default, and a home opts out by writing "off" into that same file. Values are read with the whole-file whitespace-stripped convention the other scalar config items already use, plus case folding. An absent file, an empty file, and "on" all resolve on; only "off" opts out; an unrecognized value warns and keeps the default rather than failing a spawn over a purely visual setting. The empty file is exactly the historical opt-in form, so every home that had already enabled the projection stays enabled with no migration step, and no previously enabled home can be turned off by the flip. Because absence now means on at both ends, secondmate inheritance needs no item-specific convergence: mirroring an absent primary file converges a secondmate to the same default-on rather than turning its projection off, and only an explicit primary opt-out propagates the opt-out. The gate itself moves into fm_backend_herdr_presentation_enabled in the Herdr adapter so the semantics have one owner that regressions can exercise directly. * no-mistakes(document): Document Herdr default-on presentation safety --------- Co-authored-by: kunchenguid <kun-1@kunchenguid.com> * fix(bin): surface fleet-wide open decisions on every wake drain (#1711) * fix: surface consolidated open decisions on every wake-drain A needs-decision or blocked event buried under later, unrelated status appends was only ever shown via the last-line wake annotation, so a still-open captain decision could go silently missed even though status_open_decisions (fm-classify-lib.sh) already folds the whole status stream correctly and fleet-snapshot/bearings already reuse it. Wire that same fold into bin/fm-wake-drain.sh: a new fleet-wide scan_open_decisions wrapper scans every state/<id>.status, and fm-wake-drain.sh prints a separate, bounded OPEN DECISIONS section on every drain (including the empty-queue fast path), so session-start and every wake-handling turn surface it for free without duplicating the open/resolved fold itself. Heartbeat wakes drain through the same script, so this covers that surface too. Also tighten status_open_decisions' file guard to skip an unreadable status file instead of leaking a bash redirection error, now that a fleet-wide directory scan can reach files a single targeted read would not. * no-mistakes(review): Prevent status symlinks leaking open decisions * fix: drop unbounded perl subprocess from status symlink guard The review step's own symlink-safety auto-fix (O_NOFOLLOW read via a perl subprocess) forked one perl process per status file scanned by the new fleet-wide open-decisions scan, with no cap - inflating fm-wake-drain.sh's total external-read cost from 8 (the existing annotation read_cap) to 18 in the enrichment-caps regression test. The plain [ -L "$f" ] check already rejects any status file that is itself a symlink before any read happens, which is exactly what the new regression test exercises and is the same defense level the sibling scan_captain_relevant_statuses/last_status_line already rely on elsewhere in this file (no O_NOFOLLOW). Drop the subprocess-based nofollow read and keep the cheap builtin guard. * no-mistakes(document): Document actionable fleet-wide open decision drains * fix(bin): abort parked runs and reap leaked processes before teardown (#1710) * fix(bin): abort orphaned no-mistakes runs and reap leaked processes at teardown Teardown could remove a task's worker while its no-mistakes pipeline run was still parked at a gate, leaving an orphaned run holding a fleet slot indefinitely (observed 2026-08-03: runs parked 7h39m and parked at a post-CI approval gate). It could also leave backgrounded/disowned descendant processes rooted under the worktree or tasktmp surviving reparented to init (observed: two `go test` binaries pinning CPU for hours with no live task meta to attribute them to). Add two coupled pre-teardown steps, both scoped to this task's exact branch/head or worktree/tasktmp so they can never touch another task's run or processes: - conclude_task_no_mistakes_run aborts a run parked at a gate via `no-mistakes axi abort`, cd'd into the exact worktree so the daemon resolves the run itself rather than teardown naming a --run id. - reap_task_worktree_processes sweeps for processes whose cwd is under the worktree or tasktmp (via `lsof -a -d cwd`) and TERM/KILLs them. Both run before any worktree return, branch delete, or backend kill, and are idempotent on a retried teardown. The branch+head attribution logic is factored out of bin/fm-crew-state.sh into the new shared bin/fm-nm-run-lib.sh so both scripts use the same ownership contract. * no-mistakes(review): Fail closed on incomplete teardown cleanup * no-mistakes(review): Bind teardown cleanup to verified run and process identities * no-mistakes(review): Require confirmed aborts and convergent identity-safe process reaping * no-mistakes(review): Handle process exits during teardown identity checks * no-mistakes(review): Restore teardown library in hermetic gotmp fixtures * no-mistakes(document): Document teardown run attribution and timeout * no-mistakes(lint): Rename shell variable conflicting with done keyword * no-mistakes: apply CI fixes * fix: resolve fm-remote-entrypoint.sh SCRIPT_DIR through a PATH symlink (#1709) The script installs as a symlink under ~/.local/bin. Taking dirname of the symlink itself (instead of its real target) pointed SCRIPT_DIR at ~/.local/bin, breaking sourcing of the sibling fm-remote-job-lib.sh. Resolve the real path first, preferring python3's os.path.realpath, then realpath, falling back to the raw BASH_SOURCE on hosts with neither. * fix(pi): gate Calm built-in overrides by activation state (#1724) * fix(pi): stop Calm claiming a built-in tool name another extension owns fm-calm.ts claimed bash/read/edit/write/grep/find/ls unconditionally at extension load, regardless of whether Calm was on. Pi resolves two extensions registering the same built-in name by first-registered-wins with no merge and no unregister call, and Calm's project-local .pi/extensions/ position beats any global or CLI-configured extension, so a user who never even enabled Calm could have their own bash/read/etc override silently replaced. Captain-approved plan implemented: - Registration is now gated on config/calm already being "on" at load time. A Calm-off session or reload registers nothing, so a non-Calm user never contests a name. This stays synchronous during the factory's own load, not deferred to session_start: /reload (and ctx.newSession/fork/switchSession) render the restored transcript from a pre-session_start snapshot of the tool registry, so a deferred claim would miss that render - confirmed by tests/fm-calm-pi-extension .test.sh's hidden-block-geometry E2E when trialed. - The first time Calm turns on in a session that started off (activateBuiltInsIfNeeded, from the /calm command handler), Calm calls pi.getAllTools() - safe only once every extension has finished loading, unlike the load-time path above - to see whether a different extension already owns a name, and skips claiming only that one, leaving it and its owning extension fully intact and callable. - A contested name found this way prints a prominent ctx.ui.notify() warning naming the tool, plus a console diagnostic. - reportBuiltInLosses() remains the backstop for the one case neither of the above can reach: a session that starts or reloads with Calm already on, where the registry snapshot is taken before Calm gets any chance to check ownership. A symlink-safe realpath comparison avoids misreporting Calm's own registration as foreign when its path crosses a symlink (macOS /tmp, /var). Confirmed, bounded trade-off: the very first time a session that started Calm-off turns Calm on, tool-call rows already on screen from before that toggle do not retroactively collapse, because Pi never lets an extension re-point an already-rendered row at a definition registered later. Every session after that first toggle starts with the preference already on and takes the synchronous load-time path, so the guarantee is intact from then on. docs/calm.md and the file's own header document this in full. tests/fm-calm-pi-extension.test.sh gains test_builtin_gate_load_time (config/calm off registers nothing, on registers all 7 synchronously at load) and test_calm_activation_collision_and_regression_bound (first activation claims every uncontested built-in, leaves a foreign bash tool fully intact and callable, warns and logs the contested name, and locks in the documented pre-activation bound against real ToolExecutionComponent rendering). test_rendering_and_session_lifecycle and the live interactive E2E are updated for the new gate-at-load and first-activation-bound contract. * no-mistakes(document): Document Calm tool collision boundaries * no-mistakes: apply CI fixes * fix(bin): persist secondmate parent bindings for cleanup (#1727) * fix(bin): give secondmate homes a durable parent binding record Finished-worker cleanup on a remote second mate refused forever with "cannot resolve the primary home ... durable parent binding". The remote launch hands the child the remote code checkout as its parent home (fm-spawn.sh's sole writer of FM_PUBLIC_FOLLOWUP_PRIMARY_HOME receives FM_HOME=$FM_ROOT from fm-remote-secondmate-control.sh's host-local launch), and that path can never carry the parent's real records, so the guard refused unconditionally once relay looked active anywhere on that host. fm-home-seed.sh and fm-remote-home-provision.sh now write a durable .fm-secondmate-parent record next to the .fm-secondmate-home identity marker, naming the home's route to its parent as local (with the real parent path) or remote (with the parent's SSH alias for diagnostics only). fm-teardown.sh's cleanup gate reads it: a remote parent is out of scope for the delegated-promise check (the whole promised-public- reply subsystem is same-filesystem by construction, so a remote parent can never hold one), while a token committed directly to the child's own .env file - never the process environment - still refuses, so an unrelated export in the remote host's login shell can no longer mask in. For a local secondmate, the durable parent_home now also backs up the launch-time env var, closing a silent fail-open where a restart that dropped the launch prefix made the guard treat a genuinely active parent relay as off. Regression coverage drives the real remote route (SSH boundary + Herdr fixture) and real fm-home-seed.sh seeding rather than hand-crafted markers. * no-mistakes(review): Captain: fail closed on unsafe durable parent records * no-mistakes(review): Captain: enforce durable parent binding commit protocol * no-mistakes(review): Captain: publish local parent binding before identity * no-mistakes(review): Captain: refuse conflicting local parent bindings * no-mistakes(review): Captain: reject non-regular secondmate seed leaves * no-mistakes(review): Captain: enforce unique durable parent bindings * no-mistakes(review): Captain: reject route-incompatible durable parent fields * no-mistakes(document): Document durable secondmate parent bindings * no-mistakes(lint): Fix secondmate parent parser ShellCheck warnings * no-mistakes: apply CI fixes * feat(bin): enforce latest AXI-family tool floors (#1733) * feat(bin): gate lavish-axi at its session_ended floor in bootstrap bin/fm-procevent-lavish.sh decides that a human "Send & End" review is terminal by reading session_ended from the poll response's leading session block. That field first shipped in lavish-axi 0.1.35, so an older installed build silently leaves every ended review source armed forever and captures an empty ended result on each later cycle. The same release is what makes a plain reopen refuse a session the human deliberately ended. Add LAVISH_AXI_MIN=0.1.35 to the existing axi-family floor structure in bin/fm-bootstrap.sh, reusing tool_version_at_least and the same MISSING diagnostic gh-axi already emits, so an incompatible build is reported as an upgrade request before any review surface is armed. Later lavish-axi releases only add artifact-authoring surface the adapter never reads, so the floor is the feature-introduction point rather than latest. Fixtures that stubbed lavish-axi as a bare exit-0 tool would now be read as unparseable builds, so tests/lib.sh gains fm_fake_version_tool and every bootstrap-running suite uses it for lavish-axi. * no-mistakes(review): Clarify lavish-axi version floor rationale * no-mistakes: apply CI fixes * feat(bin): set axi-family floors to current latest under the bump policy The axi-family bootstrap floors are the CURRENT LATEST published version of each tool, captain-bumped periodically to move the whole fleet onto the newest axi tools. They are not the minimum feature-introduced version. The earlier lavish-axi work set a feature-minimum floor, which is the opposite of this policy, so replace it along with the older feature-minimum rationale carried by tasks-axi and quota-axi. State the policy explicitly in bin/fm-bootstrap.sh's header, which owns it, and in each per-tool floor owner, so no future change argues a floor back down to the earliest release that happens to satisfy some behavior. Remove the lavish-axi session_ended and upstream-PR citation, the tasks-axi multi-ID-mv minimum argument, and the quota-axi credential-source argument as floor rationale; the tasks-axi feature probes remain as a separate defense-in-depth concern. Floors: lavish-axi 0.1.45 (was 0.1.35), tasks-axi 0.2.4 (was 0.2.2), quota-axi 0.1.17 (was 0.1.16), gh-axi 0.1.29 unchanged and already latest. Each was verified against the tool's current published version. The mechanism is unchanged: the same shared version helper and the same MISSING diagnostic path. The below-fires and at-or-above-silent regression rows move to the new floors, keeping each boundary genuine by pinning the patch immediately below each floor rather than a version that was only below the old one. Fleet fixtures move to the new floors so a bootstrap- running suite is not reported as an out-of-date build. Three operator-facing backlog handoff and receipt errors named "0.2.2+" while the enforced floor moved, so they now point at the floor's owner instead of duplicating a version number that drifts. * no-mistakes(review): Centralize AXI floor policy beside constants * no-mistakes(review): Clarify bootstrap boundary test comment * no-mistakes(document): Centralize AXI floor policy rationale * fix(bin): bound open decision scans with incremental cursors (#1737) * fix(bin): bound OPEN DECISIONS scan cost with a per-status-file cursor The fleet-wide OPEN DECISIONS scan added in #1711 re-reads and refolds every task's entire lifetime status log on every drain, so its cost grows unbounded with total log size. Add status_open_decisions_incremental and scan_open_decisions_incremental to fm-classify-lib.sh: they persist a per-status-file byte cursor plus the folded open-decision set, and fold only newly appended bytes on each call, reusing status_open_decisions' exact fold-line rule (extracted into _fm_decision_fold_line) so the two strategies can never disagree on what is open. A missing or invalidated cursor (new task, truncated/rewritten/shrunk log) falls back to a full re-fold. bin/fm-wake-drain.sh now calls the incremental wrapper instead of the whole-file scan. * fix(bin): add O(1) rotation detection and read-failure guarding to the cursor fold Add the two pieces the incremental open-decisions cursor was missing, scoped to this repo's actual status-file usage (create-once, append-only, never replaced or rewritten in place): - An O(1) device+inode identity check (one stat call) alongside the existing size-shrink check, so a status file replaced/rotated/recreated at the same path is detected and falls back to a full re-fold, even when the replacement is the same size. A same-inode, same-size, in-place byte edit is a deliberately accepted gap: no code path in this repo ever does that to a status file. - Checked reads: a stat/wc/tail failure is a genuine I/O error, not "the file is empty" - it now reports the already-trusted persisted open set unchanged instead of risking a silent invalidation. Both stay O(1) plus new bytes per call, matching the cursor's bounded- cost design; no content hashing or pending-fragment machinery. * no-mistakes(review): Preserve cursor state across failed incremental reads * no-mistakes(review): Refold status when cursor cache reads fail * no-mistakes(document): Document cursor-backed open-decision scanning * no-mistakes: apply CI fixes * no-mistakes(review): Harden teardown and remote runtime edge cases * fix(bin): prevent remote polls from blocking session startup (#1754) * fix(bin): preempt remote reply long-polls for queued short jobs Session start on a home with live remote second mates could stall silently for many minutes: the single serial remote job worker ran each armed fm-remote-delta-read.sh reply poll to its full 55s window while bootstrap's short sync, inherit, state, and route commands sat queued behind it, and non-FIFO queue pickup let re-armed polls keep winning the lane. Measured end to end, a trivial short job took 31s behind one 30s poll window. The worker now preempts a running preemptible job (the read-only, cursor- anchored delta read is the only member of that class) as soon as a non-preemptible job is queued, publishing exit 75 with emptied output - byte-identical to the poll's own elapsed-window-with-no-data result - so the parent runner takes its existing no-result path and the watcher re-arms from the same cursor with nothing lost. The delta read translates SIGTERM into that same exit after removing its staging directory. Sibling polls never preempt each other, so two armed monitors cannot churn. The same measured scenario now completes in 1s. * no-mistakes(document): Clarify remote poll preemption documentation * docs: present X mode as the X and Discord public surface (#1778) Discord mentions already ride the same pairing-token opt-in, relay poll, and platform-aware reply path as X mentions, but the docs still read as X-only, so a stranger could not self-serve the Discord path. Add the numbered turn-on steps to the X mode configuration reference, pointing at the myfirstmate dashboard for account creation, bot install, and token issuance rather than duplicating operator setup here, and drop the X-only framing from the README bullet, the documentation index, and the architecture overview. * fix(bin): run session start deterministically from hooks (#1781) * feat(bin): run session start deterministically on hook-capable harnesses Session start relied on a native nudge that only asked the agent to run bin/fm-session-start.sh, and an agent can defer that. Observed 2026-08-01: an /ahoy-first session followed the recap path and did not take the helm until a later request forced it. Claude, Codex, and Pi now RUN the digest in their session-open hook through the new bin/fm-sessionstart-run.sh, so the full ordered digest is in model context before the first turn. That wrapper is the single owner of what a session-open source means: startup and Pi's "new" take the helm, clear and compact re-emit, resume/reload/fork delegate to the nudge, and an unreadable source takes the helm because doing that redundantly is idempotent while skipping it is the bug. Grok and OpenCode keep the nudge as the floor, since neither can carry hook stdout into a model turn. Because the hook now blocks session initialization, fm-session-start.sh bounds itself first. Its steps are not all individually bounded - bootstrap's gh auth probe, tool version probes, the backlog listing and per-task endpoint reads are unbounded - so the whole digest runs as one bounded child (default 120s). Whatever it emitted before the bound survives, and the parent adds a loud STARTUP TRUNCATED banner naming the stage that stalled and every stage that never ran, still exiting 0. --reemit skips only the sweeps startup already reconciled. It still re-verifies lock ownership and still drains queued wakes, which arrived after startup and are the turn's work. fm-bootstrap.sh gains FM_BOOTSTRAP_LOCKED so a re-emit keeps repair ownership instead of deferring to a lock holder that is itself. Also adds bin/fm-timeout-lib.sh as the single owner of bounded execution, replacing three near-identical copies, and gives the ahoy skill a helm check so a nudge-tier harness cannot recap before taking the helm. Verified live on 2026-08-05 against Claude 2.1.222, Codex 0.146.0, and Pi 0.82.0; docs/verification/supervision.md records the per-harness source vocabulary, the two named gaps, and the refresh command. * no-mistakes(review): Harden session-start completion, timeout, and Pi delivery * no-mistakes(review): Harden completion ownership and portable timeout escalation * no-mistakes(review): Normalize watchdog KILL exits without masking command status * no-mistakes(review): Guarantee startup bounds and align harness delivery tiers * no-mistakes(test): Fix Pi session-start live verification fixture * no-mistakes(document): Align session-start documentation with deterministic hooks * no-mistakes(lint): Silence intentional child-shell expansion lint warning * no-mistakes: apply CI fixes * no-mistakes: apply CI fixes * fix: rename X mode to Relay in user-facing docs (#1784) * docs: rename the user-facing product name to Relay The public-mention integration gated by the `.env` pairing token is now called Relay across user-facing prose, covering X and Discord alike instead of implying a single network. Renames the product-name strings only: README, docs, the captain-facing skill descriptions, and the AGENTS.md operating prose, including the `X mode (.env)` and `Optional X mode` headings and every link anchor that pointed at them. AGENTS.md section 14 carries a one-line bridge note so the older name and the unchanged identifier spellings stay discoverable. Internal identifiers are untouched: `FMX_*`, `config/x-mode.env`, `state/x-*`, `bin/fm-x-*`, the `fmx-respond` skill path, `__FM_X_MODE_ENV__`, and `x-mode-error`. Platform references to X and Discord as networks stay as they are, and the bootstrap-diagnostics entry still quotes bootstrap's emitted `FMX: X mode on/off` line verbatim because `bin/` output is out of scope for this pass. * no-mistakes(review): Complete Relay prose rename in maintained docs * no-mistakes: apply CI fixes * no-mistakes(review): Honor --force in lsof-free teardown refusals; diagnose parent bindings * feat: add Muse Code crewmate adapter (#1786) * feat(harness): add a verified muse crewmate adapter Muse Code joins the fleet as a crewmate/scout adapter, verified live against Muse Code 0.1.0-R708.1 in an isolated lab. Detection matches the anchored prefix muse-bin*, because the installed launcher execs a version-suffixed binary whose name changes on every auto-update and whose install path carries no muse component to fall back on. The same identity is taught to the tmux liveness classifier, without which a healthy muse pane would have read as a dead endpoint. Busy state folds muse's own durable session event log, bound per task by a sessions-root/worktree sidecar. It is a pull source with no writer, so nothing is armed and no record is ever seeded. The fold is anchored on the full run lifecycle prefix so muse's nested cleanup "terminal" payloads cannot settle an in-flight run, and it is depth-bounded so muse's native sub-agent logs cannot be mistaken for the parent's. The idle half stays gated: an open run proves busy, but a settled log reads unknown until a credentialed multi-step run proves one turn stays inside one run. Two findings corrected the scout report. The exec-only --no-foreign-personal-context flag is rejected by the interactive TUI, so the privacy control that actually reaches a pane worker is MUSE_EXPERIMENTAL_FOREIGN_PERSONAL_CONTEXT_KILL, verified to drop the operator's foreign personal rules while keeping the project's own AGENTS.md. And an unauthenticated muse pane never exits, it waits on a device-code prompt, so credentials are a spawn preflight rather than a screen check. muse is refused for secondmates: it has no primary supervision protocol and its hook dialect rejects the reawakening handlers that protocol needs. Per the captain's decision, auto-update is not pinned, and the credentialed multi-step smoke is deferred with an explicit checklist in docs/verification/muse.md. * no-mistakes(review): Accept Muse dispatch profiles and shared efforts * no-mistakes(review): Bind Muse busy state to current session * no-mistakes(review): Compare Muse workspace bindings literally * no-mistakes(review): Harden Muse worker credentials and live signal verification * no-mistakes(review): Cache Muse session bindings and clarify worker credentials * no-mistakes(review): Clear Muse marker inheritance and normalize interrupt aliases * no-mistakes(review): Verify Muse glyph effective foreground color * no-mistakes(review): Harden Muse XDG paths, session cache, and glyph parsing * no-mistakes(document): Document Muse adapter boundaries * fix(herdr): require 0.8.0 for default presentation spaces (#1787) * fix(herdr): floor default-on presentation spaces at Herdr 0.8.0 Default-on presentation projection turns every crewmate teardown into a workspace-emptying removal. The focus-safe removal plan avoids Herdr's focus-stealing explicit close only while the doomed pane's shell can be proved lone, childless, and idle; a persistent child of that shell (gitstatusd, a zsh-async worker, direnv) fails that proof permanently and forces the plain close, which on every release before Herdr 0.8.0 moves the captain's active workspace for ~140ms on each teardown. Gate the unconfigured default behind a Herdr 0.8.0 floor. At or above it, project as before; below it, fall back to the flat per-home layout with one warning per home per detected release naming the version and the upgrade. An explicit "on" - including the historical empty opt-in file - is still honored below the floor, so a deliberate opt-in is never silently downgraded. The floor reads two independent signals from the client's own status, either of which can establish a supported release: the protocol number and the release core of the version string. Measured against the real release binaries, no build lacking both upstream focus fixes reaches protocol 19 and every pre-fix build tops out at 17, so protocol 19 is a safe structural expression of the floor. A release that reports neither signal readably is treated as unsupported rather than guessed at. Also: - Correct the adapter comment claiming the mitigation "stays safe without any version gate". That holds for the pane-death route only; the plain-close fallback is reachable precisely on the releases where it is unsafe. - Stop discarding the projected-close helper's stderr at teardown, so a refused or failed focus restore is visible instead of silent. The close stays non-fatal; the presence gate still decides record removal. - Add Part C to the focus-flash regression: a doomed pane whose shell holds a persistent child, in the geometry where the closing workspace's right neighbour is not the anchor. That is the fallback branch the suite could not structurally reach. On 0.7.5 it observes a bounded four-sample wrong-focus window restored exactly; on 0.8.0 it observes none. It also cross-checks its own measurement against the floor classifier, so a drifted protocol mapping fails loudly. - Make the projection suite's unconfigured-home case release-aware, so the whole real-Herdr lane passes on both the CI-pinned 0.7.4 and 0.8.0. - Add an opt-in live guard that re-measures the release-to-protocol mapping against the pinned upstream binaries. The immediate no-code mitigation for a home that cannot upgrade remains writing "off" into config/herdr-presentation-spaces. * no-mistakes(review): Pin Herdr live-guard digests across supported platforms * no-mistakes(review): Document authorized Herdr cleanup containment * no-mistakes(review): Harden Herdr warning marker publication * no-mistakes(review): Honor running Herdr server presentation floor * no-mistakes(review): Recheck Herdr floor after server ensure * no-mistakes(review): Refresh 0.7.5 and 0.8.0 focus transcripts * no-mistakes(review): Route Herdr floor probe through lab session * no-mistakes(document): Align Herdr floor documentation and comments * no-mistakes(lint): Document Herdr presentation out-parameter consumer * fix(bin): classify settled Muse session logs as idle (#1788) * fix(muse): trust the settled session log as idle The credentialed multi-step smoke on Muse Code 0.1.0-R708.1 answered the one question the idle half was held back for: one real 75-second tool-loop turn with 23 tool batches stays inside exactly one run started/terminal pair, and an Escape mid tool loop closes that run as cancelled rather than leaving the turn to continue in another run. A settled log is therefore a finished turn, not a pause between the runs of one turn. Remove fm_busy_muse_idle_verified and FM_BUSY_MUSE_IDLE_VERIFIED_VERSIONS outright rather than pinning them to a version: the session log's own metadata carries only semver 0.1.0 and a build sha, so a version allowlist could not actually match the running build and would be false precision. A settled log now classifies idle, an open run still classifies busy, and only a resolution failure - no binding, no matching log, an unreadable or run-free log - stays unknown. Record the evidence in docs/verification/muse.md, including the run-scoped grep the counts must use, and keep the post-upgrade re-check guidance. * no-mistakes(review): Document Muse idle trust and remove stale gate reference * no-mistakes(document): Clarify Muse idle verification ownership * docs(agents): read the persisted digest when only a preview is shown (#1794) * fix: preserve fleet state in truncated session-start digests (#1798) * feat(session-start): order the startup digest for truncation safety and bound its bulk The digest is delivered through a harness that truncates an oversized payload from the tail, and it really has been truncated: a 70KB digest arrived as lines 1-435 of 578, cutting off eight lines before the live-task inventory. That session took the helm without ever seeing which tasks were live or where their endpoints were. Three changes, one file's worth of composition: - FLEET STATE is emitted before CONTEXT, so a truncated tail drops curated memory - stable session to session, already governed by a captain-set budget, recoverable with one targeted read - instead of live fleet identity. The LOCK/BOOTSTRAP/WAKE-QUEUE safety preamble keeps its order. The read-once contract moves out of the closing reminder into its own section ahead of both, and now names the condition that voids it: a stage the truncation banner reports as never emitted. - Status-tail lines are capped per line, reusing the cut the wake digest's OPEN DECISIONS section already applies. An observed tail line ran 865 characters and nothing bounded it. The cut and its marker now live in one place, bin/fm-line-cap-lib.sh, so the two digests cannot drift apart; each task's full status log path is still printed beside its tail. - The backlog listing is composed as a recovery input: done rows are never listed, every in-flight, held, and blocked row is shown in full with its hold and blocked-by metadata, and only the dispatchable-now listing is bounded - with an exact remainder count and the command that shows the rest. FM_SESSION_START_QUEUED_LIMIT (default 20) replaces FM_SESSION_START_BACKLOG_LIMIT, which bounded the whole listing indiscriminately and so could drop a held or blocked row. Tests exercise the real digest output: section ordering with the preamble pinned, the per-line cap and its marker, and the backlog composition including the remainder counters on both the tasks-axi and manual paths. * no-mistakes(document): Clarify digest source recovery comments * no-mistakes(review): Restore newest-node fallback for unresolvable nvm LTS aliases Captain decision (key=nvm-lts-unresolvable, option a): an lts/* default selector whose alias file is missing can no longer be resolved offline, so compose the operator PATH from the newest installed nvm version instead of dropping nvm entirely. Resolvable LTS aliases still resolve explicitly through the alias chain; a 'system' default still yields no nvm path. * no-mistakes(review): Downgrade no-evidence lsof reap refusals under --force Route the lsof-scan-failure and unverifiable-process-identity refusals in reap_task_worktree_processes through refuse_backend_reap, matching the lsof-absent path per the captain-approved no-evidence vs positive-proof asymmetry: fail-closed REFUSED by default, loud warning and proceed under --force. Positive-proof refusals (processes remain after bounded reap attempts) stay hard even under --force. * feat(send): close answered decisions at answer time via --resolve-key (#1842) A captain decision opened by a keyed needs-decision:/blocked: status line orphaned as permanently open whenever the answer kicked off work: the worker's next event is working [key=<workstream>] in a different key namespace, so no resolved [key=<decision>] ever landed and the OPEN DECISIONS fold kept listing the answered decision forever. Remove the writer-dependency at its source: the answering firstmate already holds the decision key when it sends the answer, so fm-send's new --resolve-key flag (repeatable) appends the closing resolved line to this home's own state/<id>.status after the submit is confirmed. The close is a local ledger append for crewmates, local secondmates, and remote secondmates alike - a remote mate's escalations reach this ledger through the parent-replies ingest, so only the answer message crosses the transport. Safety: each named key must currently be open per the authoritative status_open_decisions fold or fm-send refuses before sending; a failed or unconfirmed send never closes a key; an append failure after a delivered answer exits nonzero with the manual close command so the decision re-surfaces instead of silently vanishing; a send without the flag closes nothing, and working:/done: still never clear a captain decision. Complementary fixes: the wake-drain OPEN DECISIONS section prints the answer-with-close command hint at the moment of use; brief scaffolds separate resolved's two duties (keyed-phase end vs decision closure) and state that a done:/working: line never closes a decision even when the answer started that work, keeping worker self-close for blockers that clear without a firstmate reply; AGENTS.md and docs/architecture.md carry the one-line pointers to the fm-send contract. * fix(bin): seed remote secondmates from supplied origins (#1836) * feat(secondmate): seed a remote home from a supplied project origin Remote seeding required a local projects/<name> clone purely to read `git remote get-url origin` into the provisioning manifest, so setting up a remote second mate forced disposable clones and no-mistakes inits in the primary home for projects that home has no reason to hold. Firstmate now resolves the origin itself and names it as <project>=<origin-url>. The seed validates and transports what it is given, and the receiving host re-validates it rather than trusting the sender; bin/fm-project-origin-lib.sh is the single owner of which URLs are accepted, refusing executable remote-helper transports, option-shaped values, and unusable spellings at both ends. A bare <project> still reads an already-present clone's origin, so nothing that works today has to change. Registry consistency is unchanged: an unregistered or local-only project is still refused. A remote seed therefore creates nothing in the primary home beyond the route, the charter, and its launch record. The lifecycle test now seeds a registered project the primary has never cloned and asserts the primary project tree is byte-identical afterwards, alongside refusals for a missing origin, an unsafe origin, a local-only project, and an unregistered project. * no-mistakes(review): Clarify project origin documentation ownership * no-mistakes(document): Document supplied-origin remote seeding contract * feat(secondmate): accept project origins from any host or forge Firstmate is a shared template, so a project origin must be able to name any host: GitHub Enterprise on a private domain, GitLab hosted or self-hosted, Bitbucket, Gitea, Codeberg, sr.ht, a bare IP, an SSH config alias, or a plain server nobody else has heard of. The validator already decided on structure rather than on a forge allowlist, and this makes that guarantee explicit and closes the two gaps that a host-agnostic rule exposed: - a bracketed IPv6 literal in the scp-like form is now accepted, so a host reachable only by address is not excluded - a "/../" traversal inside a local or file: origin is now refused, because that names a path on the cloning host's own filesystem The library is the single owner of the accepted forms, and its header says plainly that there is no host, domain, or forge allowlist and there must never be one. The skill keeps its distinct agent-operating lines (the agent resolves and supplies the origin; a remote seed creates nothing in the primary home beyond the route, the charter, and its launch record) and points at the library for URL acceptance and at the operator doc for the rest. The lifecycle test now drives Bitbucket, a self-hosted enterprise domain, a self-hosted GitLab over ssh with a port, and a bare scp-like custom host through the real seed, manifest, transport, and remote provisioning path in one seed, asserting each URL reaches git unchanged and each clone carries its own origin's content. The unit matrix leads with non-GitHub hosts for the same reason. * no-mistakes(review): Validate project origin authorities safely * no-mistakes: apply CI fixes * no-mistakes: apply CI fixes * fix(tests): restore reliable fm-send backend parity coverage (#1851) * fix(tests): copy the whole bin/ tree into the old-vs-new conformance shim main went red on tests/fm-backend.test.sh's "fm-send --key: old vs new exit code" assertion, which reads as an fm-send fail-closed regression from build_old_bin enumerated by hand the sibling scripts it copied into the synthetic pre-refactor tree. #1842 made bin/fm-send.sh source bin/fm-line-cap-lib.sh (added by #1798) and the list never learned about it, so the pinned old fm-send.sh aborted at `. "$SCRIPT_DIR/fm-line-cap-lib.sh"` under set -eu and exited 1 before parsing a single argument, while the current one delivered the key and exited 0. The parity check compared a crashed process against a working one and reported a behavior divergence that never happened - the more so because BASE_REF collapses to HEAD on main, where both sides run byte-identical source and a genuine divergence is impossible. fm-send's --key exit path is unchanged and its fail-closed contract is intact. Copy the tree whole instead of enumerating it. An enumerated list has to be extended by hand every time an entrypoint gains a dependency and is the only thing that knows; it has been patched a dozen times for exactly that. A whole-tree copy has nothing to forget. Extracting a refactored entrypoint the baseline does not have now fails loudly instead of writing an empty file. Only old-vs-new parity covered that exit contract, and parity is near-vacuous on main. Pin it directly: tests/fm-send-strict.test.sh drives delivery both ways from one stub and asserts an undelivered key exits nonzero naming the key, so swallowing that error fails the suite. * no-mistakes(review): Materialize historical fixture dependencies from baseline * no-mistakes(document): Clarify fm-send key regression scope * fix(bin): mirror remote secondmate status streams (#1846) * fix(bin): mirror the whole remote secondmate status stream A remote secondmate's reply channel required corr=<16hex> on every line and failed the entire delta when one line lacked it, so the cursor could never advance past that line and the channel wedged permanently. The charter tells a secondmate to report its own progress phases and to raise new decisions with no correlation token, because correlation only answers a marked parent request. Those lines were therefore unrepresentable on the remote channel, while a local secondmate writes them straight into the parent's status file. Treat the channel as what it is: a mirror of the mate's status stream. A remote mate now presents the same status and decision model as a local one, so a newly raised needs-decision reaches the parent's open-decision fold identically, and correlation goes back to being a per-line property that settles a pending request rather than a gate on the stream. Only what crossing a machine boundary genuinely adds stays behind: cursor continuity, confined document fetch and rewrite, at-most-once append, and control-byte normalization that rewrites bytes without ever dropping a line. Line framing and size bounding already belong to fm-remote-delta-read.sh. A document the remote reader refuses is named in one escalation instead of stalling the stream, while an unavailable transport still leaves the delta for the existing retry. * refactor(bin): give the remote reply stream one append owner Every line entering the parent status stream - a mirrored line, the continuity escalation, and the undelivered-document escalation - now goes through one at-most-once append, so the idempotence a replayed generation depends on is stated once instead of copied at three call sites. * no-mistakes(review): Keep local document transfer failures retryable * no-mistakes(review): Isolate reply headers and normalize payload bytes * no-mistakes(review): Correct remote reply mirror contract wording * no-mistakes(review): Update remote reply script catalog description * no-mistakes(document): Document remote status-stream mirroring * docs(agents): describe the digest's fleet-state-before-context order (#1826) * fix(bin): fail closed on NUL bytes in the durable parent binding (#1847) fm_secondmate_parent_record_parse read the .fm-secondmate-parent record with bash's read, which drops NUL bytes - and different bash generations disagree on the result: 3.2 truncates the value at the NUL while 5.x splices the surrounding bytes together. A NUL-bearing parent_home could therefore resolve to a home the record's bytes never name contiguously, and which home fm-teardown.sh's promised-public-reply resolution read (registration, registry, relay state) - or whether that protection engaged at all - depended on which interpreter ran the cleanup. Reproduced end to end: the same NUL-bearing record cleaned up under bash 5.x by resolving the spliced-together registered parent, while bash 3.2 refused it as unresolved, and a literal truncated path refused under both. Reject any NUL byte in the record before field parsing, putting corrupt records in the same fail-closed bucket as duplicate fields, malformed local bindings, unsupported routes, and symlinked records. The regression test drives the real bin/fm-teardown.sh over the proven clean-cleanup fixture with a NUL spliced mid-path into the recorded parent_home, so before the fix it reproduced the wrong-home cleanup and now it must refuse with the explicit binding refusal. * fix(skills): reconcile inherited secondmate plans with shipped state (#1853) * docs(secondmate-provisioning): require record intake for an inherited domain A new mate seeded for an existing or inherited domain previously pulled in charter, inherited config, captain-shared preferences, project clones, and queued backlog rows with zero instruction about the domain's shipped history, so it assumed a greenfield domain. A live backlog keeps only the configured recent Done entries, so an inherited queue structurally over-represents plans and under-represents deliveries, and already-delivered work resurfaced as open. Add a record-intake step to the creation/seed path: classify greenfield versus existing or inherited, and for the latter reconcile every inherited plan against origin/main plus the live deployment, take only genuinely open work and still-live durable knowledge, never carry a plan row for shipped work, and record what could not be reconciled. Greenfield domains are untouched. The skill owns the procedure; the backlog handoff section carries a one-line reinforcement at the point where plan rows actually move. * no-mistakes(document): Clarify secondmate record-intake scope * no-mistakes(review): add regression test pinning hard persistent-leak refusal under --force * no-mistakes(test): wait for async runner claims instead of fixed sleeps The detached procevent runner publishes its claim file atomically but asynchronously after reconcile returns. Two assertions sampled that state after a fixed 0.5s sleep, which races runner startup on a slow machine: the orphan-runner test read the claim pid, and the crashed-leader test checked claim existence plus the replacement's started line. Replace the fixed sleeps with bounded waits on the observable state itself. Verified against unchanged bin/fm-procevent machinery: the runner claim appears at ~500ms here, right at the old sleep's edge. * no-mistakes(document): document --force no-evidence reap downgrade in teardown usage header --------- Co-authored-by: Kun Chen <3233006+kunchenguid@users.noreply.github.com> Co-authored-by: Christopher McKay <101884182+karotkriss@users.noreply.github.com> Co-authored-by: kunchenguid <kun-1@kunchenguid.com>
mjskinner82
added a commit
to mjskinner82/firstmate
that referenced
this pull request
Aug 7, 2026
* feat(send): close answered decisions at answer time via --resolve-key (kunchenguid#1842) A captain decision opened by a keyed needs-decision:/blocked: status line orphaned as permanently open whenever the answer kicked off work: the worker's next event is working [key=<workstream>] in a different key namespace, so no resolved [key=<decision>] ever landed and the OPEN DECISIONS fold kept listing the answered decision forever. Remove the writer-dependency at its source: the answering firstmate already holds the decision key when it sends the answer, so fm-send's new --resolve-key flag (repeatable) appends the closing resolved line to this home's own state/<id>.status after the submit is confirmed. The close is a local ledger append for crewmates, local secondmates, and remote secondmates alike - a remote mate's escalations reach this ledger through the parent-replies ingest, so only the answer message crosses the transport. Safety: each named key must currently be open per the authoritative status_open_decisions fold or fm-send refuses before sending; a failed or unconfirmed send never closes a key; an append failure after a delivered answer exits nonzero with the manual close command so the decision re-surfaces instead of silently vanishing; a send without the flag closes nothing, and working:/done: still never clear a captain decision. Complementary fixes: the wake-drain OPEN DECISIONS section prints the answer-with-close command hint at the moment of use; brief scaffolds separate resolved's two duties (keyed-phase end vs decision closure) and state that a done:/working: line never closes a decision even when the answer started that work, keeping worker self-close for blockers that clear without a firstmate reply; AGENTS.md and docs/architecture.md carry the one-line pointers to the fm-send contract. * fix(bin): seed remote secondmates from supplied origins (kunchenguid#1836) * feat(secondmate): seed a remote home from a supplied project origin Remote seeding required a local projects/<name> clone purely to read `git remote get-url origin` into the provisioning manifest, so setting up a remote second mate forced disposable clones and no-mistakes inits in the primary home for projects that home has no reason to hold. Firstmate now resolves the origin itself and names it as <project>=<origin-url>. The seed validates and transports what it is given, and the receiving host re-validates it rather than trusting the sender; bin/fm-project-origin-lib.sh is the single owner of which URLs are accepted, refusing executable remote-helper transports, option-shaped values, and unusable spellings at both ends. A bare <project> still reads an already-present clone's origin, so nothing that works today has to change. Registry consistency is unchanged: an unregistered or local-only project is still refused. A remote seed therefore creates nothing in the primary home beyond the route, the charter, and its launch record. The lifecycle test now seeds a registered project the primary has never cloned and asserts the primary project tree is byte-identical afterwards, alongside refusals for a missing origin, an unsafe origin, a local-only project, and an unregistered project. * no-mistakes(review): Clarify project origin documentation ownership * no-mistakes(document): Document supplied-origin remote seeding contract * feat(secondmate): accept project origins from any host or forge Firstmate is a shared template, so a project origin must be able to name any host: GitHub Enterprise on a private domain, GitLab hosted or self-hosted, Bitbucket, Gitea, Codeberg, sr.ht, a bare IP, an SSH config alias, or a plain server nobody else has heard of. The validator already decided on structure rather than on a forge allowlist, and this makes that guarantee explicit and closes the two gaps that a host-agnostic rule exposed: - a bracketed IPv6 literal in the scp-like form is now accepted, so a host reachable only by address is not excluded - a "/../" traversal inside a local or file: origin is now refused, because that names a path on the cloning host's own filesystem The library is the single owner of the accepted forms, and its header says plainly that there is no host, domain, or forge allowlist and there must never be one. The skill keeps its distinct agent-operating lines (the agent resolves and supplies the origin; a remote seed creates nothing in the primary home beyond the route, the charter, and its launch record) and points at the library for URL acceptance and at the operator doc for the rest. The lifecycle test now drives Bitbucket, a self-hosted enterprise domain, a self-hosted GitLab over ssh with a port, and a bare scp-like custom host through the real seed, manifest, transport, and remote provisioning path in one seed, asserting each URL reaches git unchanged and each clone carries its own origin's content. The unit matrix leads with non-GitHub hosts for the same reason. * no-mistakes(review): Validate project origin authorities safely * no-mistakes: apply CI fixes * no-mistakes: apply CI fixes * fix(tests): restore reliable fm-send backend parity coverage (kunchenguid#1851) * fix(tests): copy the whole bin/ tree into the old-vs-new conformance shim main went red on tests/fm-backend.test.sh's "fm-send --key: old vs new exit code" assertion, which reads as an fm-send fail-closed regression from build_old_bin enumerated by hand the sibling scripts it copied into the synthetic pre-refactor tree. kunchenguid#1842 made bin/fm-send.sh source bin/fm-line-cap-lib.sh (added by kunchenguid#1798) and the list never learned about it, so the pinned old fm-send.sh aborted at `. "$SCRIPT_DIR/fm-line-cap-lib.sh"` under set -eu and exited 1 before parsing a single argument, while the current one delivered the key and exited 0. The parity check compared a crashed process against a working one and reported a behavior divergence that never happened - the more so because BASE_REF collapses to HEAD on main, where both sides run byte-identical source and a genuine divergence is impossible. fm-send's --key exit path is unchanged and its fail-closed contract is intact. Copy the tree whole instead of enumerating it. An enumerated list has to be extended by hand every time an entrypoint gains a dependency and is the only thing that knows; it has been patched a dozen times for exactly that. A whole-tree copy has nothing to forget. Extracting a refactored entrypoint the baseline does not have now fails loudly instead of writing an empty file. Only old-vs-new parity covered that exit contract, and parity is near-vacuous on main. Pin it directly: tests/fm-send-strict.test.sh drives delivery both ways from one stub and asserts an undelivered key exits nonzero naming the key, so swallowing that error fails the suite. * no-mistakes(review): Materialize historical fixture dependencies from baseline * no-mistakes(document): Clarify fm-send key regression scope * fix(bin): mirror remote secondmate status streams (kunchenguid#1846) * fix(bin): mirror the whole remote secondmate status stream A remote secondmate's reply channel required corr=<16hex> on every line and failed the entire delta when one line lacked it, so the cursor could never advance past that line and the channel wedged permanently. The charter tells a secondmate to report its own progress phases and to raise new decisions with no correlation token, because correlation only answers a marked parent request. Those lines were therefore unrepresentable on the remote channel, while a local secondmate writes them straight into the parent's status file. Treat the channel as what it is: a mirror of the mate's status stream. A remote mate now presents the same status and decision model as a local one, so a newly raised needs-decision reaches the parent's open-decision fold identically, and correlation goes back to being a per-line property that settles a pending request rather than a gate on the stream. Only what crossing a machine boundary genuinely adds stays behind: cursor continuity, confined document fetch and rewrite, at-most-once append, and control-byte normalization that rewrites bytes without ever dropping a line. Line framing and size bounding already belong to fm-remote-delta-read.sh. A document the remote reader refuses is named in one escalation instead of stalling the stream, while an unavailable transport still leaves the delta for the existing retry. * refactor(bin): give the remote reply stream one append owner Every line entering the parent status stream - a mirrored line, the continuity escalation, and the undelivered-document escalation - now goes through one at-most-once append, so the idempotence a replayed generation depends on is stated once instead of copied at three call sites. * no-mistakes(review): Keep local document transfer failures retryable * no-mistakes(review): Isolate reply headers and normalize payload bytes * no-mistakes(review): Correct remote reply mirror contract wording * no-mistakes(review): Update remote reply script catalog description * no-mistakes(document): Document remote status-stream mirroring * docs(agents): describe the digest's fleet-state-before-context order (kunchenguid#1826) * fix(bin): fail closed on NUL bytes in the durable parent binding (kunchenguid#1847) fm_secondmate_parent_record_parse read the .fm-secondmate-parent record with bash's read, which drops NUL bytes - and different bash generations disagree on the result: 3.2 truncates the value at the NUL while 5.x splices the surrounding bytes together. A NUL-bearing parent_home could therefore resolve to a home the record's bytes never name contiguously, and which home fm-teardown.sh's promised-public-reply resolution read (registration, registry, relay state) - or whether that protection engaged at all - depended on which interpreter ran the cleanup. Reproduced end to end: the same NUL-bearing record cleaned up under bash 5.x by resolving the spliced-together registered parent, while bash 3.2 refused it as unresolved, and a literal truncated path refused under both. Reject any NUL byte in the record before field parsing, putting corrupt records in the same fail-closed bucket as duplicate fields, malformed local bindings, unsupported routes, and symlinked records. The regression test drives the real bin/fm-teardown.sh over the proven clean-cleanup fixture with a NUL spliced mid-path into the recorded parent_home, so before the fix it reproduced the wrong-home cleanup and now it must refuse with the explicit binding refusal. * fix(skills): reconcile inherited secondmate plans with shipped state (kunchenguid#1853) * docs(secondmate-provisioning): require record intake for an inherited domain A new mate seeded for an existing or inherited domain previously pulled in charter, inherited config, captain-shared preferences, project clones, and queued backlog rows with zero instruction about the domain's shipped history, so it assumed a greenfield domain. A live backlog keeps only the configured recent Done entries, so an inherited queue structurally over-represents plans and under-represents deliveries, and already-delivered work resurfaced as open. Add a record-intake step to the creation/seed path: classify greenfield versus existing or inherited, and for the latter reconcile every inherited plan against origin/main plus the live deployment, take only genuinely open work and still-live durable knowledge, never carry a plan row for shipped work, and record what could not be reconciled. Greenfield domains are untouched. The skill owns the procedure; the backlog handoff section carries a one-line reinforcement at the point where plan rows actually move. * no-mistakes(document): Clarify secondmate record-intake scope * fix: move network checks off the session-start blocking path (kunchenguid#1860) * perf(session-start): run every network check off the blocking path The session-start digest runs on a session-open hook that blocks session initialization, and every external-network call it made was individually unbounded: `gh auth status`, secondmate liveness, secondmate convergence, pending remote handoff delivery, and the fleet-sync fetch. One unreachable remote secondmate could consume the whole FM_SESSION_START_TIMEOUT and truncate the digest, so a slow network could cost the work queue itself. Measured against a host hanging 25s per SSH connection, that startup took 1m18s. The digest is now composed from local reads alone. bin/fm-startup-network.sh runs the same checks concurrently in a bounded detached worker and the digest harvests whatever finished, without ever waiting. Same fixture: 0.84s. Nothing is dropped. fm-bootstrap.sh stays the single owner of every sweep and still runs all of them, through a new FM_BOOTSTRAP_NETWORK phase split whose `skip` and `only` halves are a partition of the unsplit run. Deferral is safe because the sweeps are idempotent detectors, the result is durable and always surfaces (inline, or as a `check: startup-network` wake), and the worker re-verifies that the fleet lock still names the session that asked before it mutates anything. While the worker is still running the digest names exactly what is unconfirmed rather than implying it passed. A relaunch performed by the deferred pass is now always reported, because the digest that printed the superseded endpoint record is already out. Also collapses the duplicate tasks-axi compatibility probe: the verdict is computed once and handed to the bootstrap child for one process hop, then consumed so it never reaches a spawned agent's environment. 10 tasks-axi invocations per startup become 7. Verified on Claude Code 2.1.222 that a worker detached by the session-open hook survives the hook returning, the one vendor behavior this design needs and no portable test can see. Re-landed on current main, superseding PR kunchenguid#1845, which was cut from a pre-kunchenguid#1842 base. The digest's section numbering in AGENTS.md section 3 now states the emission order directly - supervision block and its read-once contract, fleet state, network checks, then context - which keeps kunchenguid#1826's fleet-state-before-context ordering. The old-bin test shim keeps main's git-archive baseline from kunchenguid#1851, which already subsumes this branch's reason for widening that shim. * docs(verification): re-measure the deferred startup stage on the current base Re-runs the unreachable-remote latency fixture against default-branch tip 8398d31 rather than the now-historical 345de4e, and records the sweep-result comparison the deferral's safety argument rests on: the deferred worker's published report is byte-identical to the three sweep lines the blocking baseline printed, with the unreachable route preserved in both. * no-mistakes(review): Fail deferred startup when report publication fails * no-mistakes(document): Document deferred startup network behavior accurately * fix(procevent): apply remote replies during capture (kunchenguid#1831) * fix(procevent): apply a captured adapter result in code, not by instruction A remote secondmate's reply was captured and announced, but never applied. Nothing dispatched the reply adapter's `handle` on a `procevent remote-reply` wake, and the handling instruction named only the generic acknowledgement, so the wake was retired while everything it carried was dropped: the reply never reached the secondmate's local status mirror, the request it answered kept escalating as a missed report, and the relay - whose registration each capture retires, and which only that same handling re-arms - was left dead until the next session start armed it again. Applying such a result carries no judgement, so it belongs in code. After publishing, the runner now calls `bin/fm-procevent-<adapter>.sh autohandle <source-id> <sequence> <result-file>` and lets the adapter apply and acknowledge its own result, through the same kind of seam that already owns the terminal verdict. It runs strictly after terminal retirement, because a handling adapter re-arms its own next source and retiring afterwards would drop that fresh registration. An adapter with no such command, or one whose pass does not complete, leaves the result unacknowledged and therefore still announced, so a handler receives it exactly as before. Resolving the request was not enough on its own either. An escalation opens a durable keyed decision in the parent status log, and nothing ever closed it, so a request the remote had answered kept surfacing in every later open-decisions fold. The pending-reply library now owns both ends of that decision: it opens one under a per-request key rather than the shared default key, and closes it once the record resolves, appending the closing line only while that exact decision is still open in the fold so it can neither double-close nor clear an unrelated decision that has since taken the same key. The handling instruction still routes a wake to its adapter, now as the idempotent confirmation of what the runner already did rather than as the guarantee. Verified end to end in a throwaway isolated home driving the real armed source, blocking delta reader, runner, and wake queue, with the handler doing only the generic acknowledgement and no part of the ingest stubbed: before, seven failed observations reproducing the incident; after, none. Each half is independently load-bearing - without the runner change the reply never reaches the mirror, without the escalation close the settled request still surfaces as an open decision. * no-mistakes(review): Prevent legacy reply closure from masking decisions * no-mistakes(review): Serialize pending reply resolution and escalation closure * no-mistakes(review): Serialize pending reply escalation with resolution * no-mistakes(review): Clarify guarded legacy escalation closure behavior * no-mistakes(review): Guard legacy closure and reserve pending reply keys * no-mistakes(review): Match pending reply escalations by construction * no-mistakes(document): Document automatic remote reply resolution * no-mistakes(lint): Fix unused concurrent escalation loop variable * no-mistakes(lint): Fix unused concurrent resolution loop binding * no-mistakes(review): Version fold cache and gate autohandle on publication * no-mistakes(document): Clarify remote reply relay documentation * feat(skills): port internal stow curation disciplines to the public skill (kunchenguid#1841) Bring the public installer-facing stow skill up to the internal skill's current curation behavior while keeping it fully standalone: - Replace the total-capture thesis with the compact-operating-map framing. - Add read-the-destination-before-writing with the inspect-then-update triad (supersedes what, one-sentence rewrite, delete stale now). - Add the concrete prune list together with its unique-fact guard, as an accuracy discipline with no size-budget machinery. - Curate every memory file the pass has open, not only the routed one. - Add the standing-decisions sweep category. - Add the stronger-owner pointer-over-copy test before filing. - Add tool-agnostic task-note discipline (inspect, classify, considered replacement body, never blind-append) and blocked-on recording. - Give .stow-notes.md a closed set of three exits. - Forbid storing, creating, or editing a skill as a stow destination. - Report per-file action verbs in the completion receipt. - Consolidate the repeated local-vs-external and .gitignore prose and fix the second-person voice slip, so the file does not grow (11334 -> 11276 bytes). * chore(bootstrap): raise lavish-axi version floor to 0.1.46 (kunchenguid#1865) * feat(bin): add maintenance agent grading loop * no-mistakes(review): Captain, serialize ledger writes and reject duplicate keys * no-mistakes(document): Document grading helper in toolbelt index --------- Co-authored-by: Kun Chen <3233006+kunchenguid@users.noreply.github.com>
huynhtandat223
pushed a commit
to huynhtandat223/firstmate
that referenced
this pull request
Aug 7, 2026
…chenguid#1847) fm_secondmate_parent_record_parse read the .fm-secondmate-parent record with bash's read, which drops NUL bytes - and different bash generations disagree on the result: 3.2 truncates the value at the NUL while 5.x splices the surrounding bytes together. A NUL-bearing parent_home could therefore resolve to a home the record's bytes never name contiguously, and which home fm-teardown.sh's promised-public-reply resolution read (registration, registry, relay state) - or whether that protection engaged at all - depended on which interpreter ran the cleanup. Reproduced end to end: the same NUL-bearing record cleaned up under bash 5.x by resolving the spliced-together registered parent, while bash 3.2 refused it as unresolved, and a literal truncated path refused under both. Reject any NUL byte in the record before field parsing, putting corrupt records in the same fail-closed bucket as duplicate fields, malformed local bindings, unsupported routes, and symlinked records. The regression test drives the real bin/fm-teardown.sh over the proven clean-cleanup fixture with a NUL spliced mid-path into the recorded parent_home, so before the fix it reproduced the wrong-home cleanup and now it must refuse with the explicit binding refusal.
cipherholdingsllc
added a commit
to cipherholdingsllc/firstmate
that referenced
this pull request
Aug 7, 2026
* fix(bin): prevent remote polls from blocking session startup (kunchenguid#1754) * fix(bin): preempt remote reply long-polls for queued short jobs Session start on a home with live remote second mates could stall silently for many minutes: the single serial remote job worker ran each armed fm-remote-delta-read.sh reply poll to its full 55s window while bootstrap's short sync, inherit, state, and route commands sat queued behind it, and non-FIFO queue pickup let re-armed polls keep winning the lane. Measured end to end, a trivial short job took 31s behind one 30s poll window. The worker now preempts a running preemptible job (the read-only, cursor- anchored delta read is the only member of that class) as soon as a non-preemptible job is queued, publishing exit 75 with emptied output - byte-identical to the poll's own elapsed-window-with-no-data result - so the parent runner takes its existing no-result path and the watcher re-arms from the same cursor with nothing lost. The delta read translates SIGTERM into that same exit after removing its staging directory. Sibling polls never preempt each other, so two armed monitors cannot churn. The same measured scenario now completes in 1s. * no-mistakes(document): Clarify remote poll preemption documentation * docs: present X mode as the X and Discord public surface (kunchenguid#1778) Discord mentions already ride the same pairing-token opt-in, relay poll, and platform-aware reply path as X mentions, but the docs still read as X-only, so a stranger could not self-serve the Discord path. Add the numbered turn-on steps to the X mode configuration reference, pointing at the myfirstmate dashboard for account creation, bot install, and token issuance rather than duplicating operator setup here, and drop the X-only framing from the README bullet, the documentation index, and the architecture overview. * fix(bin): run session start deterministically from hooks (kunchenguid#1781) * feat(bin): run session start deterministically on hook-capable harnesses Session start relied on a native nudge that only asked the agent to run bin/fm-session-start.sh, and an agent can defer that. Observed 2026-08-01: an /ahoy-first session followed the recap path and did not take the helm until a later request forced it. Claude, Codex, and Pi now RUN the digest in their session-open hook through the new bin/fm-sessionstart-run.sh, so the full ordered digest is in model context before the first turn. That wrapper is the single owner of what a session-open source means: startup and Pi's "new" take the helm, clear and compact re-emit, resume/reload/fork delegate to the nudge, and an unreadable source takes the helm because doing that redundantly is idempotent while skipping it is the bug. Grok and OpenCode keep the nudge as the floor, since neither can carry hook stdout into a model turn. Because the hook now blocks session initialization, fm-session-start.sh bounds itself first. Its steps are not all individually bounded - bootstrap's gh auth probe, tool version probes, the backlog listing and per-task endpoint reads are unbounded - so the whole digest runs as one bounded child (default 120s). Whatever it emitted before the bound survives, and the parent adds a loud STARTUP TRUNCATED banner naming the stage that stalled and every stage that never ran, still exiting 0. --reemit skips only the sweeps startup already reconciled. It still re-verifies lock ownership and still drains queued wakes, which arrived after startup and are the turn's work. fm-bootstrap.sh gains FM_BOOTSTRAP_LOCKED so a re-emit keeps repair ownership instead of deferring to a lock holder that is itself. Also adds bin/fm-timeout-lib.sh as the single owner of bounded execution, replacing three near-identical copies, and gives the ahoy skill a helm check so a nudge-tier harness cannot recap before taking the helm. Verified live on 2026-08-05 against Claude 2.1.222, Codex 0.146.0, and Pi 0.82.0; docs/verification/supervision.md records the per-harness source vocabulary, the two named gaps, and the refresh command. * no-mistakes(review): Harden session-start completion, timeout, and Pi delivery * no-mistakes(review): Harden completion ownership and portable timeout escalation * no-mistakes(review): Normalize watchdog KILL exits without masking command status * no-mistakes(review): Guarantee startup bounds and align harness delivery tiers * no-mistakes(test): Fix Pi session-start live verification fixture * no-mistakes(document): Align session-start documentation with deterministic hooks * no-mistakes(lint): Silence intentional child-shell expansion lint warning * no-mistakes: apply CI fixes * no-mistakes: apply CI fixes * fix: rename X mode to Relay in user-facing docs (kunchenguid#1784) * docs: rename the user-facing product name to Relay The public-mention integration gated by the `.env` pairing token is now called Relay across user-facing prose, covering X and Discord alike instead of implying a single network. Renames the product-name strings only: README, docs, the captain-facing skill descriptions, and the AGENTS.md operating prose, including the `X mode (.env)` and `Optional X mode` headings and every link anchor that pointed at them. AGENTS.md section 14 carries a one-line bridge note so the older name and the unchanged identifier spellings stay discoverable. Internal identifiers are untouched: `FMX_*`, `config/x-mode.env`, `state/x-*`, `bin/fm-x-*`, the `fmx-respond` skill path, `__FM_X_MODE_ENV__`, and `x-mode-error`. Platform references to X and Discord as networks stay as they are, and the bootstrap-diagnostics entry still quotes bootstrap's emitted `FMX: X mode on/off` line verbatim because `bin/` output is out of scope for this pass. * no-mistakes(review): Complete Relay prose rename in maintained docs * no-mistakes: apply CI fixes * feat: add Muse Code crewmate adapter (kunchenguid#1786) * feat(harness): add a verified muse crewmate adapter Muse Code joins the fleet as a crewmate/scout adapter, verified live against Muse Code 0.1.0-R708.1 in an isolated lab. Detection matches the anchored prefix muse-bin*, because the installed launcher execs a version-suffixed binary whose name changes on every auto-update and whose install path carries no muse component to fall back on. The same identity is taught to the tmux liveness classifier, without which a healthy muse pane would have read as a dead endpoint. Busy state folds muse's own durable session event log, bound per task by a sessions-root/worktree sidecar. It is a pull source with no writer, so nothing is armed and no record is ever seeded. The fold is anchored on the full run lifecycle prefix so muse's nested cleanup "terminal" payloads cannot settle an in-flight run, and it is depth-bounded so muse's native sub-agent logs cannot be mistaken for the parent's. The idle half stays gated: an open run proves busy, but a settled log reads unknown until a credentialed multi-step run proves one turn stays inside one run. Two findings corrected the scout report. The exec-only --no-foreign-personal-context flag is rejected by the interactive TUI, so the privacy control that actually reaches a pane worker is MUSE_EXPERIMENTAL_FOREIGN_PERSONAL_CONTEXT_KILL, verified to drop the operator's foreign personal rules while keeping the project's own AGENTS.md. And an unauthenticated muse pane never exits, it waits on a device-code prompt, so credentials are a spawn preflight rather than a screen check. muse is refused for secondmates: it has no primary supervision protocol and its hook dialect rejects the reawakening handlers that protocol needs. Per the captain's decision, auto-update is not pinned, and the credentialed multi-step smoke is deferred with an explicit checklist in docs/verification/muse.md. * no-mistakes(review): Accept Muse dispatch profiles and shared efforts * no-mistakes(review): Bind Muse busy state to current session * no-mistakes(review): Compare Muse workspace bindings literally * no-mistakes(review): Harden Muse worker credentials and live signal verification * no-mistakes(review): Cache Muse session bindings and clarify worker credentials * no-mistakes(review): Clear Muse marker inheritance and normalize interrupt aliases * no-mistakes(review): Verify Muse glyph effective foreground color * no-mistakes(review): Harden Muse XDG paths, session cache, and glyph parsing * no-mistakes(document): Document Muse adapter boundaries * fix(herdr): require 0.8.0 for default presentation spaces (kunchenguid#1787) * fix(herdr): floor default-on presentation spaces at Herdr 0.8.0 Default-on presentation projection turns every crewmate teardown into a workspace-emptying removal. The focus-safe removal plan avoids Herdr's focus-stealing explicit close only while the doomed pane's shell can be proved lone, childless, and idle; a persistent child of that shell (gitstatusd, a zsh-async worker, direnv) fails that proof permanently and forces the plain close, which on every release before Herdr 0.8.0 moves the captain's active workspace for ~140ms on each teardown. Gate the unconfigured default behind a Herdr 0.8.0 floor. At or above it, project as before; below it, fall back to the flat per-home layout with one warning per home per detected release naming the version and the upgrade. An explicit "on" - including the historical empty opt-in file - is still honored below the floor, so a deliberate opt-in is never silently downgraded. The floor reads two independent signals from the client's own status, either of which can establish a supported release: the protocol number and the release core of the version string. Measured against the real release binaries, no build lacking both upstream focus fixes reaches protocol 19 and every pre-fix build tops out at 17, so protocol 19 is a safe structural expression of the floor. A release that reports neither signal readably is treated as unsupported rather than guessed at. Also: - Correct the adapter comment claiming the mitigation "stays safe without any version gate". That holds for the pane-death route only; the plain-close fallback is reachable precisely on the releases where it is unsafe. - Stop discarding the projected-close helper's stderr at teardown, so a refused or failed focus restore is visible instead of silent. The close stays non-fatal; the presence gate still decides record removal. - Add Part C to the focus-flash regression: a doomed pane whose shell holds a persistent child, in the geometry where the closing workspace's right neighbour is not the anchor. That is the fallback branch the suite could not structurally reach. On 0.7.5 it observes a bounded four-sample wrong-focus window restored exactly; on 0.8.0 it observes none. It also cross-checks its own measurement against the floor classifier, so a drifted protocol mapping fails loudly. - Make the projection suite's unconfigured-home case release-aware, so the whole real-Herdr lane passes on both the CI-pinned 0.7.4 and 0.8.0. - Add an opt-in live guard that re-measures the release-to-protocol mapping against the pinned upstream binaries. The immediate no-code mitigation for a home that cannot upgrade remains writing "off" into config/herdr-presentation-spaces. * no-mistakes(review): Pin Herdr live-guard digests across supported platforms * no-mistakes(review): Document authorized Herdr cleanup containment * no-mistakes(review): Harden Herdr warning marker publication * no-mistakes(review): Honor running Herdr server presentation floor * no-mistakes(review): Recheck Herdr floor after server ensure * no-mistakes(review): Refresh 0.7.5 and 0.8.0 focus transcripts * no-mistakes(review): Route Herdr floor probe through lab session * no-mistakes(document): Align Herdr floor documentation and comments * no-mistakes(lint): Document Herdr presentation out-parameter consumer * fix(bin): classify settled Muse session logs as idle (kunchenguid#1788) * fix(muse): trust the settled session log as idle The credentialed multi-step smoke on Muse Code 0.1.0-R708.1 answered the one question the idle half was held back for: one real 75-second tool-loop turn with 23 tool batches stays inside exactly one run started/terminal pair, and an Escape mid tool loop closes that run as cancelled rather than leaving the turn to continue in another run. A settled log is therefore a finished turn, not a pause between the runs of one turn. Remove fm_busy_muse_idle_verified and FM_BUSY_MUSE_IDLE_VERIFIED_VERSIONS outright rather than pinning them to a version: the session log's own metadata carries only semver 0.1.0 and a build sha, so a version allowlist could not actually match the running build and would be false precision. A settled log now classifies idle, an open run still classifies busy, and only a resolution failure - no binding, no matching log, an unreadable or run-free log - stays unknown. Record the evidence in docs/verification/muse.md, including the run-scoped grep the counts must use, and keep the post-upgrade re-check guidance. * no-mistakes(review): Document Muse idle trust and remove stale gate reference * no-mistakes(document): Clarify Muse idle verification ownership * docs(agents): read the persisted digest when only a preview is shown (kunchenguid#1794) * fix: preserve fleet state in truncated session-start digests (kunchenguid#1798) * feat(session-start): order the startup digest for truncation safety and bound its bulk The digest is delivered through a harness that truncates an oversized payload from the tail, and it really has been truncated: a 70KB digest arrived as lines 1-435 of 578, cutting off eight lines before the live-task inventory. That session took the helm without ever seeing which tasks were live or where their endpoints were. Three changes, one file's worth of composition: - FLEET STATE is emitted before CONTEXT, so a truncated tail drops curated memory - stable session to session, already governed by a captain-set budget, recoverable with one targeted read - instead of live fleet identity. The LOCK/BOOTSTRAP/WAKE-QUEUE safety preamble keeps its order. The read-once contract moves out of the closing reminder into its own section ahead of both, and now names the condition that voids it: a stage the truncation banner reports as never emitted. - Status-tail lines are capped per line, reusing the cut the wake digest's OPEN DECISIONS section already applies. An observed tail line ran 865 characters and nothing bounded it. The cut and its marker now live in one place, bin/fm-line-cap-lib.sh, so the two digests cannot drift apart; each task's full status log path is still printed beside its tail. - The backlog listing is composed as a recovery input: done rows are never listed, every in-flight, held, and blocked row is shown in full with its hold and blocked-by metadata, and only the dispatchable-now listing is bounded - with an exact remainder count and the command that shows the rest. FM_SESSION_START_QUEUED_LIMIT (default 20) replaces FM_SESSION_START_BACKLOG_LIMIT, which bounded the whole listing indiscriminately and so could drop a held or blocked row. Tests exercise the real digest output: section ordering with the preamble pinned, the per-line cap and its marker, and the backlog composition including the remainder counters on both the tasks-axi and manual paths. * no-mistakes(document): Clarify digest source recovery comments * feat(send): close answered decisions at answer time via --resolve-key (kunchenguid#1842) A captain decision opened by a keyed needs-decision:/blocked: status line orphaned as permanently open whenever the answer kicked off work: the worker's next event is working [key=<workstream>] in a different key namespace, so no resolved [key=<decision>] ever landed and the OPEN DECISIONS fold kept listing the answered decision forever. Remove the writer-dependency at its source: the answering firstmate already holds the decision key when it sends the answer, so fm-send's new --resolve-key flag (repeatable) appends the closing resolved line to this home's own state/<id>.status after the submit is confirmed. The close is a local ledger append for crewmates, local secondmates, and remote secondmates alike - a remote mate's escalations reach this ledger through the parent-replies ingest, so only the answer message crosses the transport. Safety: each named key must currently be open per the authoritative status_open_decisions fold or fm-send refuses before sending; a failed or unconfirmed send never closes a key; an append failure after a delivered answer exits nonzero with the manual close command so the decision re-surfaces instead of silently vanishing; a send without the flag closes nothing, and working:/done: still never clear a captain decision. Complementary fixes: the wake-drain OPEN DECISIONS section prints the answer-with-close command hint at the moment of use; brief scaffolds separate resolved's two duties (keyed-phase end vs decision closure) and state that a done:/working: line never closes a decision even when the answer started that work, keeping worker self-close for blockers that clear without a firstmate reply; AGENTS.md and docs/architecture.md carry the one-line pointers to the fm-send contract. * fix(bin): seed remote secondmates from supplied origins (kunchenguid#1836) * feat(secondmate): seed a remote home from a supplied project origin Remote seeding required a local projects/<name> clone purely to read `git remote get-url origin` into the provisioning manifest, so setting up a remote second mate forced disposable clones and no-mistakes inits in the primary home for projects that home has no reason to hold. Firstmate now resolves the origin itself and names it as <project>=<origin-url>. The seed validates and transports what it is given, and the receiving host re-validates it rather than trusting the sender; bin/fm-project-origin-lib.sh is the single owner of which URLs are accepted, refusing executable remote-helper transports, option-shaped values, and unusable spellings at both ends. A bare <project> still reads an already-present clone's origin, so nothing that works today has to change. Registry consistency is unchanged: an unregistered or local-only project is still refused. A remote seed therefore creates nothing in the primary home beyond the route, the charter, and its launch record. The lifecycle test now seeds a registered project the primary has never cloned and asserts the primary project tree is byte-identical afterwards, alongside refusals for a missing origin, an unsafe origin, a local-only project, and an unregistered project. * no-mistakes(review): Clarify project origin documentation ownership * no-mistakes(document): Document supplied-origin remote seeding contract * feat(secondmate): accept project origins from any host or forge Firstmate is a shared template, so a project origin must be able to name any host: GitHub Enterprise on a private domain, GitLab hosted or self-hosted, Bitbucket, Gitea, Codeberg, sr.ht, a bare IP, an SSH config alias, or a plain server nobody else has heard of. The validator already decided on structure rather than on a forge allowlist, and this makes that guarantee explicit and closes the two gaps that a host-agnostic rule exposed: - a bracketed IPv6 literal in the scp-like form is now accepted, so a host reachable only by address is not excluded - a "/../" traversal inside a local or file: origin is now refused, because that names a path on the cloning host's own filesystem The library is the single owner of the accepted forms, and its header says plainly that there is no host, domain, or forge allowlist and there must never be one. The skill keeps its distinct agent-operating lines (the agent resolves and supplies the origin; a remote seed creates nothing in the primary home beyond the route, the charter, and its launch record) and points at the library for URL acceptance and at the operator doc for the rest. The lifecycle test now drives Bitbucket, a self-hosted enterprise domain, a self-hosted GitLab over ssh with a port, and a bare scp-like custom host through the real seed, manifest, transport, and remote provisioning path in one seed, asserting each URL reaches git unchanged and each clone carries its own origin's content. The unit matrix leads with non-GitHub hosts for the same reason. * no-mistakes(review): Validate project origin authorities safely * no-mistakes: apply CI fixes * no-mistakes: apply CI fixes * fix(tests): restore reliable fm-send backend parity coverage (kunchenguid#1851) * fix(tests): copy the whole bin/ tree into the old-vs-new conformance shim main went red on tests/fm-backend.test.sh's "fm-send --key: old vs new exit code" assertion, which reads as an fm-send fail-closed regression from build_old_bin enumerated by hand the sibling scripts it copied into the synthetic pre-refactor tree. kunchenguid#1842 made bin/fm-send.sh source bin/fm-line-cap-lib.sh (added by kunchenguid#1798) and the list never learned about it, so the pinned old fm-send.sh aborted at `. "$SCRIPT_DIR/fm-line-cap-lib.sh"` under set -eu and exited 1 before parsing a single argument, while the current one delivered the key and exited 0. The parity check compared a crashed process against a working one and reported a behavior divergence that never happened - the more so because BASE_REF collapses to HEAD on main, where both sides run byte-identical source and a genuine divergence is impossible. fm-send's --key exit path is unchanged and its fail-closed contract is intact. Copy the tree whole instead of enumerating it. An enumerated list has to be extended by hand every time an entrypoint gains a dependency and is the only thing that knows; it has been patched a dozen times for exactly that. A whole-tree copy has nothing to forget. Extracting a refactored entrypoint the baseline does not have now fails loudly instead of writing an empty file. Only old-vs-new parity covered that exit contract, and parity is near-vacuous on main. Pin it directly: tests/fm-send-strict.test.sh drives delivery both ways from one stub and asserts an undelivered key exits nonzero naming the key, so swallowing that error fails the suite. * no-mistakes(review): Materialize historical fixture dependencies from baseline * no-mistakes(document): Clarify fm-send key regression scope * fix(bin): mirror remote secondmate status streams (kunchenguid#1846) * fix(bin): mirror the whole remote secondmate status stream A remote secondmate's reply channel required corr=<16hex> on every line and failed the entire delta when one line lacked it, so the cursor could never advance past that line and the channel wedged permanently. The charter tells a secondmate to report its own progress phases and to raise new decisions with no correlation token, because correlation only answers a marked parent request. Those lines were therefore unrepresentable on the remote channel, while a local secondmate writes them straight into the parent's status file. Treat the channel as what it is: a mirror of the mate's status stream. A remote mate now presents the same status and decision model as a local one, so a newly raised needs-decision reaches the parent's open-decision fold identically, and correlation goes back to being a per-line property that settles a pending request rather than a gate on the stream. Only what crossing a machine boundary genuinely adds stays behind: cursor continuity, confined document fetch and rewrite, at-most-once append, and control-byte normalization that rewrites bytes without ever dropping a line. Line framing and size bounding already belong to fm-remote-delta-read.sh. A document the remote reader refuses is named in one escalation instead of stalling the stream, while an unavailable transport still leaves the delta for the existing retry. * refactor(bin): give the remote reply stream one append owner Every line entering the parent status stream - a mirrored line, the continuity escalation, and the undelivered-document escalation - now goes through one at-most-once append, so the idempotence a replayed generation depends on is stated once instead of copied at three call sites. * no-mistakes(review): Keep local document transfer failures retryable * no-mistakes(review): Isolate reply headers and normalize payload bytes * no-mistakes(review): Correct remote reply mirror contract wording * no-mistakes(review): Update remote reply script catalog description * no-mistakes(document): Document remote status-stream mirroring * docs(agents): describe the digest's fleet-state-before-context order (kunchenguid#1826) * fix(bin): fail closed on NUL bytes in the durable parent binding (kunchenguid#1847) fm_secondmate_parent_record_parse read the .fm-secondmate-parent record with bash's read, which drops NUL bytes - and different bash generations disagree on the result: 3.2 truncates the value at the NUL while 5.x splices the surrounding bytes together. A NUL-bearing parent_home could therefore resolve to a home the record's bytes never name contiguously, and which home fm-teardown.sh's promised-public-reply resolution read (registration, registry, relay state) - or whether that protection engaged at all - depended on which interpreter ran the cleanup. Reproduced end to end: the same NUL-bearing record cleaned up under bash 5.x by resolving the spliced-together registered parent, while bash 3.2 refused it as unresolved, and a literal truncated path refused under both. Reject any NUL byte in the record before field parsing, putting corrupt records in the same fail-closed bucket as duplicate fields, malformed local bindings, unsupported routes, and symlinked records. The regression test drives the real bin/fm-teardown.sh over the proven clean-cleanup fixture with a NUL spliced mid-path into the recorded parent_home, so before the fix it reproduced the wrong-home cleanup and now it must refuse with the explicit binding refusal. * fix(skills): reconcile inherited secondmate plans with shipped state (kunchenguid#1853) * docs(secondmate-provisioning): require record intake for an inherited domain A new mate seeded for an existing or inherited domain previously pulled in charter, inherited config, captain-shared preferences, project clones, and queued backlog rows with zero instruction about the domain's shipped history, so it assumed a greenfield domain. A live backlog keeps only the configured recent Done entries, so an inherited queue structurally over-represents plans and under-represents deliveries, and already-delivered work resurfaced as open. Add a record-intake step to the creation/seed path: classify greenfield versus existing or inherited, and for the latter reconcile every inherited plan against origin/main plus the live deployment, take only genuinely open work and still-live durable knowledge, never carry a plan row for shipped work, and record what could not be reconciled. Greenfield domains are untouched. The skill owns the procedure; the backlog handoff section carries a one-line reinforcement at the point where plan rows actually move. * no-mistakes(document): Clarify secondmate record-intake scope * fix: move network checks off the session-start blocking path (kunchenguid#1860) * perf(session-start): run every network check off the blocking path The session-start digest runs on a session-open hook that blocks session initialization, and every external-network call it made was individually unbounded: `gh auth status`, secondmate liveness, secondmate convergence, pending remote handoff delivery, and the fleet-sync fetch. One unreachable remote secondmate could consume the whole FM_SESSION_START_TIMEOUT and truncate the digest, so a slow network could cost the work queue itself. Measured against a host hanging 25s per SSH connection, that startup took 1m18s. The digest is now composed from local reads alone. bin/fm-startup-network.sh runs the same checks concurrently in a bounded detached worker and the digest harvests whatever finished, without ever waiting. Same fixture: 0.84s. Nothing is dropped. fm-bootstrap.sh stays the single owner of every sweep and still runs all of them, through a new FM_BOOTSTRAP_NETWORK phase split whose `skip` and `only` halves are a partition of the unsplit run. Deferral is safe because the sweeps are idempotent detectors, the result is durable and always surfaces (inline, or as a `check: startup-network` wake), and the worker re-verifies that the fleet lock still names the session that asked before it mutates anything. While the worker is still running the digest names exactly what is unconfirmed rather than implying it passed. A relaunch performed by the deferred pass is now always reported, because the digest that printed the superseded endpoint record is already out. Also collapses the duplicate tasks-axi compatibility probe: the verdict is computed once and handed to the bootstrap child for one process hop, then consumed so it never reaches a spawned agent's environment. 10 tasks-axi invocations per startup become 7. Verified on Claude Code 2.1.222 that a worker detached by the session-open hook survives the hook returning, the one vendor behavior this design needs and no portable test can see. Re-landed on current main, superseding PR kunchenguid#1845, which was cut from a pre-kunchenguid#1842 base. The digest's section numbering in AGENTS.md section 3 now states the emission order directly - supervision block and its read-once contract, fleet state, network checks, then context - which keeps kunchenguid#1826's fleet-state-before-context ordering. The old-bin test shim keeps main's git-archive baseline from kunchenguid#1851, which already subsumes this branch's reason for widening that shim. * docs(verification): re-measure the deferred startup stage on the current base Re-runs the unreachable-remote latency fixture against default-branch tip 8398d31 rather than the now-historical 345de4e, and records the sweep-result comparison the deferral's safety argument rests on: the deferred worker's published report is byte-identical to the three sweep lines the blocking baseline printed, with the unreachable route preserved in both. * no-mistakes(review): Fail deferred startup when report publication fails * no-mistakes(document): Document deferred startup network behavior accurately * fix(procevent): apply remote replies during capture (kunchenguid#1831) * fix(procevent): apply a captured adapter result in code, not by instruction A remote secondmate's reply was captured and announced, but never applied. Nothing dispatched the reply adapter's `handle` on a `procevent remote-reply` wake, and the handling instruction named only the generic acknowledgement, so the wake was retired while everything it carried was dropped: the reply never reached the secondmate's local status mirror, the request it answered kept escalating as a missed report, and the relay - whose registration each capture retires, and which only that same handling re-arms - was left dead until the next session start armed it again. Applying such a result carries no judgement, so it belongs in code. After publishing, the runner now calls `bin/fm-procevent-<adapter>.sh autohandle <source-id> <sequence> <result-file>` and lets the adapter apply and acknowledge its own result, through the same kind of seam that already owns the terminal verdict. It runs strictly after terminal retirement, because a handling adapter re-arms its own next source and retiring afterwards would drop that fresh registration. An adapter with no such command, or one whose pass does not complete, leaves the result unacknowledged and therefore still announced, so a handler receives it exactly as before. Resolving the request was not enough on its own either. An escalation opens a durable keyed decision in the parent status log, and nothing ever closed it, so a request the remote had answered kept surfacing in every later open-decisions fold. The pending-reply library now owns both ends of that decision: it opens one under a per-request key rather than the shared default key, and closes it once the record resolves, appending the closing line only while that exact decision is still open in the fold so it can neither double-close nor clear an unrelated decision that has since taken the same key. The handling instruction still routes a wake to its adapter, now as the idempotent confirmation of what the runner already did rather than as the guarantee. Verified end to end in a throwaway isolated home driving the real armed source, blocking delta reader, runner, and wake queue, with the handler doing only the generic acknowledgement and no part of the ingest stubbed: before, seven failed observations reproducing the incident; after, none. Each half is independently load-bearing - without the runner change the reply never reaches the mirror, without the escalation close the settled request still surfaces as an open decision. * no-mistakes(review): Prevent legacy reply closure from masking decisions * no-mistakes(review): Serialize pending reply resolution and escalation closure * no-mistakes(review): Serialize pending reply escalation with resolution * no-mistakes(review): Clarify guarded legacy escalation closure behavior * no-mistakes(review): Guard legacy closure and reserve pending reply keys * no-mistakes(review): Match pending reply escalations by construction * no-mistakes(document): Document automatic remote reply resolution * no-mistakes(lint): Fix unused concurrent escalation loop variable * no-mistakes(lint): Fix unused concurrent resolution loop binding * no-mistakes(review): Version fold cache and gate autohandle on publication * no-mistakes(document): Clarify remote reply relay documentation * feat(skills): port internal stow curation disciplines to the public skill (kunchenguid#1841) Bring the public installer-facing stow skill up to the internal skill's current curation behavior while keeping it fully standalone: - Replace the total-capture thesis with the compact-operating-map framing. - Add read-the-destination-before-writing with the inspect-then-update triad (supersedes what, one-sentence rewrite, delete stale now). - Add the concrete prune list together with its unique-fact guard, as an accuracy discipline with no size-budget machinery. - Curate every memory file the pass has open, not only the routed one. - Add the standing-decisions sweep category. - Add the stronger-owner pointer-over-copy test before filing. - Add tool-agnostic task-note discipline (inspect, classify, considered replacement body, never blind-append) and blocked-on recording. - Give .stow-notes.md a closed set of three exits. - Forbid storing, creating, or editing a skill as a stow destination. - Report per-file action verbs in the completion receipt. - Consolidate the repeated local-vs-external and .gitignore prose and fix the second-person voice slip, so the file does not grow (11334 -> 11276 bytes). * chore(bootstrap): raise lavish-axi version floor to 0.1.46 (kunchenguid#1865) * docs(cmux): add operator war-room recipe and thin helper (CMUX-001) Publishes concrete cmux CLI recipes for a labeled, multi-pane war-room view (new-workspace/layout, new-split, set-color, capture UUIDs, read-screen, scoped close-surface before close-workspace), backed by a thin bin/fm-cmux-war-room.sh helper (banner, color-for-harness, teardown-surfaces). The 1:1 task-workspace backend invariant in cmux-backend.md is unchanged; this is an operator recipe sheet plus a standalone helper, not a spawn-path change. --------- Co-authored-by: Kun Chen <3233006+kunchenguid@users.noreply.github.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Intent
Harden Firstmate's shared secondmate parent-binding parser (bin/fm-secondmate-parent-lib.sh, fm_secondmate_parent_record_parse) to fail closed on NUL bytes, shipped as its own small PR. This is the residual scope of a larger commissioned task: the core answerer-closes decision-closure redesign already shipped and merged separately as PR #1842, and the supervising firstmate explicitly narrowed this task to ONLY the parked NUL-aliasing parser finding, which #1842 did not cover. Requirements in their accepted form: (1) reproduce the concrete cross-home aliasing risk end-to-end BEFORE building - done and proven: bash's read drops NUL bytes and different bash generations disagree on the result (3.2 truncates the value at the NUL, 5.x splices the surrounding bytes together), so one NUL-bearing .fm-secondmate-parent record resolved to two different parent homes; through the real bin/fm-teardown.sh a NUL-spliced record reached the registered parent under bash 5.x and completed cleanup while bash 3.2 refused the byte-identical record as unresolved, and a control record with the literal truncated path refused under both, proving the outcome came specifically from NUL-splicing. (2) Reject any NUL byte in the record at the shared parser before field parsing, joining the existing fail-closed bucket (duplicate fields, malformed local bindings, unsupported routes/schema, symlinked records); the parser is load-bearing via bin/fm-teardown.sh's promised-public-reply parent resolution and bin/fm-home-seed.sh's existing-binding validation, and both inherit the rejection with no consumer changes. (3) The reproduction is turned into a regression test extending the existing durable-parent-record family in tests/fm-public-followup.test.sh: it drives the REAL bin/fm-teardown.sh over the proven clean-cleanup fixture (real fm-home-seed.sh seeded home, registered parent, landed worktree) with a NUL spliced mid-path into parent_home, and it provably failed before the fix (teardown completed the wrong-home cleanup) and passes after (explicit binding refusal, child work metadata preserved) - behavioral through the executable interface, never source-text assertions. (4) firstmate-coding-guidelines apply: one-owner rule (the lib header's fail-closed sentence is patched in place as the single contract owner; docs carry only pointers and were deliberately not touched), plain dash, no agent co-author, shellcheck-clean via bin/fm-lint.sh (pinned ShellCheck 0.11.0, clean), colocated test extension of an existing suite rather than a new runner. The branch fm/fm-secondmate-parent-nul-failclosed-r1 is deliberately new: the commissioning task's original branch name already carries merged PR #1842, so this separate deliverable ships from its own branch. Deliberate exclusions per the supervisor's explicit scope decision: do NOT rebuild or modify anything from the merged #1842 answerer-closes design; NO changes to the classify fold or corr-token status parsing (that boundary is owned by the in-flight #1831 pending-reply rework); NO mate-side open-decision ledger view (deliberately dropped in #1842's merged design). Known environment caveat from the supervisor: the test step may fail tests/fm-backend.test.sh 'Behavior portable serial 3' - a known inherited fixture bug on main (the old-vs-new shim omits fm-line-cap-lib.sh) being fixed by a separate crew, unrelated to this change; if that is the only failure the plan is to hold and rebase onto main once it is green rather than patching around it here. Delivery: no-mistakes with yolo off - ask-user findings escalate to the supervising firstmate and the captain owns the merge.
What Changed
Risk Assessment
✅ Low: The narrowly scoped parser check rejects NUL-bearing records before field parsing, preserves both consumers' intended semantics, and is covered by a behavioral teardown regression.
Testing
Inspected the parser-only diff, ran the focused public-followup executable suite, and manually captured the isolated end-to-end regression: the real teardown explicitly refused the NUL-bearing binding while preserving child work metadata. No UI surface was changed, so CLI transcript and persisted-state evidence were captured instead.
Evidence: NUL-bearing parent record teardown evidence
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-secondmate-parent-lib.sh:30- Required criterion (2) says bothfm-teardown.shandfm-home-seed.shexisting-binding validation inherit NUL rejection. This new parser check returns failure, butvalidate_existing_parent_bindingconverts every parser failure into success withfm_secondmate_parent_record_parse "$record" || return 0, after which seeding overwrites the corrupt record. Therefore teardown rejects the record, but reseeding does not. Please either allow the consumer to propagate invalid-record failure and add a behavioral reseeding regression, or revise the stated requirement.🔧 Fix: Confirm distinct NUL-binding consumer contracts
✅ Re-checked - no issues remain.
✅ **Test** - passed
✅ No issues found.
git diff --find-renames 6c206edd235e81872f67f0e6434283cc854ce246..abe131176bfc86d857f8facb1d5f95414c527759 -- bin/fm-secondmate-parent-lib.sh tests/fm-public-followup.test.shtests/fm-public-followup.test.shIsolatedtest_secondmate_teardown_rejects_nul_bearing_durable_parent_recordusing the realbin/fm-home-seed.shandbin/fm-teardown.sh, capturing teardown output, the NUL-bearing record bytes, and preserved child work metadatagit status --shortandgit rev-parse HEAD✅ **Document** - passed
✅ No issues found.
✅ **Lint** - passed
✅ No issues found.
✅ **Push** - passed
✅ No issues found.