diff --git a/.agents/skills/stow/SKILL.md b/.agents/skills/stow/SKILL.md index 348a997547..abce3830b4 100644 --- a/.agents/skills/stow/SKILL.md +++ b/.agents/skills/stow/SKILL.md @@ -300,6 +300,16 @@ Extend the completion receipt with one entry per secondmate alongside the primar Keep those entries in the same plain captain-facing language the rest of the receipt uses. The session is reset-safe only when every home is within its own budget with no unresolved exception. +## Automatic /stow markers + +Every `/stow` invocation in every home - primary or secondmate - updates the staleness markers below after that home's own pass (and, in a primary home, after the cascade above). +A secondmate home still performs this step even though it never cascades further. + +When, and only when, the whole pass for this home - including the cascade above in a primary home - is reset-safe, touch `state/.last-stow` (`touch state/.last-stow`); never touch it when reset-safe cannot be claimed. +Then touch `state/.last-stow-attempt` (`touch state/.last-stow-attempt`) as the pass's true final step, unconditionally, on every `/stow` invocation - reset-safe or not, and whatever exceptions stayed unresolved. +Both are bare-mtime markers mirroring `state/.last-heartbeat` (`bin/fm-watch.sh`): `state/.last-stow` records the last fully reset-safe pass, while `state/.last-stow-attempt` records that a pass ran at all and is the marker the automatic `/stow` triggers in `AGENTS.md` read to decide whether another pass is due. +A home carrying a sticky exception it cannot clear on its own - a `deferred` secondmate, an unresolved over-budget home, a shared preference still routing to the primary - therefore stays throttled to one automatic pass per interval instead of re-running on every heartbeat. + ## Scope exclusion: no skill storage by the pass The stow pass itself must never store, create, or edit a skill as a destination for any finding. diff --git a/AGENTS.md b/AGENTS.md index a50f6afe5c..5fbb187616 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -134,6 +134,8 @@ state/ runtime records and signals; gitignored .hash-* .count-* .stale-* .stale-since-* .paused-* .wedge-escalations-* .writing-* .seen-* .hb-surfaced-* .last-* .heartbeat-streak watcher internals; never touch .watch-triage.log watcher's absorbed-wake debug log (size-capped); never relied on, safe to delete .last-watcher-beat watcher liveness beacon, touched every poll (including while absorbing benign wakes); guard scripts read it + .last-stow bare-mtime marker touched only by the stow skill, only at the end of a reset-safe pass; the durable record of the last clean /stow + .last-stow-attempt bare-mtime marker touched by the stow skill at the end of every /stow pass, reset-safe or not; read by fm-session-start.sh's compact/clear re-emit and by section 8 rule 4's heartbeat check to gate automatic /stow .subsuper-* .supervise-daemon.* sub-supervisor internals; never touch .no-mistakes/ local validation state and evidence; gitignored ``` @@ -257,7 +259,7 @@ Route durable knowledge to its most specific owner: Firstmate never writes a project's `AGENTS.md` directly. A crewmate creates or updates it lazily through the project's selected delivery path, using `bin/fm-ensure-agents-md.sh` and preferring pointers to authoritative sources over copied detail. Keep fleet delivery posture and captain-private strategy out of project memory. -When the captain invokes `/stow`, load the `stow` skill for its memory curation, knowledge routing, and persistence of the open work records this session is holding; it files and corrects only the open work that session is holding, and never reconciles the backlog against repository or PR reality. +When the captain invokes `/stow` or an automatic trigger says it is due, load the `stow` skill for its memory curation, knowledge routing, and persistence of the open work records this session is holding; it files and corrects only the open work that session is holding, and never reconciles the backlog against repository or PR reality. ## 7. Task lifecycle @@ -409,6 +411,7 @@ Handle actionable wakes as follows: 2. For `stale:`, inspect the recorded endpoint and load `stuck-crewmate-recovery` for a stopped, looping, confused, or unresponsive worker; a deep-inspection reason also requires current-state and validation-log inspection. 3. For `check:`, act on the named poll result, including merges, Relay events, process-to-event source results, and captain inbox notes; a handled inbox note is also acknowledged with `bin/fm-inbox.sh drain --ack `, or it stays counted as still waiting for firstmate. 4. For `heartbeat:`, review the whole fleet from the structured fleet view, reconcile suspicious tasks and PR state, update the backlog, and never report an unchanged fleet as progress. + When the captain has explicitly enabled automatic stow for this home with `config/auto-stow`, also check `state/.last-stow-attempt`'s age against `FM_AUTO_STOW_INTERVAL_SECS` (default ~24h, a separate and larger clock than the heartbeat's own cadence); when due, run `/stow` first, before the rest of this review, so an automatic pass does not run on every heartbeat. That marker records an attempted pass rather than a reset-safe one, so a home holding an exception `/stow` cannot clear still waits out the full interval before the next automatic pass. When any wake reports a merged PR for a project cloned in this home, refresh that clone through the guarded fleet-sync path. When Relay-linked work reaches a milestone or terminal state, load `fmx-respond`; before terminal teardown, use its promised-final reconciliation when a typed public commitment exists, otherwise post the final completion follow-up so the link clears even if earlier follow-ups were spent. diff --git a/bin/fm-session-start.sh b/bin/fm-session-start.sh index 9eb50b4263..923eb87bfc 100755 --- a/bin/fm-session-start.sh +++ b/bin/fm-session-start.sh @@ -351,6 +351,45 @@ QUEUED_LIMIT=${FM_SESSION_START_QUEUED_LIMIT:-20} case "$QUEUED_LIMIT" in ''|*[!0-9]*|0) QUEUED_LIMIT=20 ;; esac BACKLOG_FIELDS=blocked_by,hold_kind,hold_reason +# Automatic /stow, trigger 1 (the compact/clear re-emit path below): an +# explicit home-local config/auto-stow grant plus a staleness gate on +# state/.last-stow-attempt, touched by the stow skill itself +# at the end of every pass whether or not it reached reset-safe (mirrors +# state/.last-heartbeat's bare-mtime marker, bin/fm-watch.sh). Reading the +# attempt marker rather than its reset-safe-only sibling state/.last-stow is +# what holds the once-per-interval throttle in a home whose exceptions /stow +# cannot clear. Read only here, never written by this script. Trigger 2 is the +# heartbeat-handling check in AGENTS.md section 8 rule 4, which reads the same +# marker against the same interval. +STOW_INTERVAL=${FM_AUTO_STOW_INTERVAL_SECS:-86400} +if [[ "$STOW_INTERVAL" =~ ^0*([1-9][0-9]{0,7})$ ]]; then + STOW_INTERVAL=${BASH_REMATCH[1]} +else + STOW_INTERVAL=86400 +fi +if [ "${#STOW_INTERVAL}" -eq 8 ] && [ "$STOW_INTERVAL" -gt 31536000 ]; then + STOW_INTERVAL=86400 +fi + +# stow_due_line: one "STOW DUE: ..." line when state/.last-stow-attempt is +# missing, unreadable, or at least STOW_INTERVAL seconds old, silent (prints +# nothing, exit 0) when current. Detect-only and cheap - a single mtime stat - +# matching the "always check, only speak up when it matters" idiom the +# bootstrap stage already uses. +stow_due_line() { + local marker="$STATE/.last-stow-attempt" m age + m=$(fm_path_mtime "$marker") + if [ -z "$m" ]; then + printf 'STOW DUE: no recorded /stow pass (source=%s); run /stow before other work.\n' "${SESSION_SOURCE:-unknown}" + return 0 + fi + age=$(( $(date +%s) - m )) + [ "$age" -ge 0 ] || age=$STOW_INTERVAL + [ "$age" -ge "$STOW_INTERVAL" ] || return 0 + printf 'STOW DUE: last /stow pass was %ss ago (over the %ss interval, source=%s); run /stow before other work.\n' \ + "$age" "$STOW_INTERVAL" "${SESSION_SOURCE:-unknown}" +} + RULE='================================================================================' SUBRULE='--------------------------------------------------------------------------------' @@ -639,6 +678,13 @@ if [ "$LOCK_RC" -ne 0 ]; then printf '%s\n' "$BAR" } fi +# Automatic /stow, trigger 1. Held until the lock verdict above: /stow mutates +# this home's memory files, so a re-emit that could not verify fleet-lock +# ownership must stay silent about it and leave the still-due marker to the +# next session start or re-emit that does own the lock. +if [ "$REEMIT" -eq 1 ] && [ "$READ_ONLY" -eq 0 ] && [ -e "$CONFIG/auto-stow" ]; then + stow_due_line +fi REBUILDING_SESSION_PID=$(fm_harness_ancestry_pid 2>/dev/null || true) print_agents_refresh_if_required "$REBUILDING_SESSION_PID" diff --git a/docs/architecture.md b/docs/architecture.md index a25bb20430..4d37f8e722 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -330,6 +330,7 @@ It is deliberately not a reconciliation of durable records against repository or Task-scoped notes use `tasks-axi show --full` followed by `tasks-axi update --body-file `, adding `--archive-body` when the prior body should remain recoverable. The stow pass never writes a skill, but a separately executed, captain-approved migration may move conditional knowledge into a user-owned local skill excluded from the Firstmate clone; changes to Firstmate's tracked skills remain deliberate repository work through the normal PR pipeline. Invoked in a primary home, `/stow` then cascades the same sweep to every registered secondmate, enumerated through `bin/fm-stow-cascade.sh`: each home is accounted and curated against its own startup-memory allowance, a live secondmate sweeps its own session, and a slow or unreachable home is reported as an exception rather than blocking the primary. +Automatic triggers for the same skill - a lock-owning compact/clear re-emit and a staleness-gated heartbeat check - are owned by [configuration.md](configuration.md#automatic-stow). ## Local clones stay fresh diff --git a/docs/configuration.md b/docs/configuration.md index df27bcbda2..d76487d587 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -187,6 +187,20 @@ The flag is per home and is not inherited by secondmate homes, because stow cade Only the file's presence is read, so its contents are ignored; remove it to return to the default contract on the next pass. The skill text owns the marker spelling, the tick order, and the reinforcement rule. +## Automatic /stow + +Create the home-local, gitignored `config/auto-stow` file to grant automatic memory curation for that home; its contents are ignored, and removing it disables both triggers. +This explicit captain opt-in satisfies `VISION.md`'s authority boundary while letting two existing turns decide whether to run the internal [`/stow` skill](../.agents/skills/stow/SKILL.md) instead of waiting for the captain to type it. + +1. An enabled, lock-owning compact/clear session-start re-emit prepends one `STOW DUE:` line when `state/.last-stow-attempt` is missing or older than `FM_AUTO_STOW_INTERVAL_SECS` (default 86400), and stays silent when the marker is current, automatic stow is disabled, or the session could not verify fleet-lock ownership (`bin/fm-session-start.sh`). +2. Enabled heartbeat handling in `AGENTS.md` section 8 rule 4 runs `/stow` first when the same marker is due, using that same larger interval so a pass does not run on every heartbeat wake. + +`FM_AUTO_STOW_INTERVAL_SECS` accepts whole seconds from 1 through 31536000 (365 days); empty, non-numeric, zero, or larger values fall back to 86400. +The stow skill touches `state/.last-stow-attempt` at the end of every pass, reset-safe or not, and touches `state/.last-stow` only when the pass is reset-safe. +The automatic triggers read the attempt marker, so a home holding an exception `/stow` cannot clear still waits out the full interval. +Away-mode heartbeats stay bash-only and never run `/stow`. +On a default Pi primary, heartbeat wakes go to the supervision branch, which does not currently run the `AGENTS.md` check; compact/clear re-emit remains the automatic path there ([Pi supervision branch](pi-supervision-branch.md#heartbeat-routing)). + ## Secondmate routes (data/secondmates.md) Persistent secondmate routes live locally in `data/secondmates.md`. @@ -649,6 +663,7 @@ FM_ZELLIJ_SESSION=firstmate # zellij-only: named session for normal backend ops CMUX_SOCKET_PASSWORD= # cmux-only: socket password fallback when config/cmux-socket-password is absent (docs/cmux-backend.md) FM_SESSION_START_STATUS_TAIL=5 # state/*.status lines printed per task in the session-start digest; each line is capped by bin/fm-line-cap-lib.sh FM_SESSION_START_QUEUED_LIMIT=20 # plain queued backlog rows in the session-start digest; in-flight, held, and blocked rows are never bounded and done rows are never listed +FM_AUTO_STOW_INTERVAL_SECS=86400 # 1..31536000 seconds; invalid or larger values fall back to 86400; gates automatic /stow against state/.last-stow-attempt's mtime (see "Automatic /stow" above) FM_BOOTSTRAP_DETECT_ONLY=0 # internal/read-only session-start mode: skip bootstrap's mutating sweeps and print advisory TANGLE wording FM_BOOTSTRAP_NETWORK=all # internal session-start phase split: all, skip (local steps only), or only (network steps only); see bin/fm-bootstrap.sh FM_STARTUP_NETWORK_TIMEOUT=120 # seconds bounding the whole deferred network stage; hitting it prints an actionable NETWORK_CHECKS line diff --git a/docs/pi-supervision-branch.md b/docs/pi-supervision-branch.md index 599da22bd0..1aadab5559 100644 --- a/docs/pi-supervision-branch.md +++ b/docs/pi-supervision-branch.md @@ -58,6 +58,11 @@ A review that found literally nothing worth reporting uses verdict `routine`, `t Only a captain-worthy finding reports verdict `captain` and opens a main turn. Every other fleet-wide or unresolvable wake - including watcher-failure alarms, which are never offered to the branch - keeps today's wake-to-main path. +The branch's heartbeat review does not currently run automatic `/stow`. +`AGENTS.md` section 8 rule 4's staleness-gated `/stow` instruction lives on main, and default-on branch supervision routes heartbeat wakes away from main. +When the captain has enabled `config/auto-stow`, a lock-owning compact/clear session-start re-emit remains the automatic `/stow` path on a default Pi primary (`bin/fm-session-start.sh`; `FM_AUTO_STOW_INTERVAL_SECS` in [configuration.md](configuration.md)). +Wiring heartbeat `/stow` into the branch is tracked in [issue #2944](https://github.com/kunchenguid/firstmate/issues/2944): `bin/fm-branch-prompt.sh`'s byte-stable-prefix contract forbids per-wake state, so that change has to preserve cache identity rather than appending a live marker age. + ## Cost model and the byte-stable prefix The captain accepted the normal provider prompt-caching strategy: a byte-identical branch prefix generated once per firstmate version, the same tool set in the same order on every request, and one shared `prompt_cache_key` per home for all branch sessions (set in a `before_provider_request` hook, and only for providers whose requests already carry that field); main keeps its own per-session key. diff --git a/tests/fm-session-start.test.sh b/tests/fm-session-start.test.sh index e74eceb7ab..e3d83301f9 100755 --- a/tests/fm-session-start.test.sh +++ b/tests/fm-session-start.test.sh @@ -2016,6 +2016,301 @@ EOF pass "--reemit reprints the digest without repeating startup's mutating sweeps and still drains queued wakes" } +# --- automatic /stow trigger 1: STOW DUE on compact/clear re-emit ------------ +# An explicit config/auto-stow grant and staleness gate on +# state/.last-stow-attempt that surface one STOW DUE line in a lock-owning +# compact/clear re-emit, silent when disabled, when the marker is current, and +# when the re-emit could not verify fleet-lock ownership. These exercise the +# real digest's public output only - never source bytes. + +# Set 's mtime to exactly seconds (touch -t takes a local-time +# stamp, not an epoch, on both platforms, so convert via BSD `date -r` or GNU +# `date -d @`). +set_stow_marker_mtime() { # + local epoch=$1 f=$2 stamp + touch "$f" + if stamp=$(date -r "$epoch" +%Y%m%d%H%M.%S 2>/dev/null); then + touch -t "$stamp" "$f" + else + stamp=$(date -d "@$epoch" +%Y%m%d%H%M.%S) + touch -t "$stamp" "$f" + fi +} + +run_reemit_for_stow() { # [source] + local home=$1 root=$2 path=$3 source=${4:-compact} + touch "$home/config/auto-stow" + FM_HOME="$home" FM_ROOT_OVERRIDE="$root" FM_FAKE_HARNESS_PID=$$ PATH="$path" \ + env -u CLAUDECODE -u PI_CODING_AGENT -u FM_PI_HARNESS -u GROK_AGENT \ + "$SESSION_START" --reemit --source "$source" +} + +test_stow_due_silent_without_captain_opt_in() { + local rec root home fakebin out + rec=$(new_world stow-due-disabled) + IFS='|' read -r root home fakebin <, or empty when absent. +stow_line_no() { # + printf '%s\n' "$1" | grep -n -F -- "$2" | head -1 | cut -d: -f1 +} + +test_stow_due_surfaced_when_marker_absent_on_reemit() { + local rec root home fakebin out due_at bootstrap_at + rec=$(new_world stow-due-absent) + IFS='|' read -r root home fakebin < "$home/state/.lock" + cat > "$fakebin/ps" <<'SH' +#!/usr/bin/env bash +set -u +case "$*" in + *"-p 999999"*) printf 'claude\n'; exit 0 ;; + *"comm="*|*"args="*) printf 'bash\n'; exit 0 ;; +esac +exit 0 +SH + chmod +x "$fakebin/ps" + + out=$(run_reemit_for_stow "$home" "$root" "$fakebin:$BASE_PATH") + + assert_contains "$out" "READ-ONLY SESSION" \ + "the read-only re-emit fixture did not actually refuse the lock" + assert_not_contains "$out" "STOW DUE:" \ + "a re-emit without verified fleet-lock ownership was told to run the mutating /stow pass" + + pass "a re-emit that lacks verified fleet-lock ownership stays silent about /stow" +} + +test_stow_due_never_appears_on_ordinary_startup() { + local rec root home fakebin out + rec=$(new_world stow-due-startup) + IFS='|' read -r root home fakebin <