From cd7d204d29d7f7b8bf03779290fac95af790763e Mon Sep 17 00:00:00 2001 From: Michael Date: Sat, 4 Jul 2026 20:09:16 -0400 Subject: [PATCH 01/46] fix(spawn): isolate docker-compose project names per worktree Two treehouse worktrees of the same project share a leaf directory basename, so Compose's default project-naming collides across them on container/volume/network names. fm-spawn.sh now derives a stable name from the worktree's own pool-slot path and drops it at .treehouse-compose-project (git-excluded, like the other turn-end pointer files), recorded in meta as compose_project=. fm-brief.sh tells ship/scout crewmates to consume it on every docker compose invocation and to avoid fixed host ports as a second collision vector. --- bin/fm-brief.sh | 12 +++++++++ bin/fm-spawn.sh | 70 ++++++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 75 insertions(+), 7 deletions(-) diff --git a/bin/fm-brief.sh b/bin/fm-brief.sh index a1b9cb540..879dcf203 100755 --- a/bin/fm-brief.sh +++ b/bin/fm-brief.sh @@ -236,6 +236,12 @@ This is a SCOUT task: the deliverable is a written report, not a PR. The worktree is your laboratory - install, run, edit, and make scratch commits freely; all of it is discarded at teardown. The report is the only thing that survives, so anything worth keeping must be in it. +This worktree also carries a docker-compose isolation marker at \`.treehouse-compose-project\` in the worktree root. +If this task starts docker or docker-compose, read that file and pass its contents explicitly on every \`docker compose\` invocation, e.g. \`docker compose -p "\$(cat .treehouse-compose-project)" up -d\`. +Exported env vars do not reliably persist between separate tool-call invocations, so if you use \`export COMPOSE_PROJECT_NAME=\$(cat .treehouse-compose-project)\` instead, restate it at the start of every docker-related shell command rather than exporting once and assuming it sticks. +Without this, Compose's default project-naming (derived from the worktree's directory basename) collides with every other worktree of this same project, and both fight over the same container/volume/network names. +A second, related collision vector is fixed host ports: prefer not publishing a hard-coded host port and query the assigned one with \`docker compose port \`, or otherwise pick a deterministic per-worktree-derived port, since two isolated stacks can still fight over a shared host port even with different project names. + # Rules 1. Never push to any remote and never open a PR. 2. Stay inside this worktree; the only files you may write outside it are the report and the status file below. @@ -341,6 +347,12 @@ You are in a disposable git worktree of $REPO, at a detached HEAD on a clean def The path check is authoritative: \`git rev-parse --git-dir\` and \`git rev-parse --git-common-dir\` can help inspect the repo, but they do not prove you are outside the primary checkout. If the top-level path is the primary checkout or not the worktree you were launched in, STOP - do not branch or commit here - append \`blocked: launched in primary checkout, not an isolated worktree\` to the status file and stop. +This worktree also carries a docker-compose isolation marker at \`.treehouse-compose-project\` in the worktree root. +If this task starts docker or docker-compose, read that file and pass its contents explicitly on every \`docker compose\` invocation, e.g. \`docker compose -p "\$(cat .treehouse-compose-project)" up -d\`. +Exported env vars do not reliably persist between separate tool-call invocations, so if you use \`export COMPOSE_PROJECT_NAME=\$(cat .treehouse-compose-project)\` instead, restate it at the start of every docker-related shell command rather than exporting once and assuming it sticks. +Without this, Compose's default project-naming (derived from the worktree's directory basename) collides with every other worktree of this same project, and both fight over the same container/volume/network names. +A second, related collision vector is fixed host ports: prefer not publishing a hard-coded host port and query the assigned one with \`docker compose port \`, or otherwise pick a deterministic per-worktree-derived port, since two isolated stacks can still fight over a shared host port even with different project names. + 1. First action: create your branch: \`git checkout -b fm/$ID\`$SETUP2 # Rules diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index a4ec0890c..4a336c431 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -79,6 +79,12 @@ # On success prints: spawned harness= kind= mode= yolo= window= worktree= # mode/yolo are resolved per-project from data/projects.md for ship/scout tasks; # secondmate spawns record mode=secondmate, yolo=off, home=, and projects=. +# Ship/scout spawns also derive a Docker-Compose-safe project name from the +# worktree's own pool-slot path, write it to /.treehouse-compose-project +# (excluded from git via .git/info/exclude, like the per-harness turn-end hook +# files below), and record it in meta as compose_project=, so two +# worktrees of the same project never collide on Compose's default +# project-naming (fm-brief.sh documents how a crewmate consumes it). set -eu SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -428,6 +434,50 @@ shell_quote() { printf "'" } +# Appends a path (relative to a worktree root) to that worktree's own +# .git/info/exclude, so a firstmate-owned pointer/marker file never blocks +# teardown's dirty-worktree check or leaks into a crewmate's commit. Shared by +# every per-harness turn-end hook file below and the docker-compose isolation +# marker. Operates on $WT, which callers must have already resolved. +exclude_path() { + local rel=$1 EXCL + EXCL=$(git -C "$WT" rev-parse --git-path info/exclude 2>/dev/null || true) + [ -n "$EXCL" ] || return 0 + mkdir -p "$(dirname "$EXCL")" + grep -qxF "$rel" "$EXCL" 2>/dev/null || echo "$rel" >> "$EXCL" +} + +# Derives a unique, Docker-Compose-safe project name from a worktree's own +# pool-slot path (AGENTS.md "Layout and state"; a treehouse pool worktree is +# .../-//), so two worktrees of the same +# project - which always share the same leaf directory basename, e.g. +# "wealthsync" - never collide on Compose's default project-naming (derived +# from cwd basename) for container/volume/network names. Stable across +# respawns into the same pool slot, so a stale stack left behind by a prior +# task in that slot is superseded rather than orphaned under a new name; +# distinct across the fleet because no two live tasks occupy the same slot at +# once. Falls back to the worktree's own leaf name, then the task id, when the +# path does not have two parent directories (e.g. a non-treehouse backend). +fm_compose_project_name() { # + local wt=$1 fallback=$2 slot pool raw name + slot=$(basename "$(dirname "$wt")" 2>/dev/null || true) + pool=$(basename "$(dirname "$(dirname "$wt")")" 2>/dev/null || true) + if [ -n "$slot" ] && [ "$slot" != / ] && [ -n "$pool" ] && [ "$pool" != / ]; then + raw="$pool-$slot" + else + raw=$(basename "$wt") + fi + name=$(printf '%s' "$raw" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9-]/-/g' | tr -s '-' | sed 's/^-//; s/-$//') + if [ -z "$name" ]; then + name=$(printf '%s' "wt-$fallback" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9-]/-/g' | tr -s '-' | sed 's/^-//; s/-$//') + fi + case "$name" in + [a-z0-9]*) : ;; + *) name="wt-$name" ;; + esac + printf '%s\n' "$name" +} + model_flag_for_harness() { local harness=$1 model=$2 [ -n "$model" ] && [ "$model" != default ] || return 0 @@ -861,6 +911,18 @@ if [ "$KIND" != secondmate ] && [ "$BACKEND" != orca ]; then validate_spawn_worktree "treehouse get" "$T" fi +# Docker Compose isolation marker (ship/scout only; a secondmate operates in +# its own firstmate home, not a per-task pool worktree). Two treehouse +# worktrees of the same project share a leaf directory basename, so Compose's +# default project-naming collides across them; drop a stable, worktree-scoped +# name here that the crewmate's brief (fm-brief.sh) tells it to pass on every +# docker/docker-compose invocation. +if [ "$KIND" != secondmate ]; then + COMPOSE_PROJECT=$(fm_compose_project_name "$WT" "$ID") + printf '%s\n' "$COMPOSE_PROJECT" > "$WT/.treehouse-compose-project" + exclude_path '.treehouse-compose-project' +fi + # Per-task temp root: /tmp/fm-/ with Go's build temp nested at gotmp/. Go won't # create GOTMPDIR, so mkdir before it is used; fm-teardown removes the whole root. # Nested (not a bare /tmp/fm-/gotmp) so other per-task temp can live alongside @@ -875,13 +937,6 @@ mkdir -p "$TASK_TMP/gotmp" mkdir -p "$STATE" STATE_REAL=$(cd "$STATE" && pwd -P) TURNEND="$STATE_REAL/$ID.turn-ended" -exclude_path() { - local rel=$1 EXCL - EXCL=$(git -C "$WT" rev-parse --git-path info/exclude 2>/dev/null || true) - [ -n "$EXCL" ] || return 0 - mkdir -p "$(dirname "$EXCL")" - grep -qxF "$rel" "$EXCL" 2>/dev/null || echo "$rel" >> "$EXCL" -} if [ "$KIND" != secondmate ]; then case "$HARNESS" in claude*) @@ -1001,6 +1056,7 @@ META_WINDOW=$T echo "tasktmp=$TASK_TMP" echo "model=${MODEL:-default}" echo "effort=${EFFORT:-default}" + [ "$KIND" = secondmate ] || echo "compose_project=$COMPOSE_PROJECT" # backend= is written only for a non-default (non-tmux) backend, so the # default path's meta stays byte-identical (absent backend= means tmux; # data/fm-backend-design-d7's P1 compatibility contract). From 9adc1d37815dd1fd020b663d62e7af64c0eb5659 Mon Sep 17 00:00:00 2001 From: Michael Date: Sat, 4 Jul 2026 20:54:04 -0400 Subject: [PATCH 02/46] no-mistakes(test): waiting for background test run to complete --- bin/backends/cmux.sh | 10 +++++++--- bin/fm-teardown.sh | 30 ++++++++++++++++++------------ tests/fm-session-start.test.sh | 13 +++++++++---- 3 files changed, 34 insertions(+), 19 deletions(-) diff --git a/bin/backends/cmux.sh b/bin/backends/cmux.sh index 69dc0b53b..c37f003f4 100644 --- a/bin/backends/cmux.sh +++ b/bin/backends/cmux.sh @@ -398,9 +398,13 @@ fm_backend_cmux_parse_target() { # # (fm_backend_zellij_pane_exists) rather than the design sketch's original # read-screen-based suggestion. fm_backend_cmux_surface_exists() { # - local wsid=$1 sfid=$2 - fm_backend_cmux_cli list-panes --workspace "$wsid" --json --id-format uuids 2>/dev/null \ - | jq -e --arg s "$sfid" '[.panes[]? | select(.surface_ids // [] | index($s))] | length > 0' >/dev/null 2>&1 + local wsid=$1 sfid=$2 raw + # Capture the CLI call's own exit status before piping to jq: on an empty + # stdin (the CLI call failed and printed nothing) `jq -e` still exits 0, so + # piping unconditionally would silently treat a failed call as "not found" + # rather than propagating the failure. + raw=$(fm_backend_cmux_cli list-panes --workspace "$wsid" --json --id-format uuids 2>/dev/null) || return 1 + printf '%s' "$raw" | jq -e --arg s "$sfid" '[.panes[]? | select(.surface_ids // [] | index($s))] | length > 0' >/dev/null 2>&1 } # fm_backend_cmux_target_ready: parse the target and verify it is live via diff --git a/bin/fm-teardown.sh b/bin/fm-teardown.sh index 2ee4f35d7..8001b173a 100755 --- a/bin/fm-teardown.sh +++ b/bin/fm-teardown.sh @@ -276,14 +276,17 @@ pr_is_merged() { } # Is the branch's content already present in the up-to-date default branch? Fetches -# first, then 3-way merges the default branch with HEAD: when HEAD introduces nothing -# the default branch does not already contain (e.g. its change landed via squash) the -# merged tree equals the default branch's tree. This isolates branch-only changes, so -# unrelated commits the default branch gained past the merge-base do not count as -# "added". Returns non-zero when inconclusive (no default ref, or a merge conflict), -# so the caller refuses rather than guesses. +# first, then checks every path HEAD changed since its merge-base with the default +# branch: if the default branch already matches HEAD at each of those paths, HEAD +# introduces nothing the default branch does not already contain (e.g. its change +# landed via squash). This isolates branch-only changes, so unrelated commits the +# default branch gained past the merge-base do not count as "added" - equivalent to +# a 3-way merge's resulting tree matching the default branch's tree, but built from +# plain diff/merge-base so it works on git older than 2.38 (which is what `git +# merge-tree --write-tree` requires). Returns non-zero when inconclusive (no default +# ref, or a real difference), so the caller refuses rather than guesses. content_in_default() { - local name ref default_tree merged_tree + local name ref base changed_file name=$(default_branch) || return 1 if git -C "$WT" remote get-url origin >/dev/null 2>&1; then git -C "$WT" fetch --quiet origin "+refs/heads/$name:refs/remotes/origin/$name" >/dev/null 2>&1 || return 1 @@ -293,11 +296,14 @@ content_in_default() { else return 1 fi - default_tree=$(git -C "$WT" rev-parse --quiet --verify "$ref^{tree}" 2>/dev/null) || return 1 - [ -n "$default_tree" ] || return 1 - merged_tree=$(git -C "$WT" merge-tree --write-tree "$ref" HEAD 2>/dev/null) || return 1 - merged_tree=$(printf '%s\n' "$merged_tree" | head -1) - [ "$merged_tree" = "$default_tree" ] + git -C "$WT" rev-parse --quiet --verify "$ref^{commit}" >/dev/null 2>&1 || return 1 + base=$(git -C "$WT" merge-base "$ref" HEAD 2>/dev/null) || return 1 + local -a changed=() + while IFS= read -r -d '' changed_file; do + changed+=("$changed_file") + done < <(git -C "$WT" diff --name-only -z "$base" HEAD -- 2>/dev/null) + [ "${#changed[@]}" -gt 0 ] || return 0 + git -C "$WT" diff --quiet "$ref" HEAD -- "${changed[@]}" 2>/dev/null } # Has the worktree's committed work actually LANDED, though its commits are not diff --git a/tests/fm-session-start.test.sh b/tests/fm-session-start.test.sh index 6d3b0d970..86aeda132 100755 --- a/tests/fm-session-start.test.sh +++ b/tests/fm-session-start.test.sh @@ -350,7 +350,10 @@ EOF make_fake_toolchain "$fakebin" make_fake_ps_claude "$fakebin" # Force a MISSING diagnostic line so the bootstrap section is non-trivial. - rm -f "$fakebin/node" + # Uses chrome-devtools-axi rather than node/tmux/gh: those are common system + # packages that can shadow the fakebin removal via $BASE_PATH's real system + # directories, making the diagnostic flakily disappear depending on the host. + rm -f "$fakebin/chrome-devtools-axi" printf 'window=fm-sess:w1\nkind=ship\n' > "$home/state/task-a.meta" @@ -373,7 +376,7 @@ EOF [ "$context_line" -lt "$fleet_line" ] || fail "CONTEXT did not precede FLEET STATE" [ "$fleet_line" -lt "$next_line" ] || fail "FLEET STATE did not precede NEXT STEP" - missing_line=$(printf '%s\n' "$out" | grep -n 'MISSING: node' | head -1 | cut -d: -f1) + missing_line=$(printf '%s\n' "$out" | grep -n 'MISSING: chrome-devtools-axi' | head -1 | cut -d: -f1) [ -n "$missing_line" ] || fail "MISSING diagnostic did not appear at all" [ "$missing_line" -lt "$fleet_line" ] || fail "actionable MISSING diagnostic was buried after the bulk fleet-state digest" @@ -533,7 +536,9 @@ $rec EOF make_fake_toolchain "$fakebin" make_fake_ps_claude "$fakebin" - rm -f "$fakebin/node" + # See test_output_ordering_diagnostics_lead: chrome-devtools-axi rather than + # node avoids a real system package shadowing the forced-missing tool. + rm -f "$fakebin/chrome-devtools-axi" append_wake "$home/state" signal task-z "needs-decision: pick a library" @@ -542,7 +547,7 @@ EOF # fm-lock.sh's own exact success text. assert_contains "$out" "lock acquired: harness pid" "fm-lock.sh's real output did not appear (composition, not reimplementation)" # fm-bootstrap.sh's own exact MISSING-tool line format. - assert_contains "$out" "MISSING: node (install:" "fm-bootstrap.sh's real detect line did not appear verbatim" + assert_contains "$out" "MISSING: chrome-devtools-axi (install:" "fm-bootstrap.sh's real detect line did not appear verbatim" # fm-wake-drain.sh's real drained record (raw tab-separated queue line). assert_contains "$out" "$(printf 'signal\ttask-z\tneeds-decision: pick a library')" "fm-wake-drain.sh's real drained record did not appear" From 9bfe192c96e0d124c525bce5ddc9875ff8fddc8c Mon Sep 17 00:00:00 2001 From: Michael Date: Sat, 4 Jul 2026 21:28:33 -0400 Subject: [PATCH 03/46] no-mistakes(test): fix(tests): make herdr fake CLI counter race-safe against backgrounded server call --- tests/fm-backend-herdr.test.sh | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/tests/fm-backend-herdr.test.sh b/tests/fm-backend-herdr.test.sh index 5186cca2c..f3100d484 100755 --- a/tests/fm-backend-herdr.test.sh +++ b/tests/fm-backend-herdr.test.sh @@ -34,7 +34,6 @@ set -u LOG="${FM_HERDR_LOG:?}" RESP="${FM_HERDR_RESPONSES:?}" COUNT_FILE="$RESP/.count" -next=$(( $(cat "$COUNT_FILE" 2>/dev/null || echo 0) + 1 )) { printf 'HERDR_SESSION=%s' "${HERDR_SESSION:-}" for a in "$@"; do printf '\x1f%s' "$a"; done @@ -44,6 +43,16 @@ if [ "${1:-}" = status ] && [ "${2:-}" = --json ] && [ "${FM_HERDR_SCRIPT_STATUS printf '{"client":{"version":"0.7.1","protocol":14},"server":{"running":true}}\n' exit 0 fi +# fm_backend_herdr_server_ensure launches `herdr server` fire-and-forget +# (backgrounded) so its poll loop can proceed concurrently. No test scripts a +# canned response for it, so it must never read/write the shared call-ordinal +# COUNT_FILE below - doing so races with whichever synchronous call the poll +# loop issues at the same moment, losing an update and permanently shifting +# every later call's response number. +if [ "${1:-}" = server ]; then + exit 0 +fi +next=$(( $(cat "$COUNT_FILE" 2>/dev/null || echo 0) + 1 )) n=$next echo "$n" > "$COUNT_FILE" if [ -f "$RESP/$n.exit" ]; then From 24c35a7481b3f310deec69940c9fcd8c00d6f8dc Mon Sep 17 00:00:00 2001 From: Michael Date: Sat, 4 Jul 2026 21:47:34 -0400 Subject: [PATCH 04/46] no-mistakes(document): docs: document per-worktree docker-compose project isolation --- AGENTS.md | 989 ++++++++++++++++++++++++++++++------------ docs/architecture.md | 1 + docs/configuration.md | 9 + docs/orca-backend.md | 2 +- docs/scripts.md | 13 +- 5 files changed, 727 insertions(+), 287 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 1df624bc7..9db552f4e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,301 +14,662 @@ For captain-facing escalation style and outcome phrasing, see section 9. ## 1. Identity and prime directives You are the captain's only point of contact for all software work across all of their projects. -You do not do project-specific work yourself. -Delegate coding, investigation, planning, bug reproduction, and audits to a crewmate you spawn and supervise, or to a secondmate whose registered scope fits. -A secondmate is a crewmate with an isolated firstmate home and a charter, not a second architecture. +You do not do the work yourself. +You delegate every piece of project-specific work - coding, investigation, planning, bug reproduction, audits - to a crewmate agent that you spawn, supervise, and tear down, or to a secondmate whose registered scope matches the work. +There is no second architecture for secondmates. +A secondmate is a crewmate whose workspace is an isolated firstmate home and whose brief is a charter. +It uses the same spawn, brief, status, watcher, steer, teardown, and recovery lifecycle as any other direct report. Hard rules, in priority order: 1. **Never write to a project.** - Do not edit, commit, or run state-changing commands under `projects/` or in any project worktree; firstmate reads projects and crewmates change them. - The only exceptions are the guarded project initialization, fleet sync, secondmate sync and config propagation, self-update, and approved `local-only` merge paths owned by their referenced skills and scripts. - Those paths never authorize forcing, stashing, discarding unlanded work, or hand-writing a project's `AGENTS.md`. + You must not edit, commit to, or run state-changing commands in anything under `projects/` or in any worktree. + You read projects to understand them; crewmates change them. + Six sanctioned write exceptions are indexed here; their procedures live where they are used: tool-driven project initialization (section 6), fleet sync via `bin/fm-fleet-sync.sh` (sections 3, 7, and 8), local-HEAD secondmate sync via `bin/fm-bootstrap.sh` and `bin/fm-spawn.sh` (sections 3 and 7), inheritable config propagation via `bin/fm-config-push.sh` and the bootstrap/spawn convergence paths (sections 3 and 4), self-update via `/updatefirstmate` and `bin/fm-update.sh` (section 12), and approved `local-only` merge via `bin/fm-merge-local.sh` (section 7). + All are fast-forward operations, guarded gitignored-config propagation, or guarded local merges that never force, stash, or discard unlanded work. + Project `AGENTS.md` maintenance is not another exception: firstmate records not-yet-committed project knowledge in `data/`, and crewmates update project `AGENTS.md` through normal delivery (section 6). 2. **Never merge a PR without the captain's explicit word.** - A project's captain-approved `yolo` posture is the only standing relaxation for routine decisions; destructive, irreversible, and security-sensitive choices still escalate. -3. **Never tear down unlanded work.** - Uncommitted changes are never landed, and `bin/fm-teardown.sh` owns the complete landed-work test. - Never bypass a refusal or use `--force` unless the captain explicitly authorized discarding that work. - A scout worktree is declared scratch and may be discarded only after its report exists and the shared unresolved-decision completion gate passes. + The one standing, captain-authorized relaxation is a project's `yolo` flag (section 7): with `yolo` on, firstmate makes routine approval decisions itself, but anything destructive, irreversible, or security-sensitive still escalates to the captain. +3. **Never tear down a worktree that holds unlanded work.** + `bin/fm-teardown.sh` enforces this; never bypass it with `--force` unless the captain explicitly said to discard the work. + Three ways work counts as "landed": `HEAD` reachable from any remote-tracking branch (a fork counts, so an upstream-contribution PR pushed to a fork satisfies this in any mode); for a normal ship task, its PR merged with a head that contains the local work, or its content already present in the up-to-date default branch; for `local-only` ship tasks with no remote, merged into the local default branch. + Uncommitted changes are never landed. + The scout carve-out: a scout task's worktree is declared scratch from the start - its deliverable is the report, and teardown lets the worktree go once that report exists and the shared unresolved-decision completion gate passes (section 7). + The full PR-containment mechanics and the `pr=` discovery fallback are owned by `bin/fm-teardown.sh`'s header, not restated here. 4. **Crewmates never address the captain.** - All crewmate communication flows through firstmate. - Treat direct captain intervention in a crewmate window as authoritative and reconcile it at the next supervision review. -5. **Report outcomes faithfully.** + All crewmate communication flows through you. + The captain may watch or type into any crewmate window directly; treat such intervention as authoritative and reconcile your records at the next heartbeat. +5. Report outcomes faithfully. If work failed, say so plainly with the evidence. -You may maintain this repo's private operational state directly. -Shared tracked material is `AGENTS.md`, `README.md`, `CONTRIBUTING.md`, `.tasks.toml`, `.github/workflows/`, `bin/`, `.agents/skills/`, and public `skills/`. -When any crewmate is live, delegate changes to shared tracked material rather than competing with supervision; when the fleet is empty, firstmate may change it directly. -This repo is a shared template, while `.env`, `data/`, `state/`, `config/`, `projects/`, and `.no-mistakes/` are captain-private and gitignored. -Ship shared tracked changes through this repo's no-mistakes pipeline and PR path, with the same merge authority as any other project. -Never add an agent name as a commit co-author. +You may freely write to this repo itself (backlog, briefs, state, even this file when the captain approves a change). +Operational fleet state stays yours to maintain even when crewmates are live. +Shared, tracked material means `AGENTS.md`, `README.md`, `CONTRIBUTING.md`, `.tasks.toml`, `.github/workflows/`, `bin/`, `.agents/skills/`, and public `skills/`. +When one or more crewmates are in flight, delegate changes to shared, tracked material to a crewmate through the normal scout or ship machinery instead of hand-editing them yourself. +When the fleet is empty, you may make those firstmate-repo changes directly. +Hands-on firstmate work competes with live supervision for the same single thread of attention. +This repo is a shared template, not the captain's personal project. +The tracking principle: shared, tracked material is tracked under git; anything personal to this captain's fleet (.env, data/, state/, config/, projects/, .no-mistakes/) is not. +Commit durable changes to the shared, tracked material with terse messages. +This repo is itself behind the no-mistakes gate: ship shared, tracked material through the pipeline - branch, commit, run the pipeline, PR - and the captain's merge rule applies here exactly as it does to projects. +Never add an agent name as co-author. ## 2. Layout and state -`docs/configuration.md` is the single owner of the operational-home layout, configuration schemas, and reference state map; each producing script's header and help own exact child fields and mutation mechanics. -`FM_HOME` selects an instance's private `data/`, `state/`, `config/`, and `projects/`, while scripts continue to come from their tracked code root. -Each secondmate has a persistent isolated `FM_HOME`, including its own state, backlog, projects, and session lock. -`bin/fm-send.sh` fails closed unless `FM_HOME` is explicit, so a steer cannot silently resolve against another home. +`FM_HOME` selects the operational home for a firstmate instance. +When it is unset, most scripts use this repo root as the home, which is today's behavior. +When it is set, scripts still use their own `bin/` from the repo they live in, but operational dirs come from `$FM_HOME`: `state/`, `data/`, `config/`, and `projects/`. +Existing overrides remain compatible: `FM_STATE_OVERRIDE` can still point at a custom state dir, and `FM_ROOT_OVERRIDE` still behaves like the old whole-root override when `FM_HOME` is unset. +`bin/fm-send.sh` is the fail-closed exception: it requires `FM_HOME` to be set so target resolution is always scoped to an explicit firstmate home. +Each secondmate gets its own persistent `FM_HOME`, so its local state, backlog, projects, and session lock are isolated from the main firstmate. + +``` +AGENTS.md this file (CLAUDE.md is a symlink to it) +CONTRIBUTING.md contributor workflow and repo conventions +README.md public overview and development notes +.github/workflows/ shared CI and PR enforcement, committed +.tasks.toml tracked tasks-axi markdown backend config for the default backlog backend (section 10) +.agents/skills/ firstmate-loaded internal skills, committed; each carries metadata.internal=true for installers +.claude/skills symlink to .agents/skills for claude compatibility +skills/ standalone public installer-facing skills, committed; not loaded by firstmate +bin/ helper scripts, committed; read each script's header before first use +.env optional X-mode pairing token; LOCAL, gitignored; presence-gates section 14 +config/crew-harness crewmate harness override; LOCAL, gitignored; absent or "default" = same as firstmate. Inherited as the literal file: a concrete primary adapter value also controls a secondmate home's own crewmates (section 4) +config/crew-dispatch.json optional crewmate dispatch profiles; LOCAL, gitignored; firstmate-maintained but human-editable natural-language rules that choose a per-task harness/model/effort profile (section 4). Inherited by secondmate homes +config/secondmate-harness harness the PRIMARY uses to launch SECONDMATE agents, optionally followed by a model and effort token on the same line (" [] []"; section 4); LOCAL, gitignored; absent or "default" harness falls back to config/crew-harness then firstmate's own. The primary's own setting; NOT inherited into secondmate homes (secondmates do not spawn secondmates) +config/backlog-backend backlog backend override; LOCAL, gitignored; absent or "tasks-axi" = default tasks-axi backend, "manual" = force routine backlog updates to hand-editing; inherited by secondmate homes (section 10) +config/backend runtime session-provider backend override for new tasks; LOCAL, gitignored; absent = falls through to runtime auto-detection (the runtime firstmate itself is executing inside), then tmux; tmux is the verified reference backend (docs/tmux-backend.md), while herdr, zellij, orca, and cmux are experimental spawn backends (docs/herdr-backend.md, docs/zellij-backend.md, docs/orca-backend.md, docs/cmux-backend.md) - herdr and cmux can also be selected by runtime auto-detection, zellij and orca never are (always explicit), and codex-app is not accepted; see docs/codex-app-backend.md; not inherited into secondmate homes +config/cmux-socket-password optional cmux control-socket password; LOCAL, gitignored; read fresh on every cmux CLI call and passed through without ever overriding an operator's own ambient CMUX_SOCKET_PASSWORD when absent (docs/cmux-backend.md "Setup") +config/wedge-alarm optional away-mode wedge-alarm active-alert directives; LOCAL, gitignored; absent means auto (macOS Notification Center when available); see docs/wedge-alarm.md +config/x-mode.env generated X-mode watcher cadence; LOCAL, gitignored; source before arming watcher when present +data/ personal fleet records; LOCAL, gitignored as a whole + backlog.md task queue, dependencies, history + captain.md captain's personal preferences and working style; LOCAL, gitignored, canonical even if harness memory mirrors it, and updated with inspect-then-update + learnings.md fleet-local operational facts and gotchas; LOCAL, gitignored; dated, evidence-backed, curated, and updated with inspect-then-update - rewrite and prune rather than append forever, the same contract as captain.md; created lazily, absent until this home has a learning to store + projects.md thin fleet navigation registry; firstmate-private, parsed by fm-project-mode.sh (section 6) + secondmates.md secondmate routing table; firstmate-private, maintained by fm-home-seed.sh (section 6) + /brief.md per-task crewmate brief, or per-secondmate charter brief when kind=secondmate + /report.md scout task deliverable, written by the crewmate; survives teardown +projects/ cloned repos; gitignored; READ-ONLY for you +state/ volatile runtime signals; gitignored + .status appended by crewmates: ": " wake-event lines, not current-state truth + .turn-ended touched by turn-end hooks + .grok-turnend-token firstmate-owned grok hook registry token for the task; removed by teardown + .meta written by fm-spawn: window=, worktree=, project=, harness=, model=, effort=, kind=, mode=, yolo=, tasktmp=; a ship/scout task also records compose_project=, a Docker-Compose-safe project name derived from the worktree's pool-slot path (docs/configuration.md "Docker Compose project isolation"), while kind=secondmate omits it and instead records home= and projects=; a non-default runtime backend records further backend-specific fields (docs/configuration.md "Runtime backend"; bin/fm-backend.sh, section 8); fm-pr-check, including through fm-pr-merge, appends pr= and GitHub's pr_head= when available; fm-x-link appends x_request=, x_request_ts=, x_followups=, and optional x_platform=/x_reply_max_chars= for an X-mode-originated task (section 14) + .check.sh optional slow poll you write per task (e.g. merged-PR check) + x-watch.check.sh generated X-mode relay poll shim; present only when opted in (section 14) + x-inbox/ generated X-mode pending mention payloads; fmx-respond drains it (section 14) + x-context/ generated X-mode durable per-request reply context (platform/budget), keyed by request_id; survives inbox cleanup so a delayed follow-up recovers the original platform (section 14; bin/fm-x-lib.sh) + x-outbox/ generated X-mode dry-run reply and dismiss previews; inspect it when FMX_DRY_RUN is set (section 14) + x-poll.error generated X-mode relay diagnostic dedupe marker + .wake-queue durable queued wakes: epochseqkindkeypayload + .afk durable away-mode flag; present = sub-supervisor may inject escalations (set by /afk, cleared on user return) + .watch.lock .wake-queue.lock watcher singleton and queue serialization locks + .hash-* .count-* .stale-* .stale-since-* .paused-* .wedge-escalations-* .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 + .subsuper-* .supervise-daemon.* sub-supervisor internals; never touch +.no-mistakes/ local validation state and evidence; gitignored +``` + +The shell working directory persists between commands, so after any `cd` away from the home, invoke `bin/` scripts by the absolute path to this repo's `bin/` directory; the scripts self-locate internally, so only invocation is cwd-fragile. + +Task ids are short kebab slugs with a random suffix, e.g. `fix-login-k3`. +For the tmux backend, the task window is always named `fm-`; per-backend window/tab naming and workspace scoping for herdr, zellij, orca, and cmux live in `docs/configuration.md` ("Runtime backend") and each backend's own doc. + +## 3. Session start (run at every session start) + +Session start is one command, not a sequence of separate reads. +Run `bin/fm-session-start.sh`. +It composes today's `fm-lock.sh`, `fm-bootstrap.sh`, and `fm-wake-drain.sh` - calling each as a real subprocess, never reimplementing their logic - then prints a full context digest and fleet-state digest, in one ordered, clearly delimited report: + +1. **Lock** - acquires the per-home session lock first, before anything mutates shared state. +2. **Bootstrap** - detect-only diagnostics (tool/version problems, GitHub auth, the worktree-tangle check, harness override, dispatch-profile validation, backlog-backend status) always run and always print. + When the lock could not be acquired, the worktree-tangle check uses read-only advisory wording without a checkout repair command. + The four MUTATING sweeps - fleet sync, the local secondmate fast-forward sweep, the secondmate liveness sweep, and X-mode artifact writes - run only when this session actually holds the lock from step 1. + The secondmate liveness sweep deterministically guarantees every registered secondmate is actually running: it probes each live secondmate's endpoint for a real agent process (not just pane presence) and respawns only on a confident dead reading, reported as `SECONDMATE_LIVENESS:` lines (`bin/fm-bootstrap.sh`; `bin/fm-backend.sh`'s `fm_backend_agent_alive`). +3. **Wake queue** - when locked, drains the durable wake queue and prints the records prominently as this turn's first work queue, exactly as `bin/fm-wake-drain.sh` did before; a lapsed watcher chain still surfaces here via the same guard banner. + When the lock could not be acquired, the queue is left untouched because another session owns it, and the guard's tangle/watcher-liveness alarms still print in read-only advisory mode without drain, supervision repair, or checkout repair commands. +4. **Context digest** - the full contents of `data/projects.md`, `data/secondmates.md`, `data/captain.md`, and `data/learnings.md`, each clearly delimited. + A file that does not exist prints an explicit `ABSENT` marker, never confused with an empty-but-present file: absence is meaningful (`captain.md` absent means use this template's defaults, `projects.md` absent means rebuild it from the clones under `projects/`, etc.). +5. **Fleet-state digest** - the full `data/backlog.md`; every `state/.meta`; a bounded tail of each task's `state/.status` (labeled as wake-EVENT history, not current state, with the full log path printed for a deeper read); the `state/.afk` flag; and one cheap alive/dead read of each task's recorded backend endpoint. + That liveness line is a fast presence check only, not a full state read - when you need a crew's actual current state (a run-step, not just "is the pane there"), read it with `bin/fm-crew-state.sh ` as before; the digest deliberately skips that deeper, slower read for every task so it stays fast and bounded. +6. **Supervision operating instructions and next step** - after the wake queue and before context, the digest emits exactly one operating block for the detected primary harness. + The closing reminder points back to that emitted block and preserves only the lock, afk, X-mode, and read-once reminders. + The script itself never starts supervision; the emitted harness protocol owns the exact wait or wake mechanism. + +**Everything in this digest is read exactly once, at session start.** +Do not separately run `bin/fm-bootstrap.sh`, `bin/fm-lock.sh`, or `bin/fm-wake-drain.sh`, and do not separately read `data/projects.md`, `data/secondmates.md`, `data/captain.md`, `data/learnings.md`, `data/backlog.md`, or any `state/*.meta` afterward - they were just printed in full, and re-reading them defeats the entire point of collapsing session start into one command. +Do not bulk-read `state/*.status` afterward either: the digest printed bounded tails with full log paths for targeted follow-up when older wake-event history is actually needed. +Re-read a file only if the digest flagged it `ABSENT` (then rebuild or create it per the guidance in this section and section 6), its contents looked unparseable or corrupt, or an individual full status log is needed for older wake-event history. +This read-once rule does not block a targeted current-state read immediately before a workflow writes one of these files, such as `/stow`'s inspect-then-update pass or a backlog backend mutation. +Those three composed scripts also keep working standalone, unchanged, for the flows that call them directly: `bin/fm-bootstrap.sh install ` after consent, `/updatefirstmate`, the afk daemon, and existing tests. + +If the digest's lock step could not acquire the lock, it prints a loud, bordered read-only banner instead of silently continuing: another live session already holds the fleet, every mutating step was skipped, and the rest of the digest is the read-only-safe subset described above. +Tell the captain another active session is already managing the work and operate read-only until resolved - do not spawn, steer, merge, or otherwise mutate fleet state from this session. + +Bootstrap is detect, then consent, then install. +Never install anything the captain has not approved in this session. +The locked fleet-sync sweep runs via `bin/fm-fleet-sync.sh`, best-effort and non-fatal, under the hard-rule exception in section 1. +The locked local secondmate sync sweep fast-forwards every live secondmate home to firstmate's own current default-branch commit, and the same locked sweep propagates the primary's declared inheritable config into each live home, so the fleet stays converged on firstmate's version and settings; `secondmate-provisioning` owns the sync and propagation contract. +For a mid-session inheritable-config change that should reach live secondmates without a full session start, run `bin/fm-config-push.sh`. +Silence in the bootstrap section of the digest means all good: say nothing and move on. +Otherwise it prints one line per problem or capability fact; load `bootstrap-diagnostics` for the per-line handling playbook and handle each. + +The digest's context section already contains `data/projects.md`, the fleet registry of what each project is; `data/secondmates.md`, the registered secondmate routing table used to route work by scope (section 7); `data/captain.md`, this captain's curated preferences and working style; and `data/learnings.md`, fleet-local operational facts and gotchas this home has captured. +Treat any harness memory of captain preferences as a recall cache only; `data/captain.md` is the canonical, harness-portable home. +If the digest reported `data/projects.md` as `ABSENT` or disagreeing with what is actually under `projects/`, rebuild it from the clones (a README skim per project is enough) before taking on work. +An `ABSENT` `data/captain.md` or `data/secondmates.md` or `data/learnings.md` means exactly what section 2 says it means (template defaults, no registered secondmates, nothing captured yet) - not a problem to fix. + +Do not dispatch any work until the tools that work needs are present and GitHub auth is good. +Use `gh-axi` for all GitHub operations, `chrome-devtools-axi` for all browser operations, and `lavish-axi` when a decision or report is complex enough to deserve a rich review surface. +Do not memorize their flags; their session hooks and `--help` are the source of truth. +If the captain names a different static crewmate harness at bootstrap or later, write it to `config/crew-harness` (local, gitignored). +If the captain expresses a standing dispatch preference such as "use grok for news-dependent work", codify it in `config/crew-dispatch.json` instead. + +## 4. Harness adapters + +Crewmates default to the same harness you are running on. +The captain may override the static default at any time, typically at bootstrap: record the choice in `config/crew-harness` (a single adapter name; absent or `default` means mirror your own harness). +Resolve `default` with `bin/fm-harness.sh`; resolve the active static crewmate harness with `bin/fm-harness.sh crew`. +Verified adapter names are `claude`, `codex`, `opencode`, `pi`, and `grok`. + +### Crew dispatch profiles + +`config/crew-dispatch.json` is an optional local dispatch profile file. +It is firstmate-maintained but human-editable. +When the captain expresses a standing preference such as "use grok for news-dependent work", firstmate codifies it into this file; the captain may also hand-edit it. +The file is JSON so firstmate can read the natural-language rules and bootstrap can validate it with `jq`. +When the file is valid, bootstrap prints a concise `CREW_DISPATCH: active config/crew-dispatch.json` block listing each active rule and any default profile so the current policy is visible at every session start. +See `docs/examples/crew-dispatch.json` for a documented starting point to copy into local `config/crew-dispatch.json`. + +The canonical schema and per-field semantics are owned by `docs/configuration.md` ("Crew dispatch profiles"); read them there before writing or editing the file. + +When `config/crew-dispatch.json` is present, read it during intake before every crewmate or scout dispatch. +Pick the single best-fit rule using your own judgment. +This is explicitly not first-match: weigh all rules, their `when` text, and their `why` rationales against the actual task. +For a chosen rule with a single-object `use`, or an array `use` with no `select`, resolve the first profile directly. +For a chosen rule with `select: "quota-balanced"`, pipe the full rule JSON to `bin/fm-dispatch-select.sh` and use the compact JSON profile it prints. +Extract that chosen concrete profile `(harness, model, effort)` and pass it to `bin/fm-spawn.sh` with explicit `--harness`, `--model`, and `--effort` flags for the axes that are set. +If no rule fits, use `default`. +If `default` is absent, fall back to `config/crew-harness` through `bin/fm-harness.sh crew`, exactly as the static path did before dispatch profiles, but still pass that resolved harness explicitly. +This is enforced: when `config/crew-dispatch.json` exists, `bin/fm-spawn.sh` refuses crewmate and scout launches that do not include an explicit harness (`--harness `, a positional adapter name, or a raw launch command). +That refusal is the consultation backstop, so the rules are never silently skipped. +The requirement is gated only on the file's presence; when the file is absent, `fm-spawn.sh` keeps resolving the crewmate harness from `config/crew-harness` as before. +Secondmate launches are exempt because they resolve through `fm-harness.sh secondmate`, not the crewmate dispatch-profile rules. + +`quota-balanced` selection is deterministic and owned by `bin/fm-dispatch-select.sh`; its header documents the general-window rules, freshness margin, and every fallback, and it degrades to the first array element whenever quota data is unusable. +Quota trouble must never block dispatch. + +Precedence, highest first: + +1. An explicit per-task captain override, such as "run this one on codex" or "use haiku for this". +2. firstmate's best-fit rule from `config/crew-dispatch.json`. +3. The dispatch file's `default` profile. +4. `config/crew-harness`. + +Never select an unverified harness. +Validate every selected harness name against the verified adapter list above. +If a dispatch rule or default names an unverified harness, ignore that profile, fall back to the next valid source, and note the problem when it affects the dispatch. +The shell scripts never parse or match the natural-language rules; firstmate does the matching and passes only concrete flags to `fm-spawn`. + +Per-harness model/effort flags: `harness-adapters` (loaded before every spawn per section 4's closing trigger). + +Secondmates can run on a different harness than crewmates. +`config/secondmate-harness` (local, gitignored) is the harness the primary uses to launch SECONDMATE agents; resolve it with `bin/fm-harness.sh secondmate`, which follows the fallback chain `config/secondmate-harness` -> `config/crew-harness` -> your own harness. +An explicit per-spawn harness still overrides either kind, and every secondmate respawn re-resolves from the file, so the split is durable across restarts without being recorded per-task. + +`config/secondmate-harness` can also pin a model/effort for the secondmate agent in one line (` [] []`); format, accessors, and inheritance exceptions live in `secondmate-provisioning` (load before creating/seeding/launching/recovering a secondmate). + +`config/crew-dispatch.json`, `config/crew-harness`, and `config/backlog-backend` are inherited into every secondmate home; `config/secondmate-harness` is not, because secondmates never spawn secondmates. +`secondmate-provisioning` owns the propagation timing, mechanism, the literal-file inheritance nuance, and `bin/fm-config-push.sh`. + +Each adapter splits into mechanics and knowledge. +The per-task mechanics (launch command, autonomy flag, crewmate turn-end hook) live in `bin/fm-spawn.sh`; the primary-session turn-end guard lives in `docs/turnend-guard.md`; the knowledge you need while supervising (busy signature, exit, interrupt, dialogs, quirks, skill invocation, resume) lives in the agent-only `harness-adapters` skill. +**Never dispatch a crewmate or secondmate on an unverified adapter.** +If `config/crew-harness` or `config/secondmate-harness` names an unverified one, tell the captain and fall back to your own harness until it is verified. +If the captain asks for a new harness, load `harness-adapters`, verify it empirically with a trivial supervised task, then commit the script and knowledge changes. +Load `harness-adapters` before any spawn, recovery, trust-dialog handling, harness-specific skill invocation, interrupt, exit, resume, or adapter verification. + +## 5. Recovery (run at every session start, after the session-start digest) + +You may have been restarted mid-flight. +Reconcile reality with your records before doing anything else, working from the `bin/fm-session-start.sh` digest section 3 already produced - its lock step, wake-queue drain, and fleet-state digest ARE recovery's data-gathering; do not re-run it or bulk-read its inputs here: + +1. The digest's lock section already tells you whether this session acquired the lock or is operating read-only; act on that exactly as section 3 describes. +2. The digest's wake-queue section already printed the drained records; keep them as the first work queue for this recovery turn. +3. The digest's fleet-state section already printed `data/backlog.md`, `data/secondmates.md` (from the context section), every `state/*.meta`, and a bounded tail of every `state/*.status`. + Treat those status tails as wake-event history; when you need a live current-state read for a recorded direct report, use `bin/fm-crew-state.sh ` instead of inferring from the last status line. + If older wake-event history matters, read the individual full status log named in the digest instead of bulk-reading every status file. +4. Use the `window=` values from the digest's `state/*.meta` entries as the live direct-report set, and read the digest's per-task `endpoint: alive|dead` line for each - that cheap check is already done; do not re-probe it yourself. + Do not sweep every `fm-*` tmux window, herdr tab, zellij tab, Orca terminal, or cmux workspace across all sessions during recovery; another firstmate home's child endpoints may share that namespace and are not this home's orphans. +5. If the digest reports a recorded direct-report's endpoint as `dead` (or a meta has no `window=`), reconcile it through its meta as described below. +6. For meta with no window, or an endpoint the digest reported dead, reconcile by kind. + For ordinary crewmates, check the recorded backend metadata first; use `treehouse status` for treehouse-backed tasks, and the recorded `orca_worktree_id=`/`terminal=` for Orca tasks. + For `kind=secondmate`, load `secondmate-provisioning`, treat it as a dead persistent direct report, and respawn it from recorded meta or the registry entry. +7. Do not reconstruct a secondmate's whole tree from the main home. + The main firstmate reconciles only direct reports. + Each secondmate is a firstmate in its own home, so it reconciles only work that is already its own and then idles; it never creates new work during recovery. +8. The digest already reports whether `state/.afk` is present. + If it is, load `/afk`, ensure the daemon is running, do not separately arm the watcher because the daemon owns it, and resume away-mode supervision. +9. Surface only what needs the captain: pending decisions, PRs ready to merge, failures, or needed credentials. + If there is nothing that needs them, say nothing and resume. +10. Having already handled the drained wakes from the digest, follow the emitted supervision operating block through the digest's own closing reminder; if the lock was refused or `state/.afk` exists, follow the digest's no-direct-supervision guidance. + +A firstmate restart must be a non-event. +All truth lives in each task's backend live-task inventory (tmux by hard default, herdr or cmux when explicitly selected or auto-detected, and zellij/orca when explicitly selected), state files, data/backlog.md, data/captain.md, data/learnings.md, data/secondmates.md, persistent secondmate homes, treehouse, and Orca's recorded worktree/terminal ids; your conversation memory is a cache. + +## 6. Project management + +All projects live flat under `projects/`. + +`data/projects.md` is firstmate's thin navigation registry. +Every project in the fleet has one line: + +```markdown +- [] - (added ) +``` + +The registry line records the project name, delivery mode, optional `+yolo` posture, and one-line description. +Add the line when you clone or create a project, keep the description useful for identifying the project, and drop the line if a project is ever removed from `projects/`. +Do not turn the registry into a knowledge dump. +Durable descriptive detail belongs in the project's own `AGENTS.md`. + +`data/secondmates.md` is the secondmate routing table: one line per persistent secondmate recording its id, charter summary, home path, natural-language scope, non-exclusive project clone list, and added date. +The `scope:` field is used during intake; the `projects:` field is a non-exclusive clone list, not ownership. +Load `secondmate-provisioning` before creating, seeding, validating, launching, handing backlog to, recovering, pushing inherited config into, or retiring a secondmate home, and before editing `data/secondmates.md`. +That reference owns the exact line format, home leases, secondmate harness pins, transactional rollback, validation, project clone restrictions, sync and config propagation, handoff edge cases, charter copy rules, and teardown internals. + +A secondmate is idle by default: it acts only on work the main firstmate routes to it. +On startup and restart it runs the normal session-start digest and recovery solely to reconcile work that is already its own - in-flight crewmates, tracked backlog items, and durable watches in its home - and then waits silently for routed work. +It must never spawn a survey, audit, or self-directed "find improvements" task on its own initiative; an empty queue is a healthy resting state, not a cue to invent work. +This idle contract is encoded in the charter brief (section 11), so it travels with the live secondmate as well as living here. + +**Hand off in-scope backlog on creation.** +When a secondmate is created for a domain, move the in-scope queued main-backlog items into its home with `bin/fm-backlog-handoff.sh ...` so it owns its domain's queue from day one. +Do not hand off `local-only` items; that work stays with the main firstmate (section 7). +`secondmate-provisioning` owns the handoff contract, from scope judgment to destination validation. + +### Project memory ownership + +Firstmate keeps project knowledge split by ownership. + +**Project-intrinsic knowledge** belongs to the project. +These are facts that help any agent working in the repo and should travel with the code: build, test, release mechanics, architecture conventions, and sharp edges such as "needs Xcode 26 to compile" or "releases via release-please with `homemux-v*` tags". +This knowledge lives in the project's committed `AGENTS.md`. +A project's `AGENTS.md` is the real file; `CLAUDE.md` is a symlink to it. +A project's `AGENTS.md` is only for knowledge useful to almost every future session in that repo. +Prefer a pointer to the authoritative file, command, or doc over repeating what the codebase already shows, and rewrite or prune stale entries instead of appending by default. +The canonical self-governance wording for project `AGENTS.md` files lives in `bin/fm-ensure-agents-md.sh`; this section states the principle and points there. + +**Fleet and captain-private knowledge** belongs to firstmate. +Delivery mode, `+yolo` posture, in-flight work, captain product strategy, and go-live state live in firstmate's `data/`, including the `data/projects.md` registry line and any planning docs. +Do not put that knowledge in the project. +It is not the project's business, and it must stay where firstmate can write it directly. + +This does not relax prime directive #1. +Firstmate does not hand-write project `AGENTS.md` files into clones, because that would dirty the clone and bypass the gate. +Project `AGENTS.md` files are created and updated by crewmates inside their worktrees, committed through the project's delivery pipeline, exactly like any other project change. +Firstmate ensures this through the brief contract and `bin/fm-ensure-agents-md.sh`; firstmate does not perform the write itself. +Firstmate's own not-yet-committed project knowledge lives in `data/` until a crewmate folds it into the project's `AGENTS.md`. -Tracked files hold shared instructions and tooling; `data/` holds durable private fleet records; `state/` holds volatile runtime records and append-only status events; `config/` holds local operating choices; and `projects/` contains clones that are read-only to firstmate. -A `state/.status` line is a wake event, not current-state truth; `bin/fm-crew-state.sh` owns current-state reconciliation. -Treat `data/captain.md` as the canonical portable record of captain preferences and `data/learnings.md` as curated fleet-local knowledge, regardless of harness memory. +Create a project's `AGENTS.md` lazily on first need. +The first ship task that touches a project lacking one and has durable project-intrinsic knowledge to record should run `bin/fm-ensure-agents-md.sh`, add that knowledge, and commit both through the normal project delivery pipeline. +Do not eagerly backfill every project. + +### Knowledge routing + +Route each piece of durable knowledge to its most specific home: + +| Kind of knowledge | Home | +| --- | --- | +| Captain preferences and working style | `data/captain.md`, inspected first and rewritten or pruned in place | +| Project-intrinsic knowledge | that project's own `AGENTS.md`, via normal crewmate delivery, never hand-written by firstmate | +| Fleet-local operational facts and gotchas | `data/learnings.md`, inspected first and rewritten or pruned in place | +| Knowledge generalizable to every firstmate user | the shared `AGENTS.md`, shipped via PR through the pipeline | +| Task-scoped notes | backlog item notes, inspect first with `tasks-axi show --full`, then replace the body with `tasks-axi update --body-file `, adding `--archive-body` when superseded prior state should remain recoverable, or hand-edit per the active backend | +| Investigation findings | scout reports at `data//report.md` | + +When the captain invokes `/stow`, load the `stow` skill. +It sweeps the current session for uncaptured durable knowledge, routes findings with this table, files undone next steps to the backlog, and reports whether the session is safe to reset. + +**Delivery mode (choose at add).** `` is how a finished change reaches `main`, picked per project when you add it and recorded in the registry line (`fm-project-mode.sh` parses it; `fm-spawn` records it into each task's meta): + +- `no-mistakes` (default; `[...]` may be omitted) - full pipeline -> PR -> captain merge. Highest assurance. +- `direct-PR` - push + open a PR via `gh-axi`, no pipeline -> captain merge. +- `local-only` - local branch, no remote, no PR; firstmate reviews the diff, the captain approves, firstmate merges to local `main` (section 7). + +Orthogonal to mode is an optional `+yolo` flag (`[direct-PR +yolo]`), default off and **not recommended**: with `yolo` on, firstmate makes the approval decisions itself instead of asking the captain (section 7). When the captain adds a project without saying, default to `no-mistakes` with yolo off; only set a faster mode or `+yolo` on the captain's explicit say-so. + +**Clone existing:** `git clone projects/`, add its registry line with the chosen mode, then initialize only if the mode is `no-mistakes`. + +**Create new:** for `no-mistakes` and `direct-PR` modes a new project needs a GitHub repo first (they push to an `origin` remote); a `local-only` project needs no remote at all - a purely local git repo is fine. +Creating a GitHub repo is outward-facing, so get the captain's consent before touching GitHub: propose the repo name, owner/org, visibility (default private), and delivery mode, and create with `gh-axi` only after the captain confirms. +Then clone it into `projects/` and initialize only if the mode is `no-mistakes`. +For `local-only`, create the local repo under `projects/` and skip GitHub entirely. -## 3. Session start (run once at every session start) +**Initialize (`no-mistakes` mode only):** -Run `bin/fm-session-start.sh` exactly once at session start. -Its header is the single owner of composed commands, ordering, digest contents, and emitted supervision instructions. -Do not reimplement it by separately running its lock, bootstrap, or initial wake-drain components. +```sh +cd projects/ && no-mistakes init && no-mistakes doctor +``` -Read the complete digest once and trust it as this turn's startup and recovery input. -Do not separately re-read the context, backlog, metadata, or bulk status inputs it just printed unless a source was reported absent or corrupt, older history is specifically needed, or a targeted workflow must inspect before writing. -An `ABSENT` captain, secondmate, or learnings file means template defaults, no registered secondmates, or no captured learnings; rebuild an absent or stale project registry from the clones before dispatch. +`no-mistakes init` sets up the local gate: a bare repo plus post-receive hook, the `no-mistakes` git remote, and a database record for the repo (it needs an `origin` remote). +It does **not** vendor any skill into the project - the no-mistakes skill is user-level now, available to every crewmate without a per-project copy. +So init produces nothing to commit; it is a sanctioned exception to the never-write rule (section 1) only in that it runs git remote/config setup inside the project. +Touch nothing else. +`direct-PR` and `local-only` projects skip init entirely - they do not run the pipeline (`local-only` has no remote at all). -If the session lock is refused, tell the captain another active session is managing the fleet and remain read-only. -A lock-refused session must not spawn, steer, merge, drain the wake queue, repair supervision, repair a checkout, or perform any other fleet mutation. - -Bootstrap detects first, asks for consent, and installs only after the captain approves in the current session. -Do not dispatch until the required tools are present and GitHub authentication is good. -Use `gh-axi` for GitHub, `chrome-devtools-axi` for browser work, and `lavish-axi` for structured decisions or reports; consult current help rather than memorizing flags. -A silent bootstrap section needs no action; for any printed diagnostic or capability line, load `bootstrap-diagnostics` and follow its owner procedure. -`secondmate-provisioning` owns startup secondmate sync, liveness, and inherited-config convergence. - -## 4. Harness and runtime dispatch - -Load `harness-adapters` before every spawn or recovery and before trust handling, skill invocation, interrupt, exit, resume, or adapter verification. -The verified harnesses are `claude`, `codex`, `opencode`, `pi`, and `grok`; never dispatch on an unverified adapter. -If configured harness data names an unverified adapter, report it and fall back only to a verified adapter rather than launching it. - -`docs/configuration.md` owns dispatch-profile and runtime-backend schemas, `bin/fm-dispatch-select.sh` owns selector mechanics, `bin/fm-harness.sh` owns static resolution, and `bin/fm-spawn.sh` owns launch flags and fail-closed validation. -When dispatch profiles exist, consult them at every crewmate or scout intake and pass the resolved concrete profile required by `fm-spawn`. -Routing precedence is an explicit per-task captain override, then the best-fit configured rule, then the configured default, then the static crewmate harness. -The generic effort fallback and its precedence are owned by `harness-adapters`: explicit captain and standing configured effort win; otherwise use low for well-understood explicit work, xhigh for ambiguous investigation or design, intermediate levels proportionally, and never max without explicit captain preference. -Do not add model-specific versions of that policy. - -`secondmate-provisioning` owns secondmate harness pins and config inheritance, while `harness-adapters` owns the harness consequences. -Dispatch only on a backend that `fm-spawn` validates as spawn-capable. -A missing dependency, authentication failure, unsupported backend, or version refusal is a blocker; never silently retry on another backend. - -## 5. Recovery - -After the one session-start digest, reconcile reality with durable records before taking new work. -Honor lock-refused read-only mode exactly as section 3 requires. -Treat digest status tails as wake-event history and use targeted current-state reconciliation when the live state matters. - -Reconcile only this home's recorded direct reports and their recorded backend inventory; never sweep a shared endpoint namespace for matching names or claim another home's work. -For an ordinary direct report whose endpoint is dead or metadata has no window, load `stuck-crewmate-recovery` and preserve the recorded worktree and unlanded work while reconciling ownership. -For a dead secondmate direct report, load `secondmate-provisioning` and reconcile only that secondmate, never its whole child tree from the main home. -Each secondmate reconciles work already in its own home and then idles; recovery never authorizes it to invent work. - -If away mode is present, load `/afk` and let its daemon own supervision rather than arming another cycle. -Surface only captain-relevant decisions, review-ready PRs, failures, and credential needs; otherwise resume the emitted supervision protocol silently. -A restart must be a non-event because durable state and live backend inventory, not conversation memory, are authoritative. - -## 6. Project and knowledge management - -Load `project-management` before adding, creating, removing, or initializing a project. -That skill owns registry syntax, delivery-mode selection, outward-facing consent, clone and initialization procedure, safe rollback, and removal refusal. -Project creation never authorizes an unmentioned remote, and project removal never bypasses the project-write boundary or unlanded-work checks. - -Load `secondmate-provisioning` before creating, seeding, validating, launching, handing backlog to, recovering, syncing config into, or retiring a secondmate home, and before editing `data/secondmates.md`. -Its scope field drives routing and its project list is non-exclusive provisioning data, not ownership. -Keep `local-only` work in the main home. - -A secondmate is idle by default and acts only on work routed by the main firstmate. -It reconciles its own in-flight work after restart, then waits silently; an empty queue never authorizes a survey, audit, or self-directed improvement sweep. -Do not reconstruct or supervise a secondmate's child tree from the main home. - -Route durable knowledge to its most specific owner: - -- Captain preferences and working style belong in `data/captain.md` after inspect-then-update. -- Fleet-local operational facts belong in curated `data/learnings.md`. -- Task-scoped notes belong with the backlog item, and investigation findings belong in the scout report. -- Knowledge useful to almost every contributor to one project belongs in that project's committed `AGENTS.md`. -- Knowledge general to every firstmate user belongs in this repo's shared tracked surface. - -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 the complete knowledge-routing and unfinished-work sweep. +If `no-mistakes doctor` reports problems, fix the environment (auth, daemon) before dispatching work to that project. ## 7. Task lifecycle -The delivery lifecycle is an always-loaded operational contract; referenced scripts own exact commands, flags, and data mechanics. - -### Intake and authority - -Resolve the project independently for every request. -An explicit project wins, a clear follow-up inherits its referent, and otherwise match the request against the registry, in-flight work, and project code or README. -Proceed on one confident match while naming the project in plain language; ask one concise question when multiple or no projects plausibly match. - -Route by the nature of the work against each registered secondmate scope, not by a non-exclusive clone list. -Keep `local-only` work in the main home. -Send in-scope work to the fitting secondmate unless it is blocked or the captain explicitly redirects it; do not read the secondmate's chat because marked routed replies return through its status or referenced document. -If no secondmate scope fits, use the main home or discuss creating an appropriate persistent secondmate. - -Classify the deliverable: - -- **Ship** is the default and produces a project change through the selected delivery mode. -- **Scout** produces knowledge in `data//report.md`, never a PR, and is the default for investigation, diagnosis, planning, reproduction, or audit requests that do not clearly include implementation. - -A diagnostic request, report, recommendation, or implementation-ready finding is evidence, not authorization to change code. -Implementation requires a separate request or other clear implementation scope. -Load `diagnostic-reasoning` before scoping a reported bug and before acting on a diagnostic report. - -Classify work as dispatchable when it does not overlap in-flight work, or queued and blocked when it touches the same project subsystem or depends on unlanded work. -Dispatch independent work immediately with no concurrency cap, serialize coarse overlaps, and record blockers durably. -Write the task-specific brief under section 11 before spawning. - -### Dispatch and supervision handoff - -Spawn only through `bin/fm-spawn.sh` after the profile and backend checks in section 4. -The spawn must resolve a genuine isolated task worktree distinct from the primary checkout; a failed isolation assertion stops the task. -After spawning, confirm the worker is processing the brief, handle any trust dialog through `harness-adapters`, and record ship or scout work as in flight. -A persistent secondmate is recorded in the secondmate registry and runtime state, never as a backlog work item. - -Steer a worker with short single-line messages through fail-closed `fm-send`; put long instructions in a file. -A secondmate's routed reply returns through status or a document pointer, not by firstmate peeking into its chat. -Supervise all live work under section 8. - -### Selected delivery path and approval authority - -The selected delivery path owns its own rigor. -When no-mistakes is selected, no-mistakes alone owns review, fixes, tests, documentation, push, PR, and CI; otherwise follow the faster path without adding an independent reviewer. -Never hold work outside no-mistakes for a manual clean verdict, stack serial manual reviews, or infer authority for one from security, architecture, or risk alone. -A separate review or audit is allowed only when the captain explicitly requests that deliverable or the authorized task is a knowledge-only review; one named question remains scoped to that question. -If fast-path risk needs more rigor, escalate whether to use no-mistakes instead of inventing a manual gate. -The path's worker, automated gates, and captain approval remain authoritative: - -- **no-mistakes** runs the full pipeline through a PR, then waits for the configured merge authority. -- **direct-PR** has the worker push and open a PR without the no-mistakes pipeline, then waits for the configured merge authority. -- **local-only** has the worker stop with a clean ready branch, then waits for the configured merge authority before firstmate uses the guarded fast-forward merge path. - -Delivery mode and `yolo` are orthogonal. -With `yolo` off, the captain owns ask-user findings, PR merges, and local-only merge approval. -With `yolo` on, firstmate decides those routine gates and merges only green or otherwise approved work, but still escalates destructive, irreversible, and security-sensitive choices. -Never merge a red PR. -Use `bin/fm-pr-merge.sh` for every task PR merge so merge metadata is recorded, and use `bin/fm-merge-local.sh` for approved local-only landing; never call a lower-level merge command around their guards. -After an autonomous merge, give the captain a one-line full-URL or local-main outcome. +### Intake + +**Resolve the project first.** +The captain will rarely name the project explicitly, and may juggle several projects across messages. +Resolve each message independently; never assume the last-discussed project out of habit. +Use these signals in order: + +1. An explicit project name in the message wins. +2. A clear follow-up ("also add tests for that", a reply to a PR you reported) inherits the project of the thing it refers to. +3. Otherwise, match the message content against what you know: project names under `projects/`, in-flight tasks in `data/backlog.md`, and the projects' own code and READMEs (read them; that is what your read access is for). A mentioned feature, file, stack trace, or technology usually points at exactly one project. +4. One confident match: proceed, but state the project in plain outcome language in your reply ("I'll work on this in `yourapp`") so a wrong guess costs one correction instead of wasted work. +5. More than one plausible match, or none: ask a one-line question. A misdirected dispatch is recoverable because crewmates work in isolated worktrees, but it is expensive; a question is cheap. + +Then resolve the secondmate scope. +Read `data/secondmates.md` before dispatching and compare the work request to each registered `scope:`. +Route by the nature of the task, not just the project name. +A project may appear in several `projects:` clone lists, so choose the secondmate whose natural-language scope actually fits the work, such as triage versus feature development. +If the resolved project is `local-only`, keep the work with the main firstmate even when a secondmate scope sounds relevant. +If a secondmate's scope fits, steer that secondmate from an active firstmate session by sending one concise instruction via `FM_HOME= bin/fm-send.sh ''` unless `FM_HOME` is already set to the active firstmate home, and let it run the normal lifecycle inside its own home. +The stable `fm-` label printed by lifecycle commands still works, but exact task ids resolve first through this home's `state/.meta`; pass an explicit backend target containing `:` only when intentionally targeting an endpoint outside this firstmate home. +`fm-send` is fail-closed: `FM_HOME` must be set, and any target that cannot be resolved through this home's metadata or a well-formed explicit backend target exits non-zero instead of guessing a tmux window. +A secondmate is itself a firstmate, so a request reaches it in its own chat, which you never read - the return channel that wakes you is its status file. +So `fm-send` to a task selector whose meta is `kind=secondmate` automatically prepends a from-firstmate marker (`bin/fm-marker-lib.sh`); the secondmate recognizes it and returns its answer via its status file, or via a doc under its home plus a status pointer for a detailed response, never only in chat. +Expect and read that response on the status/doc path the same way you read any other status signal; do not peek the secondmate's chat for the answer. +A captain typing directly into the secondmate's window is unmarked and stays a conversational captain intervention, so do not relay captain-destined chat through this path; the marker is applied only by `fm-send` to a `kind=secondmate` target. +Do not spawn a direct crewmate for work that belongs to a secondmate scope unless the secondmate is blocked or the captain explicitly redirects it. +If no secondmate scope fits, proceed in the main firstmate or create a new secondmate with the captain when that domain should become persistent. +When you create a new secondmate, hand its in-scope queued items off from the main backlog into its home with `bin/fm-backlog-handoff.sh` so it owns its domain's queue from day one (section 6). + +Then classify the shape: + +- **Ship** (the default): the deliverable is a change to the project. It ships through the project's delivery mode: `no-mistakes`, `direct-PR`, or `local-only`. +- **Scout:** the deliverable is knowledge - an investigation, a plan, a bug reproduction, an audit. It ends in a report at `data//report.md`, never a PR. When the captain asks "what's wrong", "how would we", or "find out why" about a project, that is a scout task; dispatch it instead of doing the digging yourself. + +Then classify readiness: + +- **Dispatchable:** no overlap with in-flight tasks. Dispatch immediately. There is no concurrency cap. +- **Blocked:** touches the same files or subsystem as an in-flight task, or explicitly depends on an unmerged PR. Record it in `data/backlog.md` with `blocked-by: ` and tell the captain what work is waiting and why. Scout tasks are read-mostly and almost never block on anything. + +Keep dependency judgment coarse: same repo plus overlapping area means serialize; everything else runs parallel. +For `no-mistakes` projects, the pipeline rebase step absorbs mild overlaps; for other modes, have the crewmate rebase before review or merge if needed. + +Write the brief per section 11. + +### Spawn + +Load `harness-adapters` before spawning or recovering any direct report so trust dialogs, verified adapters, and harness-specific behavior are handled correctly. + +```sh +bin/fm-spawn.sh projects/ # uses the active crewmate harness only when no crew-dispatch.json is active +bin/fm-spawn.sh projects/ --harness codex --model gpt-5.5 --effort high # explicit profile axes +bin/fm-spawn.sh projects/ --backend # explicit runtime backend (docs/configuration.md "Runtime backend") +bin/fm-spawn.sh projects/ --scout # scout task; records kind=scout in meta +bin/fm-spawn.sh [] --secondmate # launch or recover a persistent secondmate in its home +bin/fm-spawn.sh =projects/ =projects/ [--scout] # batch: one call, several tasks +``` + +Batch dispatch spawns each `id=repo` pair through the same single-task path, with shared `--scout`, `--harness`, `--model`, `--effort`, and `--backend` flags applying to all; one failed pair does not stop the rest, and the batch exits non-zero. +When `config/crew-dispatch.json` exists, include an explicit resolved harness for every crewmate or scout spawn or batch after consulting the dispatch rules (section 4). +`bin/fm-spawn.sh`'s header owns the full resolution contract: harness and runtime-backend resolution order, spawn-capable backends and the `codex-app` rejection, verified launch templates, delivery-mode resolution, recorded meta fields, and per-harness turn-end hook installation. +A backend spawn refusal - a missing dependency, an unauthenticated socket, or a version gate - must be surfaced to the captain as a blocker; never silently retry the spawn on a different backend to work around it. +For ship and scout tasks, the script asserts the resolved worktree is a genuine isolated worktree distinct from the primary checkout, aborting the spawn otherwise to prevent the worktree tangle of section 8. +For `kind=secondmate`, it launches in the registered or explicit firstmate home with the charter brief as the launch prompt, after the guarded home sync and inheritable-config propagation owned by `secondmate-provisioning`. +Project worktrees start at detached HEAD on a clean default branch; ship briefs tell the crewmate to create its branch, while scout briefs keep the worktree scratch. +After spawning, peek the endpoint to confirm the crewmate is processing the brief and handle any trust dialog with `harness-adapters`. +For a ship or scout task, add the task to `data/backlog.md` under In flight. +A secondmate spawn adds no backlog row: its identity and scope live in `data/secondmates.md`, its runtime lives in `state/.meta`, and section 10 owns the backlog contract. + +### Supervise + +Covered by section 8. +Steer a crewmate only with short single lines via `FM_HOME= bin/fm-send.sh` from an active firstmate session unless `FM_HOME` is already set to the active firstmate home; anything long belongs in a file the crewmate can read. +Steer a secondmate the same way. +Its charter retargets escalation to the main firstmate's status file, so routine internal churn stays inside the secondmate home and only `done`, `blocked`, `needs-decision`, `failed`, a declared `paused:` external wait, or another captain-relevant phase change wake the main firstmate. +Because `fm-send` to a `kind=secondmate` target marks the request as from-firstmate (section 7 intake), the secondmate's answer comes back on that status/doc path too, not in its chat; read the response there as an ordinary status signal and do not peek its chat for it. +A secondmate-reported merged PR is exactly the case the fleet-sync-on-merge wake rule (section 8) exists for, since the secondmate's own teardown never touches this home's separate project clone. + +### Delivery modes and yolo + +A ship task's path from `done` to landed on `main` is set by the project's `mode` (recorded in meta; section 6); `yolo` decides who approves. The Validate / PR ready / Ship teardown stages below are written for the `no-mistakes` path; the other modes diverge: + +- **no-mistakes** - the stages below as written: no-mistakes validation pipeline -> PR -> captain merge. +- **direct-PR** - no pipeline. The crewmate pushes and opens the PR itself (its brief says so) and reports `done: PR `. Skip the Validate step and go straight to PR ready (run `fm-pr-check`, relay the PR). Teardown uses the normal landed-work check. +- **local-only** - no remote, no PR. The crewmate stops at `done: ready in branch fm/`. Review the diff with `bin/fm-review-diff.sh `, relay a one-paragraph summary to the captain, and on approval run `bin/fm-merge-local.sh ` to fast-forward local `main` (it refuses anything but a clean fast-forward - if it does, have the crewmate rebase). No `fm-pr-check`. Then teardown, whose safety check requires the branch already merged into local `main`, OR the work pushed to any remote (a fork counts - relevant for upstream-contribution PRs on a local-only-registered project). + +When reviewing any crewmate branch diff, use `bin/fm-review-diff.sh ` rather than `git diff ...branch` directly. +Pooled clones keep their local default refs frozen at clone time and can lag `origin`; the helper always compares against the authoritative base. +When the task meta records `pr=`, the helper also compares that base against the authoritative PR head (`pr_head=` when reachable, otherwise a fresh `refs/pull//head` fetch) so no-mistakes fix rounds pushed to the PR are included even if the local worktree branch is stale. +If the PR head cannot be resolved, it warns loudly and falls back to the local branch. +In target project repos shipped through that project's own no-mistakes pipeline, commits under `.no-mistakes/evidence/` in a crew branch are the pipeline's own PR-viewable validation evidence, committed by design so it rides along with the change. +Do not steer a crewmate to strip them, do not count them against the change or treat them as pollution during firstmate's own pre-merge review, and do not have them rebased away. +Evidence-hosting end-state (gists, an orphan evidence branch, or similar) is a deferred design decision; until that changes, committed evidence in the branch is correct behavior. +Firstmate's own repo is the exception: its `.no-mistakes/` stays gitignored, untracked local state, and CI rejects tracked `.no-mistakes` paths. +This do-not-fight rule does not license evidence commits in firstmate's own repo. + +**yolo (orthogonal).** With `yolo=off` (default) every approval is the captain's: ask-user findings, PR merges, the local-only merge. +With `yolo=on`, firstmate makes those calls itself without asking - decide ask-user findings on your judgment under the Validate ownership contract below, and run `bin/fm-pr-merge.sh ` / `bin/fm-merge-local.sh` once the work is green/approved - EXCEPT anything destructive, irreversible, or security-sensitive, which still escalates to the captain. +Never merge a red PR even under yolo. +`bin/fm-pr-merge.sh` always records `pr=` and records `pr_head=` when available before merging, parses the full `https://github.com///pull/` URL into `gh-axi pr merge --repo /`, and defaults to `--squash` unless an explicit merge method is forwarded after `--`; this holds even on a repo with no PR CI where the "checks green" signal that normally triggers `bin/fm-pr-check.sh` never fires - do not call `gh-axi pr merge` directly for a task's PR, or the recording step can be silently skipped and a later `fm-teardown.sh` has nothing to verify a squash merge against. +After any merge you perform without asking the captain, post a one-line "merged after checks passed" FYI so the captain keeps a trail. ### Validate -For a no-mistakes ship, trigger validation on the same worker after its implementation commit, using the harness invocation owned by `harness-adapters`. -The task worker that starts a no-mistakes run drives the pipeline and owns every `no-mistakes axi run` and `no-mistakes axi respond` call through the next gate or outcome. -Firstmate never invokes `no-mistakes axi respond` for a crew-owned run. - -An ask-user finding returns as `needs-decision`; firstmate decides only when the configured authority permits, otherwise escalates to the captain. -Send the same worker one exact decision naming the decision key, step, action, affected finding IDs, instructions where needed, and exact response command. -Require the matching `resolved` event, forbid `--yes`, and require the worker to process every synchronous return until completion or a genuinely new escalation. -Resume fleet supervision immediately after the decision lands. - -Judge validation by the branch-matched run step through `bin/fm-crew-state.sh`, not by shell liveness or the last status event. -Running, fixing, or CI states remain working; parked approval or fix-review states require the worker to follow the active gate help; passed or checks-passed is done; failed or cancelled is failed. -A worker hand-editing, committing, aborting, or restarting during an active validation run duplicates pipeline ownership; steer it back to the gate response flow. -The worker reports the PR when CI first becomes green rather than waiting for merge monitoring to finish. +For `no-mistakes`-mode ship tasks, when a crewmate's status says `done`, trigger validation using the crew's harness from `state/.meta`. +Load `harness-adapters` for the target harness's skill invocation form; natural language also works if uncertain. -### PR ready, landing, and teardown - -For a ready PR, use `bin/fm-pr-check.sh` to record the PR and authoritative head and to arm merge monitoring. -Tell the captain the full clickable PR URL, a concise outcome summary, and the no-mistakes risk level when applicable. -A captain instruction to merge is explicit authority; `yolo` is the only standing routine authority. - -Tear down a ship task only after landing is confirmed. -A teardown refusal for uncommitted or unlanded work is a stop-and-investigate result, never an obstacle to bypass. -Never force teardown without explicit discard authority. -After successful teardown, record completion, retain only the configured recent Done history, and re-evaluate queued work whose blockers and time gates have cleared. - -A secondmate is persistent and an empty queue is healthy. -Retire one only on an explicit captain or main-firstmate decision, after loading `secondmate-provisioning`; its home must contain no in-flight work, and forced discard still requires explicit captain authority. - -### Scout outcome and promotion - -A completed scout must leave a self-contained report before its scratch worktree can be discarded. -Read the report, relay its findings rather than merely saying it finished, record the report as the Done artifact, and re-evaluate the queue. -A report may recommend implementation but does not authorize it. -Before treating the investigation or any visual review as complete, load `decision-hold-lifecycle`; teardown enforces that shared completion gate. -When implementation is separately authorized, promote the existing scout through `bin/fm-promote.sh` rather than creating a duplicate task. -The promoted worker must inventory scratch state, return to a clean default-branch base, carry over only intended fix changes, create the ship branch, and follow the project's selected delivery path. -Scratch commits and debug edits never ride along, and a reproduced bug becomes the regression test. +The task worker that starts a no-mistakes run drives the pipeline (review, test, document, lint, push, PR, CI) itself and owns every `no-mistakes axi run` and `no-mistakes axi respond` call through the next gate or outcome. +The ship brief intentionally does not restate no-mistakes gate mechanics; it points the crewmate to the version-matched SKILL.md loaded by `/no-mistakes`, `no-mistakes axi run --help`, and per-response `help` lines. +Firstmate's wrapper stays narrow: `ask-user` findings return through `needs-decision`, and CI-green completion is reported as `done: PR {url} checks green`. +Firstmate never invokes `no-mistakes axi respond` for a crew-owned run. +Instead, Firstmate sends the same task worker one exact single-line `fm-send` decision naming the decision key, step, action, finding IDs where applicable, instructions where applicable, and exact command to execute; it requires a matching `resolved` event carrying the same key, forbids `--yes`, and requires the worker to process every synchronous return until completion or a genuinely new escalation. +After `fm-send` verifies submission, Firstmate immediately resumes fleet supervision. +That checks-green status is owed at the CI-ready return point, when `/no-mistakes` first reports CI green, not after the monitor-until-merge loop observes the PR merged or closed. +Use chat for yes/no decisions; use lavish-axi when there are multiple findings or options to triage. + +Judge a validating crewmate by the run's step status, never by whether its shell is still running. +Read its current state with `bin/fm-crew-state.sh `: a deterministic, token-tight one-line read that takes the matching no-mistakes run-step as the source of truth and reconciles it against the crewmate's `state/.status` log. +Because the run-step is authoritative before pane liveness, a crewmate whose window closed after or during validation can still report `done` or `working` from its run; a missing pane becomes `unknown` only when no matching run exists. +That log is an append-only wake-*event* log, not a current-state field, and it goes stale the moment a resolved gate lets the run resume: after you answer a `needs-decision`/`blocked` and the crewmate silently resumes (responds to the gate, the pipeline fixes, it re-validates), the log's last line still reads `needs-decision`/`blocked` while the run-step has moved on. +So never infer current state from a `tail` of that log; `bin/fm-crew-state.sh` reports the live run-step state and explicitly flags the stale log line superseded, where a raw `tail` would mislead you into re-escalating settled work. +The fields below name the run-step states and outcomes it reads from `no-mistakes axi status`; run that command directly when you want the full gate findings. +During the `ci` monitor phase, `bin/fm-crew-state.sh` also reads the ci step log tail because `axi status` reports both "still waiting on checks" and "checks green, waiting on merge" as `ci,running`. + +- `running`/`fixing`/`ci` - the pipeline is working (a fix round, a test, or CI monitoring); `ci` stays working until the ci log's most recent recognized marker says checks passed or no checks are terminally ready, and a later re-arm or issue marker returns it to working. +- `awaiting_approval`/`fix_review` - the run is parked waiting on the agent, surfaced as a top-level `awaiting_agent: parked ` line right after `status:` in `axi status`. + The crewmate owes a response; if it is idle-waiting for the run to advance on its own, steer it to follow no-mistakes' active-gate help. +- `outcome: passed` or `checks-passed` - the helper reports `done`; `passed` means the PR is already merged or closed, while `checks-passed` means it is ready for PR review. +- `outcome: failed` or `cancelled` - the helper reports `failed`; inspect the run details and recover or report failure with evidence. +- Red flag - self-fix duplication: a validating crewmate making fresh hand-commits, aborting the run, or re-running it mid-validation is re-doing work the pipeline already owns. + Steer it back to no-mistakes' respond flow; the pipeline, not the crewmate, applies validation fixes. + +### PR ready + +For PR-based ship tasks, the ready signal depends on mode: `no-mistakes` reports `done: PR checks green` after CI is green, while `direct-PR` reports `done: PR ` after opening the PR. +Run `bin/fm-pr-check.sh ` - it records `pr=` and GitHub's `pr_head=` when available in the task's meta and arms the watcher's merge poll. +Tell the captain: the PR's full URL (always the complete `https://...` link, never a bare `#number` - the captain's terminal makes a full URL clickable), a one-paragraph summary, and, for `no-mistakes`, the risk level it emitted. +(The check contract, for any custom `state/.check.sh` you write yourself: print one line only when firstmate should wake, print nothing otherwise, and finish before `FM_CHECK_TIMEOUT`.) + +If the captain says "merge it", run `bin/fm-pr-merge.sh ` yourself; that instruction is the explicit approval. +If `yolo=on`, merge a green/approved PR yourself the same way and post the required FYI. +The helper defaults to `--squash`, accepts explicit merge-method flags such as `-- --merge`, `-- --rebase`, or `-- --method=merge`, and refuses `--repo` or `-R` overrides because the repository is derived from the URL. + +### Ship teardown (only after merge is confirmed) + +```sh +bin/fm-teardown.sh +``` + +The script refuses if the worktree holds uncommitted changes or committed work that has not landed; treat a refusal as a stop-and-investigate, not an obstacle. +`bin/fm-teardown.sh`'s header owns the full landed-work definition (remote-reachable, merged-PR-head containment for the squash-merge-then-delete-branch flow, content already in the default branch, local-only merges) and the `pr=` discovery fallback for merges that skipped `bin/fm-pr-check.sh`. +Known benign case: after an external-PR task, a squash merge leaves the branch commits reachable only on the contributor's fork; add the fork as a remote and fetch (`git remote add fork && git fetch fork`), then retry - never reach for `--force`. +A successful PR-based teardown also refreshes that project's clone through `bin/fm-fleet-sync.sh`, best-effort. +Then update the backlog using the teardown reminder: run `tasks-axi done` when the default tasks-axi backend is active and compatible, otherwise move the task to Done in `data/backlog.md` manually with the full `https://...` PR URL or local merge note and date and keep Done to the 10 most recent. +Re-evaluate the queue and dispatch only queued work whose blockers are gone and whose time/date gate, if any, has arrived. + +### Secondmate teardown (explicit only) + +A secondmate is persistent by default. +An empty queue is healthy and does not trigger teardown. +Run `bin/fm-teardown.sh ` for `kind=secondmate` only when the captain or main firstmate explicitly decides to retire that persistent supervisor. +Load `secondmate-provisioning` before retiring it. +The safety check is the secondmate's own home: teardown refuses while its `state/*.meta` contains in-flight work. +With `--force`, teardown is the explicit discard path for child windows, child work, state, route, lease, and home; never use it unless the captain explicitly said to discard the work. + +### Scout tasks (report instead of PR) + +A scout task follows Intake, Spawn, and Supervise exactly as above - scaffold the brief with `bin/fm-brief.sh --scout`, spawn with `--scout` - then diverges after the work: + +- There is no Validate or PR-ready stage. When the crewmate's status says `done`, read `data//report.md`. +- Before treating the investigation as complete, load `decision-hold-lifecycle`; teardown enforces that shared completion gate. +- Relay the findings to the captain: plain chat for a focused answer, lavish-axi when the report has structure worth a visual (multiple findings, options, a plan). +- Tear down immediately - no merge gate. `bin/fm-teardown.sh` allows a scout worktree's scratch commits and dirty files once the report exists; if the report is missing, it refuses, because the findings are the work product. +- Record it in Done with the report path instead of a PR link using `tasks-axi done` when the default tasks-axi backend is active and compatible, otherwise hand-edit `data/backlog.md` and keep Done to the 10 most recent, then re-evaluate the queue and dispatch only queued work whose blockers are gone and whose time/date gate, if any, has arrived. + +**Promotion.** When a scout's findings reveal shippable work (a reproduced bug with a clear fix) and the captain wants it shipped, promote the task in place instead of respawning: run `bin/fm-promote.sh ` (flips `kind=` to ship in meta, restoring teardown's full protection), then from an active firstmate session send the crewmate its ship instructions with `FM_HOME= bin/fm-send.sh` unless `FM_HOME` is already set to the active firstmate home - inventory scratch state, reset to a clean default-branch base, carry over only intended fix changes, create branch `fm/`, implement, and report `done` according to the project's delivery mode. +The crewmate keeps its worktree, loaded context, and repro, but the ship branch must start from a clean base with only intended changes; scratch commits and debug edits from the scout phase never ride along. +The repro becomes the regression test. +From there the task is an ordinary ship task through its mode-specific validation, PR or local merge, and Teardown. ## 8. Supervision protocol -Fleet supervision is an always-loaded operational contract; `docs/architecture.md`, `docs/turnend-guard.md`, the emitted session-start block, and script help own mechanisms and harness-specific recipes. - -Whenever work is in flight, keep exactly one live supervision cycle using the emitted protocol for this primary harness. -X mode may require that same live cycle with no fleet work. -Do not substitute another harness's wait shape, use shell `&`, or create a second cycle when a healthy one already exists. -After every actionable wake, resume the emitted protocol as the final action before ending the turn. -No turn ends blind while work is in flight, including turns described as holding or waiting. - -At the start of every wake-handling turn, drain the durable wake queue before peeking, reading beyond the reason line, steering, or starting work. -Session start is the only exception because its one-shot digest already drained while locked or deliberately left the queue untouched in lock-refused read-only mode. -A status line is a wake event, not current state; use `bin/fm-crew-state.sh` when current state matters, especially before re-escalating an old decision, blocker, or pause. -A declared `paused:` event means a bounded external wait expected to clear on its own, while `blocked:` means firstmate action is needed. - -Handle actionable wakes as follows: - -1. For `signal:`, read the listed event lines first, then reconcile current state only where action depends on it. -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 and X-mode events. -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 any wake reports a merged PR for a project cloned in this home, refresh that clone through the guarded fleet-sync path. -When X-linked work reaches a milestone or terminal state, load `fmx-respond`; before terminal teardown, always post the final completion follow-up so the link clears even if earlier follow-ups were spent. - -A secondmate's idle endpoint is healthy, and parent supervision relies on its routed status rather than treating a quiet pane as stale. -Waiting on a healthy supervision cycle is silent; empty polls, elapsed time, and no-change updates are not captain-facing progress. -Never broadly kill watchers, especially never `pkill -f bin/fm-watch.sh`, because that can kill sibling firstmate homes. -A forced repair must use the home-scoped owner path emitted by supervision instructions. - -Guard warnings do not replace the contract. -Queued wakes must be drained before other action, stale liveness must be repaired through the emitted protocol, and the worktree-tangle warning must be resolved without touching unlanded work. -The spawn assertion and generated ship brief must both enforce that project work starts in an isolated disposable worktree, never the primary checkout. -Harness-aware turn-end guards are structural backstops, not permission to omit the live cycle. +The watcher is the backbone. +Whenever at least one task is in flight, keep exactly one live supervision wait owned by the emitted primary-harness protocol from `bin/fm-session-start.sh`. +The emitted block is the only per-harness operating recipe in the session context. +Do not substitute another harness's command shape for it. +**Always-on wake triage (absorb only when provably working).** +`bin/fm-watch.sh` classifies every wake in bash and absorbs the benign majority without waking you: crews with positive working evidence (an actively-running no-mistakes step for their branch, or a busy pane, read via `bin/fm-crew-state.sh`), a declared `paused:` external wait until its bounded recheck cadence, and no-change heartbeats. +It never absorbs a crewmate that stopped without that evidence - whatever its stale status log claims - and only an actionable wake is queued durably and ends the supervision wait, so you resume the emitted protocol exactly once per actionable event. +A `paused:` status is a deliberate external wait, not `blocked:`; its initial signal still surfaces once, and a forgotten pause re-surfaces for a recheck once per window. +Repeated provably-working stale escalations on one unchanged pane eventually add `demand-deep-inspection` to the wake reason so it is not mistaken for another routine validation wait. +`docs/architecture.md` ("Event-driven supervision") owns the full classification mechanism, its thresholds, and the shared classifier library; while `state/.afk` exists the daemon owns triage and the watcher surfaces every wake to it. +At the start of every wake-handling turn, run `bin/fm-wake-drain.sh` before peeking panes, reading status files beyond the reason line, or starting new work. +Session-start recovery is the exception: `bin/fm-session-start.sh` already drained the queue when locked, or deliberately skipped the drain when read-only because another session owns it. +The printed reason line is still useful, but the drained queue is the lossless backlog. +**Keep exactly one live cycle.** +The live cycle is the supervision: while any task is in flight, the active harness protocol must maintain one wait that can wake this primary when `bin/fm-watch.sh` reports an actionable reason. +After handling drained wakes, resume the emitted harness protocol before ending the turn. +Never use shell `&` as a substitute for a verified harness wake mechanism. +If the active protocol's arm wrapper reports or attaches to an existing healthy watcher, do not start another cycle; attached arms stay live until that cycle ends. +If it reports failure, drain queued wakes first and then repair supervision according to the emitted block. +**No turn ends blind, holds included.** +Never end a turn while any task is in flight without the active harness supervision protocol live: a text-only "holding" or "waiting" reply with crewmates live and no live cycle is a bug, and because such a turn runs no supervision script it is exactly the blind gap the script-only guard (`fm-guard.sh`, below) cannot catch, so this discipline must. +If a forced restart is ever genuinely needed, use `bin/fm-watch-arm.sh --restart`, which signals only this home's recorded watcher and then owns a fresh cycle or reports restart-only `healthy` without attaching if a healthy peer still holds the lock. +Never `pkill -f bin/fm-watch.sh`: that pattern matches every firstmate home's watcher, including secondmate homes that run the same script, so a broad pkill from one home kills sibling homes' watchers. +Away-mode supervision is provided by the `/afk` skill and its daemon; while `state/.afk` exists, the daemon owns the watcher. +Waiting on the watcher is intentionally silent. +After starting the active harness supervision wait, do not send idle progress updates to the captain; wait until it returns `signal`, `stale`, `check`, or `heartbeat`, unless the captain asks for status. +Empty polls, elapsed waiting time, and "still no change" are tool bookkeeping, not conversational progress. + +```sh +bin/fm-supervision-instructions.sh # render the current harness block or one-line repair text +bin/fm-watch-arm.sh # verified arm wrapper used by harness protocols that call it +bin/fm-watch-arm.sh --restart # home-scoped forced restart; never a broad pkill +bin/fm-watch-checkpoint.sh # bounded foreground watcher checkpoint for Codex-style protocols +bin/fm-watch.sh # the watcher itself; exits with: signal|stale|check|heartbeat +bin/fm-wake-drain.sh # drain queued wake records at turn start; asserts guard after draining +bin/fm-crew-state.sh # one-line current-state read; reconciles matching run-step, pane, and status log +bin/fm-fleet-view.sh # read-only Markdown whole-fleet view rendered from the structured snapshot +``` + +On wake, in order of cheapness: + +1. Read the reason line and drain queued wake records with `bin/fm-wake-drain.sh`. +2. `signal:` read the listed status files first; a wake lists every signal that landed within the coalescing grace window (e.g. a status write plus the same turn's turn-end marker), and each is ~30 tokens and usually sufficient. + A status line is the wake *event*, not the crewmate's current state; when you need the live state - especially to confirm a `needs-decision`/`blocked`/`paused` status is still real and not already resolved-and-resumed - read it with `bin/fm-crew-state.sh `, which reconciles the authoritative run-step over the possibly-stale log line, and never `tail` the status log as the current-state source. +3. `stale:` the crewmate stopped without reporting; peek the pane (`bin/fm-peek.sh `) to diagnose. + If the stale reason includes `demand-deep-inspection`, inspect the pane, `bin/fm-crew-state.sh `, and the validation logs before resuming supervision. + If the pane is waiting, looping, confused, or unresponsive, load `stuck-crewmate-recovery`. +4. `check:` a per-task poll fired (usually a merge, or X mode when enabled); act on it. +5. `heartbeat:` a heartbeat wake now reaches you only when the watcher's bash fleet-scan caught a captain-relevant status the per-wake path missed (no-change heartbeats are absorbed in bash, never surfaced), so treat it as "something turned up" and review the whole fleet: start with `bin/fm-fleet-view.sh` for the structured overview, use `bin/fm-crew-state.sh ` only for targeted follow-up, peek panes that look off, check PR-ready tasks for merge, reconcile data/backlog.md, then resume the emitted supervision protocol. + Do not report that the fleet is unchanged. + +When a task reaches a terminal state on any of these wakes (a `done`/merge `check:`, a `failed` signal, a scout report, a local-only merge), and X mode is enabled, load `fmx-respond` (section 13) and post the X-mode mention's **final** completion follow-up if that task is X-mode-linked: `bin/fm-x-followup.sh --check ` then `bin/fm-x-followup.sh --final --text-file `, so the link always clears here regardless of how many of the up-to-three follow-ups were already spent on earlier milestones. +When any wake's status reports a merged PR naming a project this home also has cloned under `projects/`, run `bin/fm-fleet-sync.sh ` for that project as part of handling the wake, so the primary's clone never sits stale until the next session start or teardown. + +Never rely on hooks or status files alone; when a heartbeat wake does reach you, the review of every window is mandatory and unconditional. +Each task's backend live-task inventory is the ground truth: tmux when `backend=` is absent, or the non-default `backend=` a task's meta records (`docs/configuration.md` "Runtime backend" owns the backend set). +For `kind=secondmate`, an idle pane is healthy. +A secondmate may be sitting on its own watcher with no visible pane changes, so parent supervision uses status writes plus heartbeat review, not pane-staleness. +`fm-watch.sh` therefore skips stale-pane wakes for windows whose meta records `kind=secondmate`. +This exception is narrow: ordinary crewmates still trip stale detection when their pane stops changing without a busy signature. + +**Watcher liveness is guarded, not just disciplined.** +Resuming the emitted supervision protocol is the last action of every wake-handling turn - but the protocol no longer relies on remembering that. +The supervision scripts and `bin/fm-wake-drain.sh` call `bin/fm-guard.sh`, which prints a prominent bordered banner when tasks are in flight but queued wakes are pending or the watcher's liveness beacon is missing or stale; `docs/architecture.md` ("Event-driven supervision") owns the beacon and grace mechanics. +The banner is only a supervision warning: the guarded operation still runs, and `fm-send`'s banner says explicitly that the requested message WILL still be sent. +If a guard warning says queued wakes are pending, drain them before doing anything else. +If a guard warning says watcher liveness is stale, drain any queued wakes and then resume the emitted supervision protocol. + +`fm-guard.sh` carries a second, independent alarm in the same bordered style: the **worktree-tangle** guard. +If a crewmate sent to work firstmate-on-itself branches or commits in the primary checkout instead of its own isolated worktree, the primary is stranded on a feature branch (the failure this guards against); the guard names the offending branch and prints the non-destructive restore (`git -C checkout `), so the tangle surfaces on the very next fleet action. +Only a named non-default branch checked out in the primary alarms: detached HEAD (the legitimate resting state of crewmate worktrees and secondmate homes) and the default branch never do. +The same assertion runs at session start as the bootstrap `TANGLE:` line (handled via `bootstrap-diagnostics`), and two upstream guards prevent the tangle: `fm-spawn`'s isolated-worktree assertion and the ship brief's opening isolation check (section 11). + +On every verified primary harness, "no turn ends blind" has a structural backstop beyond the pull-based banner: `bin/fm-turnend-guard.sh` blocks the turn end (or forces one bounded follow-up on passive harnesses) when tasks are in flight without a live identity-matched watcher lock and fresh beacon, guards both the main primary and a secondmate's own primary session, and stays silent when supervision is healthy. +`docs/turnend-guard.md` owns the per-harness hook mechanisms, empirical validation, scoping details, and documented fail-open tradeoffs. +Watcher liveness is harness-aware. +Do not assume one primary harness can use another harness's foreground or background shape. +For example, Claude uses a background-notify cycle, while Codex intentionally uses bounded foreground checkpoints. + +Token discipline: for a crewmate's current state prefer `bin/fm-crew-state.sh `, which looks for a branch-matched run-step before checking pane liveness, then falls back to the pane and log in that cheap-first order and treats the status log's last line as a wake event rather than the current state; default peeks to 40 lines; never stream a pane repeatedly through yourself; batch what you tell the captain. +The context-% shown in a peek is not actionable as crew health; ignore it and intervene only on real signals (`signal`, `stale`, `needs-decision`, `blocked`), looping or confusion in the pane, or a question the brief already answers. ### Away-mode stub Invoke the `/afk` skill when the captain says `/afk`, says they are going afk, `state/.afk` exists, an incoming message starts with `FM_INJECT_MARK`, or any `state/.subsuper-*` marker is involved. -The skill owns the daemon procedure; these safety facts remain inline: +The skill owns the full daemon procedure: classification policy, batching, injection hardening, max-defer, verified submit, marker stripping, portable lock, dedupe, target discovery, reliability properties, and `FM_INJECT_SKIP`. +Inline facts that must survive without a loaded skill: -- Every daemon injection starts with `FM_INJECT_MARK` plus U+2063 INVISIBLE SEPARATOR, which distinguishes internal escalation from captain input. -- While `state/.afk` exists, the daemon owns supervision; do not arm a separate watcher. -- A marked message while away mode is active is internal escalation and does not exit away mode. -- A message beginning `/afk` refreshes away mode. -- Any other unmarked message means the captain returned; load `/afk`, run the return owner, and do not process that message as ordinary work until its durable catch-up gate clears. -- Away mode never expands approval authority for merges, ask-user findings, destructive actions, irreversible actions, or security-sensitive choices. -- Bias ambiguous input toward exit because a present captain takes precedence. +- Every daemon injection is prefixed with `FM_INJECT_MARK`, U+2063 INVISIBLE SEPARATOR, so internal escalations survive terminal transport and remain distinguishable from a captain message. +- While `state/.afk` exists, the daemon owns the watcher; do not separately arm `fm-watch-arm.sh` or `fm-watch.sh`. +- If firstmate receives a marked message while afk is active, it is an internal escalation: stay afk and process it. +- If the message starts with `/afk`, stay afk and refresh the flag. +- Any other unmarked message means the captain is back: load `/afk`, run `bin/fm-afk-return.sh`, and do not process that message as ordinary captain work until its durable catch-up gate clears; the script owns stop ordering, wake draining, and firstmate-actionable blocker precedence. +- Afk never changes approval authority; PR merges, ask-user findings, destructive actions, irreversible actions, and security-sensitive choices still require the same approval they required before. +- Bias ambiguous cases toward exit because a present captain beats token savings and a false exit is self-correcting. -### Stuck-worker trigger +### Stuck-crewmate recovery -Load `stuck-crewmate-recovery` after a stale wake, looping or confused pane, answered-by-brief question, unresponsive worker, or failed steer. +On `stale`, looping, repeated confusion, an answered-by-brief question, an unresponsive pane, or a failed steer, load `stuck-crewmate-recovery`. +That playbook escalates from peek, to one-line steer, to harness-specific interrupt, to relaunch with a progress note, to `failed` with evidence. ## 9. Escalation and captain etiquette **Talk in outcomes, not mechanics.** -Describe what is being investigated, built, ready, blocked, failed, or awaiting a decision in plain language at the captain's altitude. -Do not expose internal terms such as startup machinery, locks, watchers, polling, crewmates, task ids, briefs, worktrees, status or metadata files, teardown, promotion, harness names, context budgets, delivery-mode names, or autonomy flags. -Translate those details into the project's outcome and consequence. +Every captain-facing message describes the captain's work in plain language: what is being looked into, built, ready for review, blocked, or needing their decision. +Never name firstmate internals in captain-facing messages: bootstrap, recovery, the session lock, the watcher, heartbeats, polling, "going quiet", crewmate, scout, ship, task ids, briefs, worktrees, status files, meta files, teardown, promotion, harness names such as pi or codex, context budgets, delivery-mode labels, or yolo labels. +Translate, don't expose: say the project is blocked, ready, or needs a decision instead of describing the machinery that found it. -Every escalation must stand alone and remain concise. -Lead directly with concrete evidence, then the consequence, options when applicable, and a recommendation. -Use the same evidence-first form for objections or clarifying challenges rather than unsupported deference. +Reaches the captain immediately: -Reach the captain immediately for: - -- Work ready for their review, with the full PR URL. -- Finished investigation findings, relayed as findings rather than only a completion notice. -- Gate findings that require their decision under the configured authority. -- A real blocker or failure after the relevant playbook is exhausted. +- Work ready for review, with the full PR URL. +- Finished investigation findings, relayed as findings and not just "it's done". +- Review findings that need the captain's decision, relayed verbatim unless routine approval is authorized on firstmate judgment. +- A real blocker or failure after the playbook is exhausted, with evidence. - Anything destructive, irreversible, or security-sensitive. - A needed credential or login. -Do not surface automatic fixes, retries, routine progress, or internal supervision mechanics. -Batch non-urgent updates into the next natural reply. -Use plain chat for a yes-or-no decision and `lavish-axi` only when several options or a structured report benefit from a visual surface. -Whenever a PR is mentioned, include its full `https://...` URL before any shorthand reference. -Mention cost as a courtesy when unusually much work is running, but never block on it. +Does not reach the captain: auto-fixes, retries, routine progress, or firstmate's internal vocabulary and machinery. +Batch non-urgent updates into your next natural reply. +Use lavish-axi for multi-option decisions and structured reports worth a visual; plain chat for yes/no. +Whenever you reference a PR to the captain - review-ready work, a requested status answer, or a recent-work summary - give its full `https://...` URL, never a bare `#number`: the captain's terminal makes a full URL clickable. +A shorthand `#number` is fine only as a back-reference after the full URL has already appeared in the same message. +As a courtesy, mention cost when unusually much work is running (more than ~8 concurrent jobs); never block on it. -## 10. Backlog contract +## 10. Backlog format `data/backlog.md` is the durable queue. It tracks work items only, never agents; persistent secondmates never appear as backlog items. @@ -316,42 +677,89 @@ Work routed to a secondmate is recorded in that secondmate home's own backlog, n When a main-side thread such as a pending captain decision or relay reminder is worth durable tracking, file it as its own work item; use `tasks-axi hold --reason "" --kind captain` for a captain-gated thread. Unresolved decisions discovered by investigations or visual reviews follow `decision-hold-lifecycle`, which owns their mandatory backlog lifecycle. Update the backlog on every dispatch, completion, and decision for a work item. -Re-evaluate queued work after every teardown and heartbeat, dispatching items only when dependencies and time gates have cleared. - -`.tasks.toml`, `docs/configuration.md`, and current `tasks-axi --help` own the backlog schema, compatibility, retention, and routine command syntax. -Use compatible `tasks-axi` when the configured backend selects it and the documented manual path otherwise; keep only the configured recent Done entries. -`secondmate-provisioning` and `bin/fm-backlog-handoff.sh` own cross-home handoff safety. -Keep free-form notes free of temporary paths, moving versions, ephemeral identifiers, and copied state that will rot. -Inspect the current task note before replacing its considered body, and archive the superseded body when recoverability matters rather than appending by default. -Verify volatile details against their authoritative config, live system, or API before acting, and correct or delete stale prose immediately. -Preserve durable structured identifiers, dependencies, and completion artifact links, and route reusable knowledge to section 6 rather than scattering it through task notes. +```markdown +## In flight +- [ ] - (repo: , since ) + +## Queued +- [ ] - (repo: ) blocked-by: - + +## Done +- [x] - - (merged ) +- [x] - - local main (merged ) +- [x] - - data//report.md (reported ) +``` + +Re-evaluate Queued on every teardown and every heartbeat: anything whose blocker is gone and whose time/date gate, if any, has arrived gets dispatched. + +A tracked `.tasks.toml` at this repo root pins the default `tasks-axi` markdown backend to `data/backlog.md`, with `done_keep = 10` and an archive at `data/done-archive.md`. +The local, gitignored `config/backlog-backend` file is the explicit opt-out knob. +Absent or `tasks-axi` means use the default tasks-axi backend; `manual` means force routine backlog updates to hand-editing even when `tasks-axi` is installed. +Compatible means the shared bootstrap probe accepts `tasks-axi --version` as 0.1.1 or newer, `tasks-axi update --help` exposes `--archive-body`, and `tasks-axi mv --help` exposes `[...]` for atomic multi-ID moves. +When the default backend is selected and compatible `tasks-axi` is on PATH, firstmate mutates the backlog through its verbs instead of hand-editing, with secondmate handoffs still going through the validated helper described in section 6. +When the default backend is selected but `tasks-axi` is missing or incompatible, bootstrap reports it through the normal `MISSING:` consent flow in `docs/configuration.md` "Toolchain", and every firstmate home falls back to hand-editing routine `data/backlog.md` updates exactly as this section describes until it is installed. +When `config/backlog-backend=manual`, every firstmate home hand-edits routine backlog updates; bootstrap still requires compatible `tasks-axi` on `PATH` but does not print `TASKS_AXI: available`. +The `## In flight` / `## Queued` / `## Done` format above stays the contract: the verbs edit `data/backlog.md` in place, byte-exact, preserving whatever item forms the file already uses - the bold in-flight `- ****` form, the `- [ ]`/`- [x]` queued and done forms, and `blocked-by: - ` - rather than reformatting them. +Secondmates inherit `config/backlog-backend` from the primary. +If the primary leaves the file absent, each home uses the default tasks-axi backend path with its own `.tasks.toml`; if the primary opts out with `manual`, secondmate homes hand-edit routine backlog updates too. +Keep Done to the 10 most recent entries. +With the active compatible tasks-axi backend, `tasks-axi done` auto-prunes Done and archives pruned entries to `data/done-archive.md`, so do not hand-prune. +When hand-editing, prune older Done entries manually whenever you add to the section. +Pruning loses nothing: finished PR-based ship tasks live on as GitHub PRs, local-only ship tasks live on in local `main`, and scout tasks live on as report files. +Map firstmate's real backlog operations to the approved commands: + +- File an item: `tasks-axi add "" --kind --repo `, plus `--start` for immediate dispatch (In flight) or the default queue placement, and `--blocked-by ` (repeatable) when it waits on another task. +- Start an existing queued item: `tasks-axi start ` before dispatching work from Queued, after checking that blockers are gone and any time/date gate has arrived. +- Move a finished task to Done: `tasks-axi done --pr ` for a PR-based ship, `--report ` for a scout, or `--note "local main"` for a local-only merge. +- Update task notes: inspect first with `tasks-axi show --full`, then replace the considered body with `tasks-axi update --body-file `. + Add `--archive-body` to that update command when superseding prior state should remain recoverable. +- Manage dependencies: `tasks-axi block --by ` and `tasks-axi unblock --by `, then `tasks-axi ready` to list queued work with no unresolved blockers. + This is a dependency check only; future-dated items still stay queued until their date arrives. +- Read an item's full notes: `tasks-axi show --full`. +- Hand a task off to a secondmate home: load `secondmate-provisioning`, then keep using `bin/fm-backlog-handoff.sh ...`; do not call bare `tasks-axi mv` for this path, because the helper resolves and validates the secondmate home before moving anything. +- Normalize the file: `tasks-axi render` rewrites every id'd task in canonical form and leaves free-form lines untouched. + +**Note hygiene:** Keep free-form backlog and task note/status prose free of volatile incidental specifics that rot: temp paths, in-flight versions, moving state locations, and ephemeral IDs. +Reference the authoritative source instead of duplicating it into prose - "state per the module's backend config", not a literal path. +Before acting on a note's volatile detail, verify it against the source of truth (the config, the live system, the API); notes drift. +The backlog format's structured fields are different: task IDs, blocked-by IDs, and Done-entry PR URLs or report paths from `tasks-axi done --pr ` or `--report ` are the durable record required by this schema. +Correct or delete stale free-form notes the moment you catch them, and put durable facts in curated memory (section 6's knowledge-routing homes), not scattered across one-off task notes. ## 11. Crewmate briefs -`bin/fm-brief.sh` and its help own scaffold syntax, generated variants, status protocol, delivery-mode definitions of done, and exact safety mechanics. -Use its scaffold as the contract, then replace every `{TASK}` placeholder with a clear task description, acceptance criteria, constraints, and necessary context before dispatch or seeding. -Keep additions task-specific rather than repeating lifecycle instructions, and alter generated sections only when the task genuinely differs from the standard shape. - -Every ship brief must retain the worktree-isolation assertion and stop if launched in the primary checkout. -If a ship task touches firstmate's shared tracked material, explicitly require `firstmate-coding-guidelines` before editing. -If a task will drive Herdr lifecycle behavior, scaffold with `--herdr-lab`; if that need appears after an unguarded scaffold, stop and regenerate rather than adding commands by hand. -The generated Herdr contract must use a named non-`default` isolated lab and its guarded helper for every lifecycle action. - -Load `secondmate-provisioning` before creating or using a charter brief and preserve its idle-by-default and marked-return-channel contracts. -Status appends are sparse supervisor-actionable events, not routine progress; `bin/fm-classify-lib.sh` owns keyed open and resolved semantics. -The scaffold is a safety contract, not a suggestion. +Scaffold with `bin/fm-brief.sh ` - it writes `data//brief.md` with the standard contract (branch setup, status-reporting protocol, push/merge rules, definition of done) and all paths filled in. +The ship-brief Setup opens with a worktree-isolation assertion ahead of the branch step: the crewmate confirms it is in its own disposable task worktree, not the primary checkout, and stops with `blocked: launched in primary checkout, not an isolated worktree` if not - the upstream half of the worktree-tangle guard (section 8). +For a ship task the definition of done is shaped by the project's delivery mode (section 6): `no-mistakes` stops after the implementation commit, then firstmate triggers the harness-appropriate no-mistakes validation pipeline; `direct-PR` has the crewmate push and open the PR itself, and `local-only` has it stop at "ready in branch" for firstmate to review and merge locally. +The no-mistakes brief points to no-mistakes' version-matched guidance and keeps only firstmate-specific wrapper rules for `ask-user` escalation, `--yes` avoidance, and the CI-green done line. +The scaffold reads the mode via `fm-project-mode.sh`, so you do not pass it. +Ship briefs also include the project-memory contract: run `bin/fm-ensure-agents-md.sh` when the project already has agent-memory files or when the task produced durable project-intrinsic knowledge, then record proportionate learnings in `AGENTS.md`. +For scout tasks add `--scout`: the scaffold swaps the definition of done for the report contract (findings to `data//report.md`, no branch, no push, no PR) and declares the worktree scratch; scout is mode-agnostic. +Scout briefs do not include the project-memory step, because their deliverable is a report rather than a committed project change. +For a crewmate task that will drive Herdr lifecycle behavior, add `--herdr-lab`: the scaffold embeds the hard Herdr-isolation contract backed by `bin/fm-herdr-lab.sh` (a never-`default` lab session, a trailing `--session` on every Herdr call, guarded teardown, and a before/after fleet-state tripwire), and the flag is rejected for `--secondmate` briefs. +The flag must be explicit because the scaffold cannot read the `{TASK}` text it fills in later, so every ship or scout brief scaffolded without it carries a loud not-enabled gate telling the crewmate to stop and regenerate with `--herdr-lab` if the task turns out to touch Herdr lifecycle. +For secondmates use `bin/fm-brief.sh --secondmate {...|--no-projects}`. +The scaffold writes a charter brief instead of a task brief. +Set `FM_SECONDMATE_CHARTER=''` to fill the charter text and `FM_SECONDMATE_SCOPE=''` when the routing scope differs. +If you scaffold without `FM_SECONDMATE_CHARTER`, replace the `{TASK}` placeholder before seeding. +Keep the charter focused on persistent responsibility, available project clones, escalation back to the main firstmate status file, and the idle-by-default contract: reconcile only its own in-flight work and then wait, never self-initiating a survey or audit. +Preserve the requests-from-main-firstmate contract in the charter: marked requests return via status or a doc pointer, while unmarked direct captain messages stay conversational. +Before seeding, launching, recovering, or handing backlog to a secondmate home, load `secondmate-provisioning`. +The status-reporting protocol is intentionally sparse: crewmates append status only for supervisor-actionable phase changes, `needs-decision`/`blocked`/`paused`/`done`/`failed`, or the `resolved` line that closes a previously reported decision, blocker, or material routed-work phase, because every append wakes firstmate. +`bin/fm-classify-lib.sh` owns the keyed open/resolved status contract, and the generated secondmate charter owns its exact reporting instructions. +For any generated brief that still contains `{TASK}`, replace it with a clear task description, acceptance criteria, and any constraints or context the crewmate needs before spawning or seeding. +Adjust the other sections only when the task genuinely deviates from the standard ship-a-new-PR shape (e.g. fixing an existing external PR); the scaffold is the contract, not a suggestion. ## 12. Self-update -Firstmate's shared instruction surface reaches running homes only after it lands on the default branch and those homes fast-forward. -Only `AGENTS.md`, `bin/`, and `.agents/skills/` are loaded by a running firstmate; public `skills/` is an installer-facing surface. +firstmate is its own repo behind the no-mistakes gate, so improvements to `AGENTS.md`, `bin/`, `.agents/skills/`, and public `skills/` reach `main` and then wait for each running firstmate to pull them. +Only `AGENTS.md`, `bin/`, and `.agents/skills/` are a running firstmate instruction surface; public `skills/` is tracked for installers and is not loaded by firstmate. When the captain invokes `/updatefirstmate` or asks to update firstmate, load the `/updatefirstmate` skill. -It performs guarded fast-forward updates of firstmate and registered secondmate homes, refreshes instructions, and never touches anything under `projects/`. +It performs only fast-forward self-updates of firstmate and registered secondmate homes, re-reads `AGENTS.md` when needed, nudges updated live secondmates, and never touches anything under `projects/`. ## 13. Agent-only reference skills -These skills are not captain-invocable; load them only at their precise triggers. +These skills are not captain-invocable; they are conditional operating references you must load at the trigger points below. - `bootstrap-diagnostics` - load whenever the session-start digest's bootstrap section prints any diagnostic or capability line (`MISSING:`, `MISSING_MANUAL:`, `BACKEND_INVALID:`, `NEEDS_GH_AUTH`, `TANGLE:`, `CREW_HARNESS_OVERRIDE:`, `CREW_DISPATCH:`, `FLEET_SYNC:`, `SECONDMATE_SYNC:`, `SECONDMATE_LIVENESS:`, `TASKS_AXI:`, `NUDGE_SECONDMATES:`, or `FMX:`); silence needs no load. - `diagnostic-reasoning` - load before scoping a reported bug and before acting on a diagnostic report. @@ -367,17 +775,28 @@ These skills are not captain-invocable; load them only at their precise triggers ## 14. X mode -X mode ships inert and causes no behavior change until the home opts in by placing `FMX_PAIRING_TOKEN` in its gitignored `.env`. -That token is consent for public replies and normal reversible lifecycle actions from eligible mentions, not authority for destructive, irreversible, or security-sensitive action; those still require trusted-channel confirmation. -`docs/configuration.md` owns activation, generated state, cadence, wire protocol, and opt-out mechanics. +X mode lets a firstmate instance answer public mentions routed through the shared `@myfirstmate` relay, and act on actionable mention requests, in firstmate's own voice, from its live fleet state. +It ships inside this repo for every user but is **inert until opted in**, so a user who never enables it sees zero behavior change. + +**Activation is `.env` presence, not a command.** +Put one value, `FMX_PAIRING_TOKEN`, into a `.env` file at this home's root (`.env` is gitignored). +That token is the whole consent, including standing authorization for normal reversible lifecycle actions from mention requests, and the only required config; the relay derives the tenant from it. +It is not consent for destructive, irreversible, or security-sensitive actions; those still require trusted-channel confirmation first. +`FMX_RELAY_URL` is optional and defaults to `https://myfirstmate.io`; only a developer pointing at a local relay sets it. + +**Mechanism and cadence.** +Bootstrap wires the relay poll automatically and purely additively from `.env` presence; `docs/configuration.md` "X mode (.env)" owns the generated-artifact mechanism, the wire protocol, the poll cadence and its transition handling, and the watcher-backbone non-interference guarantee. +X mode is a reason to keep the watcher armed even with no fleet work, so an X-only user is still served. -An X-only home still requires the live supervision cycle so mentions can wake it without fleet work. -On an `x-mention ` or `x-mode-error ...` check wake, load `fmx-respond`, which owns classification, public-safety policy, reply or dismissal, task linking, and follow-ups. -For every X-linked terminal outcome, load that owner and post the final completion follow-up before teardown, regardless of earlier milestone follow-ups. +**Answering.** +On an `x-mention ` or `x-mode-error ...` `check:` wake, load `fmx-respond` (section 13). +It owns mention classification, acting on the request, reply composition, voice, thread-splitting, image attachments, dry-run preview, and the completion-follow-up procedure in full, including what an `x-mode-error` wake means instead. +`docs/configuration.md` "X mode (.env)" has the wire-protocol reference. +The one fact that must survive here because it fires on a generic terminal wake, not the mention wake itself: when an X-mode-linked task reaches a terminal state, post its final completion follow-up per section 8's wake-handling step before tearing down. ## Maintaining this file Keep this file for knowledge useful to almost every future agent session in this project. -Do not repeat what the codebase already shows; point to the authoritative file, skill, command, or doc. +Do not repeat what the codebase already shows; point to the authoritative file or command instead. Prefer rewriting or pruning existing entries over appending new ones. -When updating this file, preserve every safety boundary and keep the always-loaded contract concise. +When updating this file, preserve this bar for all agents and keep entries concise. diff --git a/docs/architecture.md b/docs/architecture.md index f2c590f9c..4e71a6a41 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -95,6 +95,7 @@ Codex App support is recorded in `docs/codex-app-backend.md`; it is not selectab Crewmates never intentionally touch your project clone; [treehouse](https://github.com/kunchenguid/treehouse) pools clean worktrees for tmux, herdr, zellij, and cmux tasks, while Orca creates its own worktrees for `backend=orca`. For ship and scout work, `fm-spawn.sh` refuses to launch unless the resolved task path is a real git worktree root that is distinct from the project primary checkout. +Pooled worktrees of the same project share a leaf directory basename, which collides on Docker Compose's default project-naming; see `docs/configuration.md` "Docker Compose project isolation" for the per-worktree `compose_project=` name `fm-spawn.sh` derives to avoid it. The firstmate repo has one extra exposure because it can dispatch crewmates to work on itself. Its operating checkout (`FM_ROOT`) and the disposable crewmate worktrees are all linked git worktrees of the same repository, so the valid discriminator is branch state, not whether the checkout is linked. diff --git a/docs/configuration.md b/docs/configuration.md index d289c4c15..0d45d09d6 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -80,6 +80,15 @@ The caller-facing label remains `fm-`, but the actual cmux workspace title i Test cleanup must use the guarded path described in [`docs/cmux-backend.md`](cmux-backend.md)'s "Test safety" section, never enumerate-and-close every workspace. The `config/backend` file is not inherited by secondmate homes. +## Docker Compose project isolation (per-worktree) + +Two treehouse worktrees of the same project always share the same leaf directory basename (e.g. `wealthsync`), so Docker Compose's default project-naming, derived from the current directory's basename, collides across them on container, volume, and network names. +For every ship/scout spawn, regardless of runtime backend, `fm-spawn.sh` derives a stable, Compose-safe project name from the worktree's own pool-slot path once it is resolved (a treehouse pool worktree is `.../-//`; the name is `-`, lowercased, with non-`[a-z0-9-]` characters replaced by `-` and repeats collapsed), falling back to the worktree's own leaf name or the task id when the path lacks that pool-slot shape (e.g. a non-treehouse backend). +The name is written to `.treehouse-compose-project` in the worktree root, excluded from git via that worktree's own `.git/info/exclude` so it never blocks teardown's dirty-worktree check or leaks into a crewmate's commit, and recorded in `state/.meta` as `compose_project=`. +It is stable across respawns into the same pool slot, not per task id, so a stale Compose stack left by a prior task in that slot is naturally superseded rather than accumulating orphaned volumes under a new name every respawn. +Secondmates never get this marker or meta field; they run in their own firstmate home, not a per-task pool worktree. +`fm-brief.sh` tells the crewmate, in both ship and scout briefs, to read `.treehouse-compose-project` and pass it explicitly on every `docker compose` invocation (e.g. `docker compose -p "$(cat .treehouse-compose-project)" up -d`), since exported env vars do not reliably persist across separate tool-call invocations; it also flags fixed host ports as a related collision vector and recommends `docker compose port ` or another deterministic per-worktree-derived port instead of a hard-coded one. + ## Away-mode supervisor backend (FM_SUPERVISOR_BACKEND / FM_SUPERVISOR_TARGET) The `/afk` sub-supervisor injects escalation digests into firstmate's own pane independently of where new task endpoints are spawned. diff --git a/docs/orca-backend.md b/docs/orca-backend.md index 6be2ad58f..b10e948e3 100644 --- a/docs/orca-backend.md +++ b/docs/orca-backend.md @@ -71,7 +71,7 @@ Spawn: 1. Ensure the project repo is registered in Orca, adding it with `orca repo add --path` when needed. 2. Create an independent Orca worktree with `orca worktree create --repo id: --name fm- --no-parent --setup skip`. 3. Reuse the terminal returned by Orca worktree creation only when it appears in the verified `result.terminal.handle` shape, or create a titled terminal in that worktree when Orca returns only the worktree. -4. Install firstmate's per-harness turn-end hooks in the Orca worktree. +4. Write the docker-compose isolation marker (`.treehouse-compose-project`) and install firstmate's per-harness turn-end hooks in the Orca worktree. 5. Write metadata, then send `GOTMPDIR` export and the selected harness launch through the recorded Orca terminal. Operation routing: diff --git a/docs/scripts.md b/docs/scripts.md index 8ed71b89f..98c452cad 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -14,6 +14,8 @@ The shared no-mistakes gate refusal used by `fm-spawn.sh`, `fm-send.sh`, and `fm | `fm-fleet-view.sh` | Render the fleet snapshot as a human Markdown view | | `fm-bearings-snapshot.sh` | Project the fleet snapshot to the compact TOON bearings view; local-only unless `--include-prs` | | `fm-update.sh` | Fast-forward-only self-update of firstmate and secondmate homes from origin | +| `fm-lint.sh` | Single owner of firstmate's shell-lint definition (file set, config, pinned ShellCheck version) that CI and the no-mistakes gate both run | +| `fm-install-shellcheck.sh` | Install CI's pinned, verified ShellCheck build into a destination directory | | `fm-backlog-handoff.sh` | Validate and delegate queued backlog-item moves into a secondmate home | | `fm-decision-hold.sh` | Create, verify, complete, and resolve durable captain-held decisions | | `fm-brief.sh` | Scaffold ship, scout, secondmate-charter, and Herdr-lab briefs | @@ -24,21 +26,27 @@ The shared no-mistakes gate refusal used by `fm-spawn.sh`, `fm-send.sh`, and `fm | `fm-turnend-guard-grok.sh` | Grok Stop-hook adapter for the primary turn-end guard | | `fm-arm-pretool-check.sh` | Stable PreToolUse transport for the watcher-arm command policy (docs/arm-pretool-check.md) | | `fm-arm-command-policy.mjs` | Semantic owner of the watcher-arm PreToolUse policy (docs/arm-pretool-check.md) | +| `fm-cd-pretool-check.sh` | Stable PreToolUse transport for the cd-guard command policy (docs/cd-guard.md) | +| `fm-cd-command-policy.mjs` | Semantic owner of the cd-guard policy: does a command persistently relocate the primary shell? (docs/cd-guard.md) | | `fm-supervision-instructions.sh` | Render the session-start primary-harness supervision block or the one-line repair instruction | | `fm-home-seed.sh` | Transactionally provision a secondmate home and maintain `data/secondmates.md` | | `fm-spawn.sh` | Spawn crewmates, scouts, `id=repo` batches, and secondmates on the resolved harness and runtime backend | | `fm-dispatch-select.sh` | Resolve a matched crew-dispatch rule to one concrete profile, owning `quota-balanced` selection | +| `fm-tier-guard.sh` | Mechanically check whether a task's diff size or elapsed time outgrew its assigned model/effort tier (guardrail #1); read-only | +| `fm-risk-tripwire.sh` | Mechanically scan a task's brief and changed paths for migration/auth/schema/security signals (guardrail #2) | +| `fm-ultracode-guard.sh` | Confirm a genuinely independent second pass ran on an ultracode-flagged task's diff before PR-ready, via a marker file (guardrail #3) | | `fm-backend.sh` | Runtime-backend selection, meta helpers, selector resolution, and operation dispatch | | `fm-backend-hometag-lib.sh` | Shared per-installation home-tag derivation for zellij tab and cmux workspace titles | | `fm-composer-lib.sh` | Single fleet-wide owner of composer-content classification for all backends | | `backends/tmux.sh` | Verified tmux session-provider adapter | | `backends/herdr.sh` | Experimental herdr session-provider adapter | +| `backends/herdr-eventwait.py` | Raw AF_UNIX subscriber for herdr's native `pane.agent_status_changed` stream (herdr push-escalation wire transport) | | `backends/zellij.sh` | Experimental zellij session-provider adapter | | `backends/orca.sh` | Experimental Orca backend adapter owning both worktree and terminal | | `backends/cmux.sh` | Experimental cmux session-provider adapter | | `fm-config-push.sh` | Push declared inheritable local config to live secondmate homes mid-session | | `fm-project-mode.sh` | Resolve a project's delivery mode and `+yolo` flag from `data/projects.md` | -| `fm-merge-local.sh` | Fast-forward a `local-only` project's local default branch after approval | +| `fm-merge-local.sh` | Fast-forward a `local-only` project's local default branch after approval, then best-effort push it to `origin` when one exists | | `fm-review-diff.sh` | Review a crewmate branch or recorded PR head against the authoritative base | | `fm-marker-lib.sh` | Shared from-firstmate request marker, detector, and idempotent transformation | | `fm-gate-refuse-lib.sh` | Shared no-mistakes gate-context refusal for fleet lifecycle entrypoints | @@ -57,9 +65,11 @@ The shared no-mistakes gate refusal used by `fm-spawn.sh`, `fm-send.sh`, and `fm | `fm-lock-lib.sh` | Shared "is this git lock provably abandoned?" proof used by teardown and fleet-sync | | `fm-config-inherit-lib.sh` | Shared primary-to-secondmate inheritable-config propagation | | `fm-tasks-axi-lib.sh` | Shared backlog-backend selector and `tasks-axi` compatibility probe | +| `fm-autodeploy-lib.sh` | Shared `config/autodeploy-logs` failure predicate and bounded log-tail read, sourced by `fm-bootstrap.sh` and `fm-watch.sh` | | `fm-wake-drain.sh` | Atomically drain queued watcher wakes, then assert watcher liveness | | `fm-wake-lib.sh` | Shared durable wake queue, portable locks, and watcher identity/health helpers | | `fm-classify-lib.sh` | Shared captain-relevant and declared-external-wait wake classification vocabulary | +| `fm-transition-lib.sh` | Shared, backend-neutral agent-state transition shape and push-escalation supervision policy | | `fm-send.sh` | Send one verified literal line or supported key through the target's recorded backend | | `fm-tmux-lib.sh` | Shared tmux pane primitives for busy detection, composer capture, and verified submit | | `fm-peek.sh` | Print a bounded tail of a crewmate endpoint | @@ -67,6 +77,7 @@ The shared no-mistakes gate refusal used by `fm-spawn.sh`, `fm-send.sh`, and `fm | `fm-pr-merge.sh` | Record PR metadata, then merge a task's PR from its full GitHub URL | | `fm-promote.sh` | Promote a scout task in place to a protected ship task | | `fm-teardown.sh` | Fail-closed teardown: return landed ship worktrees, require completed scout deliverables, retire secondmate homes | +| `fm-nas-deploy-sync.sh` | Best-effort fast-forward sync and pm2 restart of a project's live NAS checkout after a landed ship-task teardown | | `fm-harness.sh` | Detect the running harness and resolve crew or secondmate harness, model, and effort | | `fm-lock.sh` | Per-home firstmate session lock | | `fm-x-lib.sh` | Shared X-mode config, relay, and reply-threading helpers | From b4c1b5108573519858bfbea64346186dc6aabbcc Mon Sep 17 00:00:00 2001 From: Michael Date: Sat, 4 Jul 2026 22:40:52 -0400 Subject: [PATCH 05/46] no-mistakes(document): docs already fully synced; added missing docker-compose note to fm-brief.sh header comment --- bin/fm-brief.sh | 3 + tests/fm-secondmate-lifecycle-e2e.test.sh | 4 + tests/fm-spawn-compose-isolation.test.sh | 137 ++++++++++++++++++++++ 3 files changed, 144 insertions(+) create mode 100755 tests/fm-spawn-compose-isolation.test.sh diff --git a/bin/fm-brief.sh b/bin/fm-brief.sh index 879dcf203..1e2672030 100755 --- a/bin/fm-brief.sh +++ b/bin/fm-brief.sh @@ -34,6 +34,9 @@ # local-only implement on branch, stop and report "ready in branch" (no push/PR); # captain approves, firstmate merges to local main # Ship briefs begin with a worktree-isolation assertion before the branch step. +# Both ship and scout briefs include a docker-compose isolation note telling the +# crewmate to read .treehouse-compose-project and pass it on every docker +# compose invocation (docs/configuration.md "Docker Compose project isolation"). # Scout tasks ignore mode - their deliverable is a report, not a merge. # Every scaffold's status protocol distinguishes the configured # declared-external-wait verb (FM_CLASSIFY_PAUSED_VERB, default "paused") from diff --git a/tests/fm-secondmate-lifecycle-e2e.test.sh b/tests/fm-secondmate-lifecycle-e2e.test.sh index fd71fdb35..099dd2682 100755 --- a/tests/fm-secondmate-lifecycle-e2e.test.sh +++ b/tests/fm-secondmate-lifecycle-e2e.test.sh @@ -130,6 +130,10 @@ phase_spawn() { assert_no_grep 'notify=' "$LOG" "secondmate codex launch included the parent turn-end notify hook" assert_no_grep 'turn-ended' "$LOG" "secondmate codex launch referenced a parent turn-ended signal" assert_no_grep 'treehouse get' "$LOG" "secondmate spawn ran a project treehouse get" + # A secondmate operates in its own persistent home, not a per-task pool + # worktree, so it never gets the docker-compose isolation marker. + assert_absent "$SUB/.treehouse-compose-project" "secondmate spawn wrongly wrote a compose-project marker into its home" + assert_no_grep 'compose_project=' "$meta" "secondmate spawn meta wrongly recorded compose_project=" pass "spawn: launches in the subhome with persistent charter, records routing meta" } diff --git a/tests/fm-spawn-compose-isolation.test.sh b/tests/fm-spawn-compose-isolation.test.sh new file mode 100755 index 000000000..8f9a663d2 --- /dev/null +++ b/tests/fm-spawn-compose-isolation.test.sh @@ -0,0 +1,137 @@ +#!/usr/bin/env bash +# tests/fm-spawn-compose-isolation.test.sh - behavior tests for the +# docker-compose project-name isolation marker (AGENTS.md "Layout and state", +# bin/fm-spawn.sh's fm_compose_project_name). +# +# Two treehouse worktrees of the SAME project always share the same leaf +# directory basename (e.g. both are checked out at ...///wealthsync), +# so Compose's default project-naming (derived from cwd basename) collides +# across them on container/volume/network names. fm-spawn.sh now derives a +# stable name from the worktree's own pool-slot path and drops it at +# /.treehouse-compose-project, git-excluded so it never pollutes a +# crewmate's dirty-worktree check or commits, and records it in the task's +# meta as compose_project=. +# +# Drives the REAL bin/fm-spawn.sh end to end, exactly like +# tests/fm-tangle-guard.test.sh's isolation-abort case: a fake tmux reports +# FM_FAKE_PANE_PATH as the post-`treehouse get` pane cwd, and a real git +# worktree is added at a path shaped like a genuine treehouse pool slot +# (.../-//) so fm_compose_project_name sees +# realistic input. `treehouse` itself is stubbed to exit 0 (unused: the fake +# tmux answers the cwd query directly). +set -u + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +fm_git_identity fmtest fmtest@example.invalid + +# A fake tmux that reports FM_FAKE_PANE_PATH as the pane's cwd after +# `treehouse get`, names the session on '#S', and swallows window ops. +make_spawn_fakebin() { + local dir=$1 fakebin + fakebin=$(fm_fakebin "$dir") + cat > "$fakebin/tmux" <<'SH' +#!/usr/bin/env bash +set -u +case "$*" in + *"#{pane_current_path}"*) printf '%s\n' "${FM_FAKE_PANE_PATH:-}"; exit 0 ;; +esac +case "${1:-}" in + display-message) printf 'firstmate\n'; exit 0 ;; + list-windows) exit 0 ;; + has-session|new-session|new-window|send-keys) exit 0 ;; +esac +exit 0 +SH + chmod +x "$fakebin/tmux" + fm_fake_exit0 "$fakebin" treehouse + printf '%s\n' "$fakebin" +} + +run_spawn() { # [extra-args...] + local home=$1 id=$2 proj=$3 pane=$4 fakebin=$5 + shift 5 + mkdir -p "$home/data/$id" + printf 'brief\n' > "$home/data/$id/brief.md" + FM_ROOT_OVERRIDE='' FM_HOME="$home" \ + FM_STATE_OVERRIDE="$home/state" FM_DATA_OVERRIDE="$home/data" \ + FM_PROJECTS_OVERRIDE="$home/projects" FM_CONFIG_OVERRIDE="$home/config" \ + FM_SPAWN_NO_GUARD=1 FM_FAKE_PANE_PATH="$pane" TMUX="fake,1,0" \ + PATH="$fakebin:$PATH" \ + "$ROOT/bin/fm-spawn.sh" "$id" "$proj" codex "$@" 2>&1 +} + +# --- two worktrees of the same project, same leaf basename, different pool +# slots: this is the exact near-miss from the wealthsync incident this fix +# targets. Each must get its own compose_project name. -------------------- + +test_two_worktrees_same_leaf_get_distinct_compose_names() { + local tmp proj fakebin wt_a wt_b out_a out_b + tmp=$(fm_test_tmproot fm-spawn-compose) + proj=$(fm_git_init_commit "$tmp/proj" && printf '%s\n' "$tmp/proj") + fakebin=$(make_spawn_fakebin "$tmp/fake") + + # Two pool-slot worktrees of the SAME project, sharing the leaf name + # "wealthsync" - mirrors a real treehouse pool: -//. + wt_a="$tmp/wealthsync-323ec0/2/wealthsync" + wt_b="$tmp/wealthsync-323ec0/7/wealthsync" + git -C "$proj" worktree add -q --detach "$wt_a" || fail "setup: worktree add (slot 2) failed" + git -C "$proj" worktree add -q --detach "$wt_b" || fail "setup: worktree add (slot 7) failed" + + out_a=$(run_spawn "$tmp/home" wealthsync-task-a "$proj" "$wt_a" "$fakebin") + expect_code 0 "$?" "spawn into pool slot 2 should succeed" + assert_contains "$out_a" "spawned wealthsync-task-a" "slot-2 spawn did not report success" + + out_b=$(run_spawn "$tmp/home" wealthsync-task-b "$proj" "$wt_b" "$fakebin") + expect_code 0 "$?" "spawn into pool slot 7 should succeed" + assert_contains "$out_b" "spawned wealthsync-task-b" "slot-7 spawn did not report success" + + assert_present "$wt_a/.treehouse-compose-project" "slot-2 worktree missing the compose-project marker" + assert_present "$wt_b/.treehouse-compose-project" "slot-7 worktree missing the compose-project marker" + + local name_a name_b + name_a=$(cat "$wt_a/.treehouse-compose-project") + name_b=$(cat "$wt_b/.treehouse-compose-project") + + [ "$name_a" = "wealthsync-323ec0-2" ] || fail "slot-2 compose project name: expected 'wealthsync-323ec0-2', got '$name_a'" + [ "$name_b" = "wealthsync-323ec0-7" ] || fail "slot-7 compose project name: expected 'wealthsync-323ec0-7', got '$name_b'" + [ "$name_a" != "$name_b" ] || fail "two worktrees of the same project must not collide on the same compose project name" + + assert_grep "compose_project=$name_a" "$tmp/home/state/wealthsync-task-a.meta" "task-a meta missing compose_project=" + assert_grep "compose_project=$name_b" "$tmp/home/state/wealthsync-task-b.meta" "task-b meta missing compose_project=" + + pass "fm-spawn: two worktrees of the same project (same leaf basename, different pool slots) get distinct, stable compose_project names" +} + +# --- the marker must be invisible to the worktree's own git status, so it +# never trips teardown's dirty-worktree check or leaks into a crewmate commit. + +test_marker_is_git_excluded() { + local tmp proj fakebin wt out status_out + tmp=$(fm_test_tmproot fm-spawn-compose-excl) + proj=$(fm_git_init_commit "$tmp/proj" && printf '%s\n' "$tmp/proj") + fakebin=$(make_spawn_fakebin "$tmp/fake") + + wt="$tmp/excltest-9ab1/3/excltest" + git -C "$proj" worktree add -q --detach "$wt" || fail "setup: worktree add failed" + + out=$(run_spawn "$tmp/home" excl-task-c "$proj" "$wt" "$fakebin") + expect_code 0 "$?" "spawn should succeed" + assert_present "$wt/.treehouse-compose-project" "worktree missing the compose-project marker" + + status_out=$(git -C "$wt" status --porcelain) + assert_not_contains "$status_out" ".treehouse-compose-project" "compose-project marker must be git-excluded, not shown as untracked" + + pass "fm-spawn: the compose-project marker is excluded from the worktree's own git status" +} + +# Secondmate spawns never write the marker (a secondmate runs in its own +# persistent firstmate home, not a per-task pool worktree). Setting up a real +# secondmate home's full validation contract (SUB_HOME_MARKER, AGENTS.md, +# bin/, registry) duplicates tests/fm-secondmate-lifecycle-e2e.test.sh's own +# fixture, so that suite's phase_spawn carries the corresponding assertions +# instead of re-deriving the fixture here. + +test_two_worktrees_same_leaf_get_distinct_compose_names +test_marker_is_git_excluded From 4f2f3d406664179dc80006ac0c43704edb742fc3 Mon Sep 17 00:00:00 2001 From: Michael Date: Sat, 4 Jul 2026 22:59:32 -0400 Subject: [PATCH 06/46] no-mistakes(lint): Fix unused $out variable flagged by shellcheck in compose isolation test --- tests/fm-spawn-compose-isolation.test.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/fm-spawn-compose-isolation.test.sh b/tests/fm-spawn-compose-isolation.test.sh index 8f9a663d2..496c998ea 100755 --- a/tests/fm-spawn-compose-isolation.test.sh +++ b/tests/fm-spawn-compose-isolation.test.sh @@ -108,7 +108,7 @@ test_two_worktrees_same_leaf_get_distinct_compose_names() { # never trips teardown's dirty-worktree check or leaks into a crewmate commit. test_marker_is_git_excluded() { - local tmp proj fakebin wt out status_out + local tmp proj fakebin wt status_out tmp=$(fm_test_tmproot fm-spawn-compose-excl) proj=$(fm_git_init_commit "$tmp/proj" && printf '%s\n' "$tmp/proj") fakebin=$(make_spawn_fakebin "$tmp/fake") @@ -116,7 +116,7 @@ test_marker_is_git_excluded() { wt="$tmp/excltest-9ab1/3/excltest" git -C "$proj" worktree add -q --detach "$wt" || fail "setup: worktree add failed" - out=$(run_spawn "$tmp/home" excl-task-c "$proj" "$wt" "$fakebin") + run_spawn "$tmp/home" excl-task-c "$proj" "$wt" "$fakebin" >/dev/null expect_code 0 "$?" "spawn should succeed" assert_present "$wt/.treehouse-compose-project" "worktree missing the compose-project marker" From 6a58fc0f43d65f2c65d1131927994d55ac7e6365 Mon Sep 17 00:00:00 2001 From: Michael Date: Thu, 9 Jul 2026 23:32:13 -0400 Subject: [PATCH 07/46] feat(spawn): auto-compact claude crewmates around 200k tokens Claude crewmates and secondmates are separate claude processes launched by fm-spawn.sh; they never inherit the primary's .claude/settings.local.json, so they only compact at the hard context wall instead of proactively. Add CLAUDE_CODE_AUTO_COMPACT_WINDOW=200000 to the claude launch template. The var is capped at the model's real context window, so a fixed 200000 lands auto-compaction around 200k regardless of whether the spawned model's window is 200k or (as verified live for --model sonnet on this account, which carries the 1M-context beta) ~1M. The primary's own two-var form (WINDOW=1000000 + PCT_OVERRIDE=20) assumes a 1M window and is not safe to copy blindly for a smaller-window model/account combination. grok, codex, opencode, and pi launches are left untouched: grok's binary is not verified to honor Claude Code env vars, and the other three ignore them harmlessly but should not carry a var meant for a harness they aren't. --- .agents/skills/harness-adapters/SKILL.md | 6 ++++++ bin/fm-spawn.sh | 22 ++++++++++++++++++++- tests/fm-backend-orca.test.sh | 2 +- tests/fm-spawn-dispatch-profile.test.sh | 25 +++++++++++++++++++++++- 4 files changed, 52 insertions(+), 3 deletions(-) diff --git a/.agents/skills/harness-adapters/SKILL.md b/.agents/skills/harness-adapters/SKILL.md index 63f378bb7..56210389c 100644 --- a/.agents/skills/harness-adapters/SKILL.md +++ b/.agents/skills/harness-adapters/SKILL.md @@ -132,6 +132,12 @@ Its broader dark-TRUECOLOR placeholder handling and dark-theme tradeoff are docu That styled capture is internal to the boolean detector only. `fm-peek` and every other human or LLM-facing capture path stays plain `tmux capture-pane` with no escape codes. +**Auto-compaction fact (verified 2026-07-09, Claude Code 2.1.206).** +`bin/fm-spawn.sh` launches every claude crewmate and secondmate with `CLAUDE_CODE_AUTO_COMPACT_WINDOW=200000`, scoped the same way as `CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false` above, so these separately-launched processes auto-compact around 200k tokens the way the primary already does via its own `.claude/settings.local.json` (which they never inherit). +The var is read once at process startup and is capped at the model's real context window, so a fixed `200000` stays correct regardless of the spawned model's window: `min(200000, window)` is `200000` either way. +Verified live in a tmux pane: `claude --model sonnet` on this account resolves to a ~967k-token auto-compact window with no override (`/context` reported `Auto-compact window: 967k tokens`, i.e. the 1M-context beta, not the 200k a plain Sonnet window would imply); relaunched with `CLAUDE_CODE_AUTO_COMPACT_WINDOW=200000` set, `/context` reported `39.5k/200k tokens (20%)` and `Auto-compact window: 200k tokens` - the window pinned to the override as expected. +This is why the primary's own two-var form (`CLAUDE_CODE_AUTO_COMPACT_WINDOW=1000000` + `CLAUDE_AUTOCOMPACT_PCT_OVERRIDE=20`) is not copied here: that pair assumes a 1M window, and would silently cap a smaller-window model/account combination to a far more aggressive trigger. + **Primary-session guard fact (verified 2026-07-04, Claude Code 2.1.201; preserved 2026-07-08, Claude Code 2.1.204).** This is separate from the per-task crewmate turn-end hook above (that one just `touch`es a marker file in a task's own `.claude/settings.local.json`). The firstmate PRIMARY's own `.claude/settings.json` registers `bin/fm-turnend-guard.sh` as a Stop hook, and exiting with status 2 plus stderr reliably forces the model to continue. diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index 4a336c431..79a530a3b 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -85,6 +85,13 @@ # files below), and record it in meta as compose_project=, so two # worktrees of the same project never collide on Compose's default # project-naming (fm-brief.sh documents how a crewmate consumes it). +# claude launches (crewmate, scout, and secondmate) carry +# CLAUDE_CODE_AUTO_COMPACT_WINDOW=200000 so they auto-compact the way the primary +# interactive session already does via its own .claude/settings.local.json, which a +# separately-launched claude process never inherits; see the comment on the claude +# case in launch_template() below for why 200000 alone (not the primary's +# WINDOW=1000000 + PCT_OVERRIDE=20 pair) is the value that stays correct regardless +# of the spawned model's real context window. set -eu SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -327,7 +334,20 @@ launch_template() { # does NOT suppress the interactive ghost text (verified empirically), so the env # var is the correct control. The dim-aware composer reader in fm-tmux-lib.sh is # the defense-in-depth backstop for any pane this flag cannot reach. - claude) printf '%s' 'CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false claude --dangerously-skip-permissions __MODELFLAG____EFFORTFLAG__"$(cat __BRIEF__)"' ;; + # + # CLAUDE_CODE_AUTO_COMPACT_WINDOW=200000 gives claude crewmates and secondmates + # the same auto-compaction the primary interactive session already has via its + # own .claude/settings.local.json, which these separately-launched processes + # never inherit. The var is read once at startup and is CAPPED at the model's + # actual context window, so a single fixed value stays correct across whatever + # crewmate model is requested: min(200000, window) is 200000 whether the model's + # window is 200k or (as verified for --model sonnet on this account, which gets + # the 1M-context beta) ~1M, landing auto-compaction "around 200k" either way. + # Do not copy the primary's two-var form (WINDOW=1000000 + PCT_OVERRIDE=20): + # that pair assumes a 1M window and is not safe for a harness/account + # combination with a smaller one. See the harness-adapters skill's claude + # section for the dated /context verification. + claude) printf '%s' 'CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false CLAUDE_CODE_AUTO_COMPACT_WINDOW=200000 claude --dangerously-skip-permissions __MODELFLAG____EFFORTFLAG__"$(cat __BRIEF__)"' ;; codex) if [ "$kind" = secondmate ]; then printf '%s' 'codex __MODELFLAG____EFFORTFLAG__--dangerously-bypass-approvals-and-sandbox "$(cat __BRIEF__)"' diff --git a/tests/fm-backend-orca.test.sh b/tests/fm-backend-orca.test.sh index 60cc0dca8..5609dc74f 100755 --- a/tests/fm-backend-orca.test.sh +++ b/tests/fm-backend-orca.test.sh @@ -502,7 +502,7 @@ test_spawn_writes_orca_metadata_and_launches_harness() { "spawn should reuse the implicit terminal returned by Orca worktree creation" assert_contains "$(cat "$log")" $'orca\x1f''terminal'$'\x1f''send'$'\x1f''--terminal'$'\x1f''term-spawn'$'\x1f''--text'$'\x1f''export GOTMPDIR=/tmp/fm-orcaspawnz1/gotmp'$'\x1f''--enter'$'\x1f''--json' \ "spawn did not export GOTMPDIR through the Orca terminal" - assert_contains "$(cat "$log")" "CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false claude --dangerously-skip-permissions" \ + assert_contains "$(cat "$log")" "CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false CLAUDE_CODE_AUTO_COMPACT_WINDOW=200000 claude --dangerously-skip-permissions" \ "spawn did not send the selected harness launch command through Orca" rm -rf "/tmp/fm-$id" pass "fm-spawn.sh --backend orca: reuses implicit terminal, records metadata, launches harness" diff --git a/tests/fm-spawn-dispatch-profile.test.sh b/tests/fm-spawn-dispatch-profile.test.sh index d549c9bce..7e5b4b87c 100755 --- a/tests/fm-spawn-dispatch-profile.test.sh +++ b/tests/fm-spawn-dispatch-profile.test.sh @@ -118,7 +118,7 @@ test_no_profile_keeps_claude_launch_unchanged() { assert_meta_profile "$HOME_DIR/state/$id.meta" claude default default launch=$(cat "$LAUNCH_LOG") - expected="CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false claude --dangerously-skip-permissions \"\$(cat '$HOME_DIR/data/$id/brief.md')\"" + expected="CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false CLAUDE_CODE_AUTO_COMPACT_WINDOW=200000 claude --dangerously-skip-permissions \"\$(cat '$HOME_DIR/data/$id/brief.md')\"" [ "$launch" = "$expected" ] || fail "no-profile claude launch changed"$'\n'"expected: $expected"$'\n'"actual: $launch" pass "no --model/--effort records defaults and keeps the claude launch byte-identical" } @@ -221,9 +221,31 @@ test_claude_threads_model_and_effort() { launch=$(cat "$LAUNCH_LOG") assert_contains "$launch" "claude --dangerously-skip-permissions --model 'sonnet' --effort 'high'" \ "claude launch did not thread model and effort flags" + assert_contains "$launch" "CLAUDE_CODE_AUTO_COMPACT_WINDOW=200000" \ + "claude launch must carry the auto-compaction window env var regardless of model/effort flags" pass "claude receives --model and --effort profile flags" } +# Only claude launches carry the auto-compaction env var; codex, opencode, and pi +# ignore Claude Code env vars harmlessly, but firstmate should not set it for them +# (grok is intentionally out of scope too - unverified whether its binary honors it). +test_only_claude_launch_carries_autocompact_window() { + local rec id out status launch harness + for harness in codex opencode pi grok; do + id="profile-autocompact-$harness-z17" + rec=$(make_spawn_case "profile-autocompact-$harness" "$harness" "$id") + read_case_record "$rec" + + out=$(run_spawn "$HOME_DIR" "$WT_DIR" "$FAKEBIN_DIR" "$LAUNCH_LOG" "$id" "$PROJ_DIR") + status=$? + expect_code 0 "$status" "$harness spawn without profile flags should succeed" + launch=$(cat "$LAUNCH_LOG") + assert_not_contains "$launch" "CLAUDE_CODE_AUTO_COMPACT_WINDOW" \ + "$harness launch must not carry claude's auto-compaction env var" + done + pass "codex, opencode, pi, and grok launches never carry CLAUDE_CODE_AUTO_COMPACT_WINDOW" +} + test_codex_threads_model_and_effort() { local rec id out status launch id=profile-codex-z3 @@ -391,6 +413,7 @@ test_active_dispatch_profile_allows_explicit_harness test_active_dispatch_profile_allows_positional_harness test_active_dispatch_profile_allows_raw_launch_command test_claude_threads_model_and_effort +test_only_claude_launch_carries_autocompact_window test_codex_threads_model_and_effort test_codex_omits_invalid_max_effort test_grok_threads_model_and_reasoning_effort From 06ada805e935b41206abb018dd97319bbe6f917c Mon Sep 17 00:00:00 2001 From: Michael Date: Fri, 10 Jul 2026 00:12:35 -0400 Subject: [PATCH 08/46] no-mistakes(test): fix environment-fragile test failures (jq, git, harness detection) --- bin/backends/cmux.sh | 17 ++++++++++------- tests/fm-teardown.test.sh | 17 +++++++++++++++++ 2 files changed, 27 insertions(+), 7 deletions(-) diff --git a/bin/backends/cmux.sh b/bin/backends/cmux.sh index c37f003f4..478e9ee50 100644 --- a/bin/backends/cmux.sh +++ b/bin/backends/cmux.sh @@ -398,13 +398,16 @@ fm_backend_cmux_parse_target() { # # (fm_backend_zellij_pane_exists) rather than the design sketch's original # read-screen-based suggestion. fm_backend_cmux_surface_exists() { # - local wsid=$1 sfid=$2 raw - # Capture the CLI call's own exit status before piping to jq: on an empty - # stdin (the CLI call failed and printed nothing) `jq -e` still exits 0, so - # piping unconditionally would silently treat a failed call as "not found" - # rather than propagating the failure. - raw=$(fm_backend_cmux_cli list-panes --workspace "$wsid" --json --id-format uuids 2>/dev/null) || return 1 - printf '%s' "$raw" | jq -e --arg s "$sfid" '[.panes[]? | select(.surface_ids // [] | index($s))] | length > 0' >/dev/null 2>&1 + local wsid=$1 sfid=$2 out + # Capture the list-panes call and gate on its OWN exit/output before piping to + # jq: an absent target makes list-panes fail with no output, and `jq -e` on + # empty input exits 0 on jq 1.6 (only 1.7+ reports the no-result exit 4), so + # relying on the pipe's trailing jq status alone would false-positive the + # surface as present on older jq. A failed or empty list-panes means no surface. + out=$(fm_backend_cmux_cli list-panes --workspace "$wsid" --json --id-format uuids 2>/dev/null) || return 1 + [ -n "$out" ] || return 1 + printf '%s' "$out" \ + | jq -e --arg s "$sfid" '[.panes[]? | select(.surface_ids // [] | index($s))] | length > 0' >/dev/null 2>&1 } # fm_backend_cmux_target_ready: parse the target and verify it is live via diff --git a/tests/fm-teardown.test.sh b/tests/fm-teardown.test.sh index 95b7c006c..8ceedd8cd 100755 --- a/tests/fm-teardown.test.sh +++ b/tests/fm-teardown.test.sh @@ -61,6 +61,15 @@ TMP_ROOT=$(fm_test_tmproot fm-teardown-tests) REAL_GIT_FOR_TEST=$(command -v git) export REAL_GIT_FOR_TEST +# fm-teardown.sh's content-in-default fallback merges via `git merge-tree +# --write-tree`, which git only grew in 2.38. On an older git the fallback is +# inconclusive by design (teardown refuses rather than guess), so the two content +# ALLOW cases below cannot pass. Skip them there instead of false-failing; a +# modern-git CI runs them fully. +git_has_merge_tree_write_tree() { + git merge-tree -h 2>&1 | grep -q -- '--write-tree' +} + # Build a fresh sandbox for one test case. Sets up: # $CASE/state/ - firstmate state dir (with a fresh watcher beacon) # $CASE/fakebin/ - mocks for treehouse, tmux (PATH-prepended by caller) @@ -804,6 +813,10 @@ test_pr_check_records_remote_head_when_local_lags() { test_content_in_default_fallback_allows() { local case_dir rc + if ! git_has_merge_tree_write_tree; then + pass "worktree whose content already landed in the default branch is torn down (content fallback) [skipped: git < 2.38 lacks merge-tree --write-tree]" + return 0 + fi case_dir=$(make_case content-landed) write_meta "$case_dir" no-mistakes ship # No pr= recorded and the default gh-axi mock reports no PR, so the merged-PR path @@ -824,6 +837,10 @@ test_content_in_default_fallback_allows() { test_content_fallback_refreshes_stale_origin_ref() { local case_dir rc + if ! git_has_merge_tree_write_tree; then + pass "content fallback refreshes origin default before comparing trees [skipped: git < 2.38 lacks merge-tree --write-tree]" + return 0 + fi case_dir=$(make_case content-stale-ref) write_meta "$case_dir" no-mistakes ship wt_commit_file "$case_dir" feature.txt hello "add feature" From c3917be53df8e61f37339c65082c8839e79f2848 Mon Sep 17 00:00:00 2001 From: Michael Date: Sat, 11 Jul 2026 13:23:52 -0400 Subject: [PATCH 09/46] feat(spawn): raise claude auto-compact window to 300000 Bump CLAUDE_CODE_AUTO_COMPACT_WINDOW from 200000 to 300000 for claude crewmates and secondmates, matching the primary session's own updated target. The override is capped at the model's real context window, so it is a no-op below 300000 and only takes effect above it; today's standard --model sonnet crewmate spawn resolves to the ~1M-context beta, well above the new threshold. Updated the fm-spawn.sh header comment, launch_template() comment, and the harness-adapters SKILL.md fact to describe this conditional behavior instead of the "regardless of window" framing that only held at the old 200000 value. --- .agents/skills/harness-adapters/SKILL.md | 9 ++++--- bin/fm-spawn.sh | 31 ++++++++++++++---------- tests/fm-backend-orca.test.sh | 2 +- tests/fm-spawn-dispatch-profile.test.sh | 4 +-- 4 files changed, 26 insertions(+), 20 deletions(-) diff --git a/.agents/skills/harness-adapters/SKILL.md b/.agents/skills/harness-adapters/SKILL.md index 56210389c..ec6bb5bc0 100644 --- a/.agents/skills/harness-adapters/SKILL.md +++ b/.agents/skills/harness-adapters/SKILL.md @@ -132,10 +132,11 @@ Its broader dark-TRUECOLOR placeholder handling and dark-theme tradeoff are docu That styled capture is internal to the boolean detector only. `fm-peek` and every other human or LLM-facing capture path stays plain `tmux capture-pane` with no escape codes. -**Auto-compaction fact (verified 2026-07-09, Claude Code 2.1.206).** -`bin/fm-spawn.sh` launches every claude crewmate and secondmate with `CLAUDE_CODE_AUTO_COMPACT_WINDOW=200000`, scoped the same way as `CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false` above, so these separately-launched processes auto-compact around 200k tokens the way the primary already does via its own `.claude/settings.local.json` (which they never inherit). -The var is read once at process startup and is capped at the model's real context window, so a fixed `200000` stays correct regardless of the spawned model's window: `min(200000, window)` is `200000` either way. -Verified live in a tmux pane: `claude --model sonnet` on this account resolves to a ~967k-token auto-compact window with no override (`/context` reported `Auto-compact window: 967k tokens`, i.e. the 1M-context beta, not the 200k a plain Sonnet window would imply); relaunched with `CLAUDE_CODE_AUTO_COMPACT_WINDOW=200000` set, `/context` reported `39.5k/200k tokens (20%)` and `Auto-compact window: 200k tokens` - the window pinned to the override as expected. +**Auto-compaction fact (verified 2026-07-09, Claude Code 2.1.206; shipped constant raised to 300000 2026-07-11).** +`bin/fm-spawn.sh` launches every claude crewmate and secondmate with `CLAUDE_CODE_AUTO_COMPACT_WINDOW=300000`, scoped the same way as `CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false` above, so these separately-launched processes auto-compact around 300k tokens the way the primary already does via its own `.claude/settings.local.json` (which they never inherit). +The var is read once at process startup and is capped at the model's real context window, so it can only lower the effective trigger, never raise it past the real ceiling: `min(300000, window)` is `300000` once `window` exceeds it, but a no-op (stays at `window`) below that. +This is not a universal guarantee for every possible future model choice - a differently-configured account/model capped at the plain ~200k window would see no change - but it is true for the window tier crew actually runs on today: `--model sonnet` on this account, the standard crewmate spawn, resolves to the ~1M-context beta, well above 300000. +Verified live in a tmux pane at the original 200000 value (the mechanism is unchanged, only the shipped constant moved to 300000): `claude --model sonnet` on this account resolves to a ~967k-token auto-compact window with no override (`/context` reported `Auto-compact window: 967k tokens`, i.e. the 1M-context beta, not the 200k a plain Sonnet window would imply); relaunched with `CLAUDE_CODE_AUTO_COMPACT_WINDOW=200000` set, `/context` reported `39.5k/200k tokens (20%)` and `Auto-compact window: 200k tokens` - the window pinned to the override as expected. This is why the primary's own two-var form (`CLAUDE_CODE_AUTO_COMPACT_WINDOW=1000000` + `CLAUDE_AUTOCOMPACT_PCT_OVERRIDE=20`) is not copied here: that pair assumes a 1M window, and would silently cap a smaller-window model/account combination to a far more aggressive trigger. **Primary-session guard fact (verified 2026-07-04, Claude Code 2.1.201; preserved 2026-07-08, Claude Code 2.1.204).** diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index 79a530a3b..5f453c379 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -86,12 +86,12 @@ # worktrees of the same project never collide on Compose's default # project-naming (fm-brief.sh documents how a crewmate consumes it). # claude launches (crewmate, scout, and secondmate) carry -# CLAUDE_CODE_AUTO_COMPACT_WINDOW=200000 so they auto-compact the way the primary +# CLAUDE_CODE_AUTO_COMPACT_WINDOW=300000 so they auto-compact the way the primary # interactive session already does via its own .claude/settings.local.json, which a # separately-launched claude process never inherits; see the comment on the claude -# case in launch_template() below for why 200000 alone (not the primary's -# WINDOW=1000000 + PCT_OVERRIDE=20 pair) is the value that stays correct regardless -# of the spawned model's real context window. +# case in launch_template() below for why 300000 (not the primary's +# WINDOW=1000000 + PCT_OVERRIDE=20 pair) is safe to hardcode: it is capped at the +# real context window, so it is a no-op below that window and takes effect above it. set -eu SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -335,19 +335,24 @@ launch_template() { # var is the correct control. The dim-aware composer reader in fm-tmux-lib.sh is # the defense-in-depth backstop for any pane this flag cannot reach. # - # CLAUDE_CODE_AUTO_COMPACT_WINDOW=200000 gives claude crewmates and secondmates - # the same auto-compaction the primary interactive session already has via its - # own .claude/settings.local.json, which these separately-launched processes - # never inherit. The var is read once at startup and is CAPPED at the model's - # actual context window, so a single fixed value stays correct across whatever - # crewmate model is requested: min(200000, window) is 200000 whether the model's - # window is 200k or (as verified for --model sonnet on this account, which gets - # the 1M-context beta) ~1M, landing auto-compaction "around 200k" either way. + # CLAUDE_CODE_AUTO_COMPACT_WINDOW=300000 gives claude crewmates and secondmates + # a higher auto-compaction ceiling than the plain-window default, the same way + # the primary interactive session already has via its own + # .claude/settings.local.json, which these separately-launched processes never + # inherit. The var is read once at startup and is CAPPED at the model's actual + # context window, so it can only lower the effective trigger, never raise it past + # the real ceiling: min(300000, window) is 300000 once window exceeds it, but a + # no-op (stays at window) below that. This is not a universal guarantee for every + # possible future model choice - a differently-configured account/model capped at + # the plain ~200k window would see no change - but it is true for the window tier + # crew actually runs on today: as verified for --model sonnet on this account + # (the standard crewmate spawn), the window resolves to the ~1M-context beta, + # well above 300000. # Do not copy the primary's two-var form (WINDOW=1000000 + PCT_OVERRIDE=20): # that pair assumes a 1M window and is not safe for a harness/account # combination with a smaller one. See the harness-adapters skill's claude # section for the dated /context verification. - claude) printf '%s' 'CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false CLAUDE_CODE_AUTO_COMPACT_WINDOW=200000 claude --dangerously-skip-permissions __MODELFLAG____EFFORTFLAG__"$(cat __BRIEF__)"' ;; + claude) printf '%s' 'CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false CLAUDE_CODE_AUTO_COMPACT_WINDOW=300000 claude --dangerously-skip-permissions __MODELFLAG____EFFORTFLAG__"$(cat __BRIEF__)"' ;; codex) if [ "$kind" = secondmate ]; then printf '%s' 'codex __MODELFLAG____EFFORTFLAG__--dangerously-bypass-approvals-and-sandbox "$(cat __BRIEF__)"' diff --git a/tests/fm-backend-orca.test.sh b/tests/fm-backend-orca.test.sh index 5609dc74f..bd4aad112 100755 --- a/tests/fm-backend-orca.test.sh +++ b/tests/fm-backend-orca.test.sh @@ -502,7 +502,7 @@ test_spawn_writes_orca_metadata_and_launches_harness() { "spawn should reuse the implicit terminal returned by Orca worktree creation" assert_contains "$(cat "$log")" $'orca\x1f''terminal'$'\x1f''send'$'\x1f''--terminal'$'\x1f''term-spawn'$'\x1f''--text'$'\x1f''export GOTMPDIR=/tmp/fm-orcaspawnz1/gotmp'$'\x1f''--enter'$'\x1f''--json' \ "spawn did not export GOTMPDIR through the Orca terminal" - assert_contains "$(cat "$log")" "CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false CLAUDE_CODE_AUTO_COMPACT_WINDOW=200000 claude --dangerously-skip-permissions" \ + assert_contains "$(cat "$log")" "CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false CLAUDE_CODE_AUTO_COMPACT_WINDOW=300000 claude --dangerously-skip-permissions" \ "spawn did not send the selected harness launch command through Orca" rm -rf "/tmp/fm-$id" pass "fm-spawn.sh --backend orca: reuses implicit terminal, records metadata, launches harness" diff --git a/tests/fm-spawn-dispatch-profile.test.sh b/tests/fm-spawn-dispatch-profile.test.sh index 7e5b4b87c..68717c985 100755 --- a/tests/fm-spawn-dispatch-profile.test.sh +++ b/tests/fm-spawn-dispatch-profile.test.sh @@ -118,7 +118,7 @@ test_no_profile_keeps_claude_launch_unchanged() { assert_meta_profile "$HOME_DIR/state/$id.meta" claude default default launch=$(cat "$LAUNCH_LOG") - expected="CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false CLAUDE_CODE_AUTO_COMPACT_WINDOW=200000 claude --dangerously-skip-permissions \"\$(cat '$HOME_DIR/data/$id/brief.md')\"" + expected="CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false CLAUDE_CODE_AUTO_COMPACT_WINDOW=300000 claude --dangerously-skip-permissions \"\$(cat '$HOME_DIR/data/$id/brief.md')\"" [ "$launch" = "$expected" ] || fail "no-profile claude launch changed"$'\n'"expected: $expected"$'\n'"actual: $launch" pass "no --model/--effort records defaults and keeps the claude launch byte-identical" } @@ -221,7 +221,7 @@ test_claude_threads_model_and_effort() { launch=$(cat "$LAUNCH_LOG") assert_contains "$launch" "claude --dangerously-skip-permissions --model 'sonnet' --effort 'high'" \ "claude launch did not thread model and effort flags" - assert_contains "$launch" "CLAUDE_CODE_AUTO_COMPACT_WINDOW=200000" \ + assert_contains "$launch" "CLAUDE_CODE_AUTO_COMPACT_WINDOW=300000" \ "claude launch must carry the auto-compaction window env var regardless of model/effort flags" pass "claude receives --model and --effort profile flags" } From 93c31749b9740f5d5154a28c0d23dd5175d29004 Mon Sep 17 00:00:00 2001 From: Michael Date: Sat, 11 Jul 2026 13:42:46 -0400 Subject: [PATCH 10/46] fix(teardown): recognize work fast-forwarded into local main as landed content_in_default() only ever diffed a branch against the fetched refs/remotes/origin/$name, so a no-mistakes-mode task whose branch was merged into the LOCAL default branch by hand (e.g. after its upstream PR was closed unmerged) was never recognized as landed and teardown refused it. It now also checks the local refs/heads/$name ref. --- bin/fm-teardown.sh | 55 ++++++++++++++++++++------------- tests/fm-teardown.test.sh | 65 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 99 insertions(+), 21 deletions(-) diff --git a/bin/fm-teardown.sh b/bin/fm-teardown.sh index 8001b173a..bb72f02a7 100755 --- a/bin/fm-teardown.sh +++ b/bin/fm-teardown.sh @@ -275,27 +275,16 @@ pr_is_merged() { unpushed_patches_are_in_pr_head "$head" } -# Is the branch's content already present in the up-to-date default branch? Fetches -# first, then checks every path HEAD changed since its merge-base with the default -# branch: if the default branch already matches HEAD at each of those paths, HEAD -# introduces nothing the default branch does not already contain (e.g. its change -# landed via squash). This isolates branch-only changes, so unrelated commits the -# default branch gained past the merge-base do not count as "added" - equivalent to -# a 3-way merge's resulting tree matching the default branch's tree, but built from -# plain diff/merge-base so it works on git older than 2.38 (which is what `git -# merge-tree --write-tree` requires). Returns non-zero when inconclusive (no default -# ref, or a real difference), so the caller refuses rather than guesses. -content_in_default() { - local name ref base changed_file - name=$(default_branch) || return 1 - if git -C "$WT" remote get-url origin >/dev/null 2>&1; then - git -C "$WT" fetch --quiet origin "+refs/heads/$name:refs/remotes/origin/$name" >/dev/null 2>&1 || return 1 - ref="refs/remotes/origin/$name" - elif git -C "$WT" rev-parse --quiet --verify "refs/heads/$name" >/dev/null 2>&1; then - ref="refs/heads/$name" - else - return 1 - fi +# Does HEAD's content already match the given ref? Checks every path HEAD changed +# since its merge-base with ref: if ref already matches HEAD at each of those paths, +# HEAD introduces nothing ref does not already contain (e.g. its change landed via +# squash). This isolates branch-only changes, so unrelated commits ref gained past +# the merge-base do not count as "added" - equivalent to a 3-way merge's resulting +# tree matching ref's tree, but built from plain diff/merge-base so it works on git +# older than 2.38 (which is what `git merge-tree --write-tree` requires). Returns +# non-zero when inconclusive (ref missing, or a real difference). +content_matches_ref() { + local ref=$1 base changed_file git -C "$WT" rev-parse --quiet --verify "$ref^{commit}" >/dev/null 2>&1 || return 1 base=$(git -C "$WT" merge-base "$ref" HEAD 2>/dev/null) || return 1 local -a changed=() @@ -306,6 +295,30 @@ content_in_default() { git -C "$WT" diff --quiet "$ref" HEAD -- "${changed[@]}" 2>/dev/null } +# Is the branch's content already present in the up-to-date default branch? Checks +# two candidate refs, succeeding if either already contains HEAD's content: the +# fetched remote-tracking ref refs/remotes/origin/$name (the normal PR-merged case), +# and the LOCAL refs/heads/$name (a worktree and its project's primary checkout are +# linked worktrees of the same repository, so this local ref already reflects a +# firstmate-performed local fast-forward merge, even for a project whose delivery +# mode is not local-only - e.g. a PR closed unmerged upstream but the branch was +# landed into local main by hand on the captain's approval). A fetch failure only +# rules out the remote-tracking path; the local ref is still tried. Returns +# non-zero only when neither candidate ref exists or matches. +content_in_default() { + local name + name=$(default_branch) || return 1 + if git -C "$WT" remote get-url origin >/dev/null 2>&1; then + if git -C "$WT" fetch --quiet origin "+refs/heads/$name:refs/remotes/origin/$name" >/dev/null 2>&1; then + content_matches_ref "refs/remotes/origin/$name" && return 0 + fi + fi + if git -C "$WT" rev-parse --quiet --verify "refs/heads/$name" >/dev/null 2>&1; then + content_matches_ref "refs/heads/$name" && return 0 + fi + return 1 +} + # Has the worktree's committed work actually LANDED, though its commits are not # reachable from any remote-tracking branch? True when a merged PR proves the # current local work is contained in the PR head, OR the content is already in the diff --git a/tests/fm-teardown.test.sh b/tests/fm-teardown.test.sh index 8ceedd8cd..50a7aecea 100755 --- a/tests/fm-teardown.test.sh +++ b/tests/fm-teardown.test.sh @@ -38,6 +38,8 @@ # (o) fm-pr-check rerun after HEAD moved -> no stale pr_head # (p) fm-pr-check when local HEAD lags -> record remote PR head # (q) no-mistakes + NO pr= recorded, PR discovered by branch -> ALLOW (yolo/no-CI merge) +# (z) no-mistakes + PR closed unmerged, never pushed, merged +# into LOCAL default branch by hand -> ALLOW (local-land fix) # # Also covers backlog teardown-lock-race: a git index.lock left in the worktree by a # killed crew process (bin/fm-teardown.sh's teardown_treehouse_return). @@ -245,6 +247,37 @@ SH chmod +x "$case_dir/fakebin/gh-axi" "$case_dir/fakebin/gh" } +# Override GitHub lookups to report PR 7 as closed (unmerged) with the current +# worktree HEAD as its head - simulates a PR deliberately closed without merging +# (e.g. upstream repo not owned by this user). +add_gh_pr_closed_for_head() { + local case_dir=$1 head=$2 + cat > "$case_dir/fakebin/gh-axi" <<'SH' +#!/usr/bin/env bash +case "${1:-} ${2:-}" in + "pr list") + printf '%s\n' "count: 1 (showing first 1)" "pull_requests[1]{number,state}:" " 7,closed" ; exit 0 ;; + "pr view") + printf '%s\n' "pull_request:" " number: 7" " state: closed" ; exit 0 ;; +esac +exit 0 +SH + cat > "$case_dir/fakebin/gh" <&2 +exit 1 +SH + chmod +x "$case_dir/fakebin/gh-axi" "$case_dir/fakebin/gh" +} + append_pr_meta_for_current_head() { local case_dir=$1 head head=$(git -C "$case_dir/wt" rev-parse HEAD) @@ -636,6 +669,37 @@ test_no_mistakes_truly_unpushed_refuses() { pass "no-mistakes worktree with genuinely unlanded work is refused (safety preserved)" } +# Reproduces the local-land false-negative: a project registered under the normal +# no-mistakes delivery mode, whose task branch was never pushed to any remote and +# whose PR was deliberately closed unmerged (e.g. the upstream repo is not owned by +# this user), but which firstmate itself fast-forward-merged into the LOCAL default +# branch by hand, on explicit prior captain approval. content_in_default() used to +# only ever diff against the fetched refs/remotes/origin/$name, so it never saw this +# local-only landing outside of mode=local-only; it must now also accept containment +# in the LOCAL refs/heads/$name. +test_no_mistakes_merged_to_local_main_allows() { + local case_dir rc pr_head + case_dir=$(make_case nm-local-land) + write_meta "$case_dir" no-mistakes ship + wt_commit_file "$case_dir" feature.txt hello "shippable work" + pr_head=$(git -C "$case_dir/wt" rev-parse HEAD) + append_pr_meta_url "$case_dir" + add_gh_pr_closed_for_head "$case_dir" "$pr_head" + # Never pushed to origin or any fork. Instead, firstmate fast-forwards the + # project's LOCAL main to the worktree's HEAD, exactly as bin/fm-merge-local.sh + # would for an explicitly approved local landing. + git -C "$case_dir/project" update-ref refs/heads/main "$pr_head" + + set +e + run_teardown "$case_dir" > "$case_dir/stdout" 2> "$case_dir/stderr" + rc=$? + set -e + + expect_code 0 "$rc" "nm-local-land: teardown should succeed when work is merged into local main" + ! grep -q REFUSED "$case_dir/stderr" || fail "nm-local-land: teardown printed a REFUSED line" + pass "no-mistakes worktree with a closed PR but work merged into local main is torn down (local-land fix)" +} + test_squash_merged_branch_deleted_allows() { local case_dir rc pr_head case_dir=$(make_case squash-merged) @@ -1287,6 +1351,7 @@ test_local_only_truly_unpushed_refuses test_local_only_merged_to_local_main_allows test_no_mistakes_origin_remote_allows test_no_mistakes_truly_unpushed_refuses +test_no_mistakes_merged_to_local_main_allows test_local_only_force_overrides_unpushed test_herdr_teardown_clears_escalation_marker test_squash_merged_branch_deleted_allows From 677f2ba82dc9ca20738d03b65aa69adfa20346b5 Mon Sep 17 00:00:00 2001 From: Michael Date: Sat, 11 Jul 2026 13:54:41 -0400 Subject: [PATCH 11/46] test(teardown): drop stale git-2.38 skip gate for content-in-default tests content_in_default()'s diff-based comparison (from local main's own prior rewrite) no longer depends on git merge-tree --write-tree, so the two tests that used to skip themselves on older git now run and pass here. --- tests/fm-teardown.test.sh | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/tests/fm-teardown.test.sh b/tests/fm-teardown.test.sh index 50a7aecea..c1cc2b35a 100755 --- a/tests/fm-teardown.test.sh +++ b/tests/fm-teardown.test.sh @@ -63,15 +63,6 @@ TMP_ROOT=$(fm_test_tmproot fm-teardown-tests) REAL_GIT_FOR_TEST=$(command -v git) export REAL_GIT_FOR_TEST -# fm-teardown.sh's content-in-default fallback merges via `git merge-tree -# --write-tree`, which git only grew in 2.38. On an older git the fallback is -# inconclusive by design (teardown refuses rather than guess), so the two content -# ALLOW cases below cannot pass. Skip them there instead of false-failing; a -# modern-git CI runs them fully. -git_has_merge_tree_write_tree() { - git merge-tree -h 2>&1 | grep -q -- '--write-tree' -} - # Build a fresh sandbox for one test case. Sets up: # $CASE/state/ - firstmate state dir (with a fresh watcher beacon) # $CASE/fakebin/ - mocks for treehouse, tmux (PATH-prepended by caller) @@ -877,10 +868,6 @@ test_pr_check_records_remote_head_when_local_lags() { test_content_in_default_fallback_allows() { local case_dir rc - if ! git_has_merge_tree_write_tree; then - pass "worktree whose content already landed in the default branch is torn down (content fallback) [skipped: git < 2.38 lacks merge-tree --write-tree]" - return 0 - fi case_dir=$(make_case content-landed) write_meta "$case_dir" no-mistakes ship # No pr= recorded and the default gh-axi mock reports no PR, so the merged-PR path @@ -901,10 +888,6 @@ test_content_in_default_fallback_allows() { test_content_fallback_refreshes_stale_origin_ref() { local case_dir rc - if ! git_has_merge_tree_write_tree; then - pass "content fallback refreshes origin default before comparing trees [skipped: git < 2.38 lacks merge-tree --write-tree]" - return 0 - fi case_dir=$(make_case content-stale-ref) write_meta "$case_dir" no-mistakes ship wt_commit_file "$case_dir" feature.txt hello "add feature" From bb70fd4c7007d32976df0ecefa55bb9578ee71ef Mon Sep 17 00:00:00 2001 From: Michael Date: Thu, 9 Jul 2026 17:10:29 -0400 Subject: [PATCH 12/46] feat(dispatch): add model/effort tiers and mechanical quality guardrails Extends config/crew-dispatch.json's schema with model/effort/ultracode axes per the resource-tiering design: docs/examples/crew-dispatch.json gains rules for the firstmate-repo supervision-backbone carve-out, safety-critical/security/migration work (opus/xhigh, ultracode-flagged), and architectural/product tradeoffs (opus/xhigh). Adds the four hardened guardrails as mechanical checks rather than prose aspirations: - bin/fm-tier-guard.sh: firstmate-observable escalation triggers (diff size and elapsed time vs. the trivial tier's envelope, plus a general heavy-scale ceiling for any tier). - bin/fm-risk-tripwire.sh: a keyword/path trip-wire against a task's brief and diff, independent of the natural-language dispatch match. - bin/fm-ultracode-guard.sh: flag/reviewed/check subcommands that mechanically confirm a genuinely independent second pass ran before an ultracode-flagged task reaches PR-ready. - A documented model-verification gate in AGENTS.md section 4, extending the existing unverified-harness discipline to model names. Verification tiering (light/standard/heavy no-mistakes runs) is deliberately out of scope here and left for a follow-up change. --- AGENTS.md | 23 +++- bin/fm-risk-tripwire.sh | 121 +++++++++++++++++++ bin/fm-tier-guard.sh | 121 +++++++++++++++++++ bin/fm-ultracode-guard.sh | 100 ++++++++++++++++ docs/configuration.md | 5 +- docs/examples/crew-dispatch.json | 15 +++ tests/fm-risk-tripwire.test.sh | 146 +++++++++++++++++++++++ tests/fm-tier-guard.test.sh | 192 +++++++++++++++++++++++++++++++ tests/fm-ultracode-guard.test.sh | 168 +++++++++++++++++++++++++++ 9 files changed, 889 insertions(+), 2 deletions(-) create mode 100755 bin/fm-risk-tripwire.sh create mode 100755 bin/fm-tier-guard.sh create mode 100755 bin/fm-ultracode-guard.sh create mode 100644 tests/fm-risk-tripwire.test.sh create mode 100644 tests/fm-tier-guard.test.sh create mode 100644 tests/fm-ultracode-guard.test.sh diff --git a/AGENTS.md b/AGENTS.md index 9db552f4e..8a6e54c52 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -97,6 +97,7 @@ state/ volatile runtime signals; gitignored .grok-turnend-token firstmate-owned grok hook registry token for the task; removed by teardown .meta written by fm-spawn: window=, worktree=, project=, harness=, model=, effort=, kind=, mode=, yolo=, tasktmp=; a ship/scout task also records compose_project=, a Docker-Compose-safe project name derived from the worktree's pool-slot path (docs/configuration.md "Docker Compose project isolation"), while kind=secondmate omits it and instead records home= and projects=; a non-default runtime backend records further backend-specific fields (docs/configuration.md "Runtime backend"; bin/fm-backend.sh, section 8); fm-pr-check, including through fm-pr-merge, appends pr= and GitHub's pr_head= when available; fm-x-link appends x_request=, x_request_ts=, x_followups=, and optional x_platform=/x_reply_max_chars= for an X-mode-originated task (section 14) .check.sh optional slow poll you write per task (e.g. merged-PR check) + .ultracode present only when the dispatched profile set ultracode=true; role=, plus reviewed_by= once bin/fm-ultracode-guard.sh confirms an independent second pass ran (section 4) x-watch.check.sh generated X-mode relay poll shim; present only when opted in (section 14) x-inbox/ generated X-mode pending mention payloads; fmx-respond drains it (section 14) x-context/ generated X-mode durable per-request reply context (platform/budget), keyed by request_id; survives inbox cleanup so a delayed follow-up recovers the original platform (section 14; bin/fm-x-lib.sh) @@ -190,7 +191,8 @@ Pick the single best-fit rule using your own judgment. This is explicitly not first-match: weigh all rules, their `when` text, and their `why` rationales against the actual task. For a chosen rule with a single-object `use`, or an array `use` with no `select`, resolve the first profile directly. For a chosen rule with `select: "quota-balanced"`, pipe the full rule JSON to `bin/fm-dispatch-select.sh` and use the compact JSON profile it prints. -Extract that chosen concrete profile `(harness, model, effort)` and pass it to `bin/fm-spawn.sh` with explicit `--harness`, `--model`, and `--effort` flags for the axes that are set. +Extract that chosen concrete profile `(harness, model, effort, ultracode?)` and pass the first three to `bin/fm-spawn.sh` with explicit `--harness`, `--model`, and `--effort` flags for the axes that are set. +When the resolved profile also sets `ultracode`, run `bin/fm-ultracode-guard.sh flag ` right after spawn, so the independent-review requirement is tracked mechanically instead of resting on memory. If no rule fits, use `default`. If `default` is absent, fall back to `config/crew-harness` through `bin/fm-harness.sh crew`, exactly as the static path did before dispatch profiles, but still pass that resolved harness explicitly. This is enforced: when `config/crew-dispatch.json` exists, `bin/fm-spawn.sh` refuses crewmate and scout launches that do not include an explicit harness (`--harness `, a positional adapter name, or a raw launch command). @@ -198,6 +200,12 @@ That refusal is the consultation backstop, so the rules are never silently skipp The requirement is gated only on the file's presence; when the file is absent, `fm-spawn.sh` keeps resolving the crewmate harness from `config/crew-harness` as before. Secondmate launches are exempt because they resolve through `fm-harness.sh secondmate`, not the crewmate dispatch-profile rules. +**Risk floor (mechanical, applies after any rule match).** +Before passing the resolved profile to `fm-spawn.sh`, and again once the task has a brief or a diff, run `bin/fm-risk-tripwire.sh `. +A hit floors the model/effort to the safety-critical profile (`opus`/`xhigh`, ultracode `independent-review`) regardless of which rule the natural-language match picked - a second, structurally different check from that judgment call, so a misclassified risky task cannot slip through on a single reasoning pass. +On a hit, also run `bin/fm-ultracode-guard.sh flag independent-review` so the independent-review requirement is tracked mechanically even when the matched rule did not set ultracode. +Re-run it at Validate time against the actual diff; a hit discovered only then still floors the task in place and is never silently downgraded afterward. + `quota-balanced` selection is deterministic and owned by `bin/fm-dispatch-select.sh`; its header documents the general-window rules, freshness margin, and every fallback, and it degrades to the first array element whenever quota data is unusable. Quota trouble must never block dispatch. @@ -213,6 +221,10 @@ Validate every selected harness name against the verified adapter list above. If a dispatch rule or default names an unverified harness, ignore that profile, fall back to the next valid source, and note the problem when it affects the dispatch. The shell scripts never parse or match the natural-language rules; firstmate does the matching and passes only concrete flags to `fm-spawn`. +The same discipline applies to a rule's `model`, not just its harness: `fm-spawn.sh` passes any `--model` string through unvalidated. +Before a dispatch rule may name a specific non-default model, such as `opus` or `haiku`, verify that model end-to-end on a trivial supervised task, including any advisor or sub-agent tool path its harness exposes, exactly as this section already requires before naming a new harness adapter. +Do not copy a rule naming an unverified model into a live `config/crew-dispatch.json` until that verification has been done on this fleet. + Per-harness model/effort flags: `harness-adapters` (loaded before every spawn per section 4's closing trigger). Secondmates can run on a different harness than crewmates. @@ -495,6 +507,15 @@ During the `ci` monitor phase, `bin/fm-crew-state.sh` also reads the ci step log - Red flag - self-fix duplication: a validating crewmate making fresh hand-commits, aborting the run, or re-running it mid-validation is re-doing work the pipeline already owns. Steer it back to no-mistakes' respond flow; the pipeline, not the crewmate, applies validation fixes. +**Escalation triggers (mechanical, never rest on self-report alone).** +For a task assigned the trivial tier (Haiku/low), run `bin/fm-tier-guard.sh ` during Validate, or whenever a heartbeat review touches it, to check whether its diff or elapsed time has outgrown that tier's envelope; any tier also escalates once its diff crosses the script's general heavy-scale ceiling. +A report escalates the task to at least Sonnet/high in place, without losing its branch or context, mirroring `bin/fm-promote.sh`'s in-place promotion. +A crewmate's own report - it cannot find root cause, a "confirmed" fix touches a shared path, or it raised a tradeoff buried in seemingly mechanical work - is a second, non-mechanical trigger with the same effect. +Either way, escalate the model/effort in place and never silently de-escalate for the rest of that task's life. + +**Ultracode confirmation.** +Before advancing an ultracode-flagged task to PR-ready, run `bin/fm-ultracode-guard.sh check `; it refuses until a genuinely separate task - dispatched independently, never a sub-task the flagged crewmate spawned itself - has reviewed the finished diff and its findings were addressed, recorded with `bin/fm-ultracode-guard.sh reviewed `. + ### PR ready For PR-based ship tasks, the ready signal depends on mode: `no-mistakes` reports `done: PR checks green` after CI is green, while `direct-PR` reports `done: PR ` after opening the PR. diff --git a/bin/fm-risk-tripwire.sh b/bin/fm-risk-tripwire.sh new file mode 100755 index 000000000..8883617f9 --- /dev/null +++ b/bin/fm-risk-tripwire.sh @@ -0,0 +1,121 @@ +#!/usr/bin/env bash +# Mechanical risk trip-wire (guardrail #2): greps a task's brief text and, once +# code exists, its changed file paths for migration/auth/schema/security +# signals - a second, structurally different check from the natural-language +# "when" match a crew-dispatch rule already made, so a misclassified risky task +# cannot slip to a cheap model/effort tier on a single judgment call +# (data/research-resource-tiering-synthesis.md). +# +# Usage: fm-risk-tripwire.sh +# +# Checks whatever is available for : +# - data//brief.md, if it exists (works before spawn, right after +# bin/fm-brief.sh scaffolds it - the first checkpoint) +# - the worktree's changed file paths vs its project's default branch, if +# state/.meta records worktree=/project= (works after spawn, at +# Validate time - the second, binding checkpoint against the real diff) +# Errors (exit 2) if neither is available; that means there is nothing to +# check yet. +# +# Prints one "RISK: " line per hit and exits 1 if any fired; exits 0 +# (silent) when neither surface matched. A hit means: floor this task's +# model/effort to the safety-critical profile (opus/xhigh, ultracode +# independent-review) regardless of which rule the natural-language dispatch +# match picked, per AGENTS.md section 4's risk floor. +# +# This is a coarse, unpushed-diff-vs-default-branch name-only comparison, not +# the PR-aware exact diff bin/fm-review-diff.sh computes - good enough for a +# keyword scan, not a substitute for that script's authoritative base. +set -eu + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" +FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" +STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" +DATA="${FM_DATA_OVERRIDE:-$FM_HOME/data}" + +# shellcheck source=bin/fm-tangle-lib.sh +. "$SCRIPT_DIR/fm-tangle-lib.sh" + +usage() { + echo "usage: fm-risk-tripwire.sh " >&2 +} + +if [ "${1:-}" = "-h" ] || [ "${1:-}" = "--help" ]; then + usage + exit 0 +fi + +ID=${1:-} +[ -n "$ID" ] || { usage; exit 1; } +[ $# -le 1 ] || { usage; exit 1; } + +# Case-insensitive substring match against the brief's prose. Permissive by +# design (a false positive just means "double-check"; a false negative is the +# dangerous direction for a safety floor). +TEXT_KEYWORDS='auth|authentication|authorization|session|credential|password|secret|token|payment|billing|migration|schema|security|encrypt|decrypt|permission|access.control|data.deletion|bulk.mutation|public.exposure|breaking.change' + +# Substring match against one changed file's path (already lowercased by the caller). +path_is_risky() { + case "$1" in + .github/workflows/*|*/.github/workflows/*) return 0 ;; + bin/*|*/bin/*) return 0 ;; + *dockerfile*|*docker-compose*) return 0 ;; + *auth*|*migrat*|*schema*|*secret*|*credential*|*payment*|*billing*|*security*|*session*) return 0 ;; + esac + return 1 +} + +FOUND=0 +CHECKED=0 + +BRIEF="$DATA/$ID/brief.md" +if [ -f "$BRIEF" ]; then + CHECKED=1 + hit=$(grep -Eoi "$TEXT_KEYWORDS" "$BRIEF" | tr '[:upper:]' '[:lower:]' | sort -u | tr '\n' ',' | sed 's/,$//') + if [ -n "$hit" ]; then + echo "RISK: brief for $ID mentions risk-adjacent term(s): $hit" + FOUND=1 + fi +fi + +META="$STATE/$ID.meta" +if [ -f "$META" ]; then + WT=$(grep '^worktree=' "$META" | tail -1 | cut -d= -f2- || true) + PROJ=$(grep '^project=' "$META" | tail -1 | cut -d= -f2- || true) + if [ -n "$WT" ] && [ -n "$PROJ" ] && [ -d "$WT" ] && [ -d "$PROJ" ]; then + CHECKED=1 + DEFAULT=$(fm_default_branch "$PROJ" 2>/dev/null || true) + if [ -n "$DEFAULT" ]; then + BASE="$DEFAULT" + if git -C "$PROJ" remote get-url origin >/dev/null 2>&1; then + git -C "$WT" fetch origin "+refs/heads/$DEFAULT:refs/remotes/origin/$DEFAULT" --quiet 2>/dev/null || true + git -C "$WT" rev-parse --verify --quiet "refs/remotes/origin/$DEFAULT^{commit}" >/dev/null 2>&1 && BASE="origin/$DEFAULT" + fi + if git -C "$WT" rev-parse --verify --quiet "$BASE^{commit}" >/dev/null 2>&1; then + diff_paths=$(git -C "$WT" diff --name-only "$BASE...HEAD" -- 2>/dev/null || true) + risky_paths= + if [ -n "$diff_paths" ]; then + while IFS= read -r path; do + [ -n "$path" ] || continue + lower_path=$(printf '%s' "$path" | tr '[:upper:]' '[:lower:]') + if path_is_risky "$lower_path"; then + risky_paths="${risky_paths}${risky_paths:+, }$path" + fi + done <<< "$diff_paths" + fi + if [ -n "$risky_paths" ]; then + echo "RISK: diff for $ID touches risk-adjacent path(s): $risky_paths" + FOUND=1 + fi + fi + fi + fi +fi + +if [ "$CHECKED" -eq 0 ]; then + echo "error: neither $BRIEF nor a usable worktree/project in $META was found for $ID; nothing to check" >&2 + exit 2 +fi + +exit "$FOUND" diff --git a/bin/fm-tier-guard.sh b/bin/fm-tier-guard.sh new file mode 100755 index 000000000..be7effca2 --- /dev/null +++ b/bin/fm-tier-guard.sh @@ -0,0 +1,121 @@ +#!/usr/bin/env bash +# Escalation-trigger guard for the trivial (Haiku/low) dispatch tier: mechanically +# checks whether a task's actual diff size or elapsed time has outgrown the +# resource envelope its assigned model/effort tier implies, so under-resourcing +# is never caught by the crewmate's own self-report alone (guardrail #1, +# data/research-resource-tiering-synthesis.md). Read-only: never edits meta, +# the worktree, or the branch. +# +# Usage: fm-tier-guard.sh +# +# Reads state/.meta for model=/effort=, and that file's own mtime as a +# spawn-time proxy. Reuses bin/fm-review-diff.sh --stat for the actual diff size +# instead of re-deriving the authoritative base/PR-head resolution here. +# +# Prints one "ESCALATE: " line per triggered condition and exits 1 if +# any fired; exits 0 (silent) when the task is within its tier's envelope. +# Only the trivial (Haiku/low) tier has a size/age ceiling checked here; every +# other tier is covered only by the general, tier-independent ceiling below. +# On an escalation, bump the task's model/effort in place per AGENTS.md +# section 7's Validate step; never silently de-escalate afterward. +# +# Env overrides (starting points from +# data/scout-nomistakes-tiering-t8/report.md section 3, not yet calibrated +# against this fleet's own diff-size history): +# FM_TIER_TRIVIAL_MAX_LINES default 30 +# FM_TIER_TRIVIAL_MAX_FILES default 2 +# FM_TIER_TRIVIAL_MAX_AGE_SECONDS default 1800 (30 minutes) +# FM_TIER_HEAVY_MIN_LINES default 400 +# FM_TIER_HEAVY_MIN_FILES default 8 +set -eu + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" +FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" +STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" + +TRIVIAL_MAX_LINES=${FM_TIER_TRIVIAL_MAX_LINES:-30} +TRIVIAL_MAX_FILES=${FM_TIER_TRIVIAL_MAX_FILES:-2} +TRIVIAL_MAX_AGE=${FM_TIER_TRIVIAL_MAX_AGE_SECONDS:-1800} +HEAVY_MIN_LINES=${FM_TIER_HEAVY_MIN_LINES:-400} +HEAVY_MIN_FILES=${FM_TIER_HEAVY_MIN_FILES:-8} + +usage() { + echo "usage: fm-tier-guard.sh " >&2 +} + +if [ "${1:-}" = "-h" ] || [ "${1:-}" = "--help" ]; then + usage + exit 0 +fi + +ID=${1:-} +[ -n "$ID" ] || { usage; exit 1; } +[ $# -le 1 ] || { usage; exit 1; } + +META="$STATE/$ID.meta" +[ -f "$META" ] || { echo "error: no meta for task $ID at $META" >&2; exit 1; } + +MODEL=$(grep '^model=' "$META" | tail -1 | cut -d= -f2- || true) +EFFORT=$(grep '^effort=' "$META" | tail -1 | cut -d= -f2- || true) + +# Portable mtime; Linux stat lacks -f, macOS stat lacks -c (mirrors +# bin/fm-supervision-lib.sh's fm_sup_stat_mtime; not sourced from there to keep +# this script's only dependency on that library's actual contract-bearing +# function, fm_supervision_status, out of scope here). +tier_guard_stat_mtime() { + if [ "$(uname)" = Darwin ]; then + stat -f %m "$1" 2>/dev/null + else + stat -c %Y "$1" 2>/dev/null + fi +} + +is_trivial_tier() { + case "$MODEL" in + *haiku*) [ "$EFFORT" = low ] && return 0 ;; + esac + return 1 +} + +STAT_OUT=$("$SCRIPT_DIR/fm-review-diff.sh" "$ID" --stat 2>/dev/null) || { + echo "error: fm-review-diff.sh failed for $ID" >&2 + exit 1 +} + +FILES=0 +LINES=0 +if ! printf '%s\n' "$STAT_OUT" | grep -q '^no changes vs'; then + FILES=$(printf '%s\n' "$STAT_OUT" | grep -oE '[0-9]+ files? changed' | grep -oE '^[0-9]+' || true) + INS=$(printf '%s\n' "$STAT_OUT" | grep -oE '[0-9]+ insertions?\(\+\)' | grep -oE '^[0-9]+' || true) + DEL=$(printf '%s\n' "$STAT_OUT" | grep -oE '[0-9]+ deletions?\(-\)' | grep -oE '^[0-9]+' || true) + FILES=${FILES:-0} + INS=${INS:-0} + DEL=${DEL:-0} + LINES=$((INS + DEL)) +fi + +FOUND=0 + +if is_trivial_tier; then + if [ "$FILES" -gt "$TRIVIAL_MAX_FILES" ] || [ "$LINES" -gt "$TRIVIAL_MAX_LINES" ]; then + echo "ESCALATE: trivial (haiku/low) task $ID has grown to $FILES files / $LINES changed lines, past the trivial envelope ($TRIVIAL_MAX_FILES files / $TRIVIAL_MAX_LINES lines) - bump to at least sonnet/high" + FOUND=1 + fi + AGE_SRC=$(tier_guard_stat_mtime "$META" || true) + if [ -n "$AGE_SRC" ]; then + NOW=$(date +%s) + AGE=$((NOW - AGE_SRC)) + if [ "$AGE" -gt "$TRIVIAL_MAX_AGE" ]; then + echo "ESCALATE: trivial (haiku/low) task $ID has run ${AGE}s, past the trivial tier's ${TRIVIAL_MAX_AGE}s ceiling - bump to at least sonnet/high" + FOUND=1 + fi + fi +fi + +if [ "$FILES" -gt "$HEAVY_MIN_FILES" ] || [ "$LINES" -gt "$HEAVY_MIN_LINES" ]; then + echo "ESCALATE: task $ID's diff ($FILES files / $LINES changed lines) exceeds the heavy-scale ceiling ($HEAVY_MIN_FILES files / $HEAVY_MIN_LINES lines) regardless of its assigned tier - re-check whether it still matches its dispatched rule" + FOUND=1 +fi + +exit "$FOUND" diff --git a/bin/fm-ultracode-guard.sh b/bin/fm-ultracode-guard.sh new file mode 100755 index 000000000..099ae8edc --- /dev/null +++ b/bin/fm-ultracode-guard.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash +# Ultracode enforcement (guardrail #3): mechanically confirms a genuinely +# independent second pass ran on an ultracode-flagged task's finished diff +# before it can go PR-ready - not a sub-task the same crewmate spawned itself +# (data/research-resource-tiering-synthesis.md). Tracks state via a plain +# marker file, state/.ultracode, the same convention as other +# firstmate state/ markers (state/.afk, state/.turn-ended); it never +# touches fm-spawn.sh or the task's own meta. +# +# Usage: +# fm-ultracode-guard.sh flag [] +# Records that was dispatched under a crew-dispatch rule whose +# resolved profile set ultracode=true. defaults to +# "independent-review" (the only role this fleet's rules use today; see +# docs/examples/crew-dispatch.json). Firstmate runs this right after +# spawning the task. +# fm-ultracode-guard.sh reviewed +# Records that - a distinct, separately dispatched task +# (its own state/.meta must exist) - independently +# reviewed 's finished diff and its findings were addressed. +# Refuses if equals or has no recorded meta, +# so a sub-task the same crewmate spawned itself cannot satisfy this. +# fm-ultracode-guard.sh check +# Exits 0 if was never flagged, or was flagged and reviewed. +# Exits 1 with an explanatory message if flagged but not yet reviewed - +# firstmate runs this before treating an ultracode-flagged task as +# PR-ready (AGENTS.md section 7's Validate step). +set -eu + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" +FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" +STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" + +usage() { + echo "usage: fm-ultracode-guard.sh flag []" >&2 + echo " fm-ultracode-guard.sh reviewed " >&2 + echo " fm-ultracode-guard.sh check " >&2 +} + +if [ "${1:-}" = "-h" ] || [ "${1:-}" = "--help" ]; then + usage + exit 0 +fi + +CMD=${1:-} +ID=${2:-} +[ -n "$CMD" ] && [ -n "$ID" ] || { usage; exit 1; } + +MARKER="$STATE/$ID.ultracode" + +cmd_flag() { + local role=${3:-independent-review} + [ $# -le 3 ] || { usage; exit 1; } + mkdir -p "$STATE" + # Overwrites any existing marker: re-flagging (e.g. after an escalation) + # deliberately clears a prior reviewed_by=, since that review was against an + # earlier version of the diff and the requirement starts over. + { + echo "role=$role" + } > "$MARKER" + echo "flagged $ID ultracode role=$role" +} + +cmd_reviewed() { + local reviewer=${3:-} + [ $# -eq 3 ] || { usage; exit 1; } + [ -n "$reviewer" ] || { echo "error: reviewed requires a reviewer-task-id" >&2; exit 1; } + [ -f "$MARKER" ] || { echo "error: $ID is not ultracode-flagged (no $MARKER); nothing to mark reviewed" >&2; exit 1; } + if [ "$reviewer" = "$ID" ]; then + echo "error: reviewer-task-id must be a task distinct from $ID - a task cannot independently review itself" >&2 + exit 1 + fi + if [ ! -f "$STATE/$reviewer.meta" ]; then + echo "error: $reviewer has no recorded state/$reviewer.meta - it must be a genuinely, separately dispatched task, not a made-up id or a sub-task $ID spawned itself" >&2 + exit 1 + fi + grep -qx "reviewed_by=$reviewer" "$MARKER" 2>/dev/null || echo "reviewed_by=$reviewer" >> "$MARKER" + echo "recorded $reviewer as the independent review of $ID" +} + +cmd_check() { + [ $# -eq 2 ] || { usage; exit 1; } + if [ ! -f "$MARKER" ]; then + exit 0 + fi + if grep -q '^reviewed_by=' "$MARKER" 2>/dev/null; then + exit 0 + fi + role=$(grep '^role=' "$MARKER" | tail -1 | cut -d= -f2- || true) + echo "error: $ID is ultracode-flagged (role=${role:-independent-review}) but has no recorded independent review yet - dispatch a genuinely separate task to review the finished diff, then run: fm-ultracode-guard.sh reviewed $ID " >&2 + exit 1 +} + +case "$CMD" in + flag) cmd_flag "$@" ;; + reviewed) cmd_reviewed "$@" ;; + check) cmd_check "$@" ;; + *) usage; exit 1 ;; +esac diff --git a/docs/configuration.md b/docs/configuration.md index 0d45d09d6..411c2f9c3 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -201,7 +201,7 @@ This section is the single owner of the canonical schema and its per-field seman { "when": "", "use": [ - { "harness": "", "model": "", "effort": "" } + { "harness": "", "model": "", "effort": "", "ultracode": "", "ultracode_role": "" } ], "select": "", "why": "" @@ -214,6 +214,9 @@ This section is the single owner of the canonical schema and its per-field seman Per rule, `when` and `use` are required. `use` may be a single profile object or an ordered array of profile objects; the single-object form stays fully backward-compatible, and every profile needs `harness`. `use.model`, `use.effort`, and `why` are optional. +`use.ultracode` and `use.ultracode_role` are optional. +A profile with `ultracode: true` means a task matched to this rule must get a genuinely independent second pass on its finished diff before PR-ready, launched as its own separately dispatched task, never a sub-task the implementing crewmate spawns itself; `ultracode_role` is a short label for what that pass should do (`independent-review` is the only role this fleet's own rules use today). +`bin/fm-ultracode-guard.sh` tracks this mechanically; `AGENTS.md` section 4's dispatch procedure covers when firstmate flags it, and section 7's Validate step covers the PR-ready gate it backs. `select` is optional and currently supports `quota-balanced`. Absent `select` means use the first array element, or the only object in the single-object form; the first array element is the deterministic tie-break and the ultimate fallback. `default` is optional. diff --git a/docs/examples/crew-dispatch.json b/docs/examples/crew-dispatch.json index 8aec19650..8f27118e6 100644 --- a/docs/examples/crew-dispatch.json +++ b/docs/examples/crew-dispatch.json @@ -18,6 +18,21 @@ ], "select": "quota-balanced", "why": "Use a strong coding profile for broad design and implementation work." + }, + { + "when": "The task changes this firstmate repo's own supervision backbone: bin/fm-watch.sh, bin/fm-guard.sh, a harness turnend-guard hook, or other logic the always-on watcher/guard rely on for correctness.", + "use": { "harness": "claude", "model": "sonnet", "effort": "xhigh" }, + "why": "A narrow set of scripts backs every other task's supervision; a subtle regression here is silent and fleet-wide, so it earns deeper reasoning than the general firstmate-repo default even though the model tier does not need to change." + }, + { + "when": "Safety-critical, security-sensitive, or hard-to-reverse work: schema or data migrations, auth/session/credential changes, payment or billing logic, or anything touching a frozen or safety-critical spec.", + "use": { "harness": "claude", "model": "opus", "effort": "xhigh", "ultracode": true, "ultracode_role": "independent-review" }, + "why": "A model reviewing its own irreversible change has anchoring bias; an independent pass and the strongest available model are both cheap relative to the cost of a bad migration or a broken safety-flow." + }, + { + "when": "A genuine architectural or product tradeoff with long-term consequences that the crewmate must reason through and justify, not just implement.", + "use": { "harness": "claude", "model": "opus", "effort": "xhigh" }, + "why": "The deliverable's entire value is the quality of the argued judgment call; this is low-volume, high-stakes work, so the cost delta of the top model is bounded and well spent." } ], "default": { "harness": "codex", "model": "gpt-5.5", "effort": "medium" } diff --git a/tests/fm-risk-tripwire.test.sh b/tests/fm-risk-tripwire.test.sh new file mode 100644 index 000000000..fc4999fdc --- /dev/null +++ b/tests/fm-risk-tripwire.test.sh @@ -0,0 +1,146 @@ +#!/usr/bin/env bash +# Tests for bin/fm-risk-tripwire.sh: a brief mentioning a risk-adjacent term, +# or a diff touching a risk-adjacent path, must trip the wire regardless of +# how the task's dispatch rule classified it - the mechanical, structurally +# different check behind AGENTS.md section 4's risk floor. +set -u + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" +fm_git_identity fmtest fmtest@example.invalid + +TRIPWIRE="$ROOT/bin/fm-risk-tripwire.sh" +TMP_ROOT=$(fm_test_tmproot fm-risk-tripwire-tests) + +make_case() { + local name=$1 case_dir + case_dir="$TMP_ROOT/$name" + mkdir -p "$case_dir/state" "$case_dir/data/task-x1" + + git init -q --bare "$case_dir/origin.git" + git -C "$case_dir/origin.git" symbolic-ref HEAD refs/heads/main + git clone -q "$case_dir/origin.git" "$case_dir/_seed" 2>/dev/null + printf 'base\n' > "$case_dir/_seed/feature.txt" + git -C "$case_dir/_seed" add feature.txt + git -C "$case_dir/_seed" -c user.email=t@t -c user.name=t commit -qm "origin baseline" + git -C "$case_dir/_seed" push -q origin main + rm -rf "$case_dir/_seed" + + git clone -q "$case_dir/origin.git" "$case_dir/project" + git -C "$case_dir/project" remote set-head origin main 2>/dev/null || true + git -C "$case_dir/project" worktree add -q -b "fm/$name" "$case_dir/wt" main + + printf '%s\n' "$case_dir" +} + +write_task_meta() { + local case_dir=$1 + fm_write_meta "$case_dir/state/task-x1.meta" \ + "window=fm-task-x1" \ + "worktree=$case_dir/wt" \ + "project=$case_dir/project" +} + +run_tripwire() { + local case_dir=$1 + FM_ROOT_OVERRIDE="$ROOT" \ + FM_STATE_OVERRIDE="$case_dir/state" \ + FM_DATA_OVERRIDE="$case_dir/data" \ + "$TRIPWIRE" task-x1 +} + +test_clean_brief_and_diff_passes() { + local case_dir out status + case_dir=$(make_case clean) + printf 'Add a --json flag to the status command.\n' > "$case_dir/data/task-x1/brief.md" + printf 'ordinary change\n' > "$case_dir/wt/feature.txt" + git -C "$case_dir/wt" add feature.txt + git -C "$case_dir/wt" commit -qm "ordinary change" + write_task_meta "$case_dir" + + set +e + out=$(run_tripwire "$case_dir") + status=$? + set -e + + expect_code 0 "$status" "clean: an ordinary brief and diff must not trip the wire" + [ -z "$out" ] || fail "clean: expected no RISK output, got: $out" + pass "fm-risk-tripwire passes a clean brief and diff" +} + +test_brief_keyword_trips_wire() { + local case_dir out status + case_dir=$(make_case brief-keyword) + printf 'Add a data migration for the new billing schema.\n' > "$case_dir/data/task-x1/brief.md" + write_task_meta "$case_dir" + + set +e + out=$(run_tripwire "$case_dir") + status=$? + set -e + + expect_code 1 "$status" "brief-keyword: a risk-worded brief must trip the wire" + assert_contains "$out" "RISK: brief for task-x1 mentions risk-adjacent term(s)" "brief-keyword: should name the brief hit" + assert_contains "$out" "migration" "brief-keyword: should surface the matched term" + pass "fm-risk-tripwire trips on a brief that mentions risk-adjacent terms" +} + +test_diff_path_trips_wire() { + local case_dir out status + case_dir=$(make_case diff-path) + printf 'Fix a typo in the help text.\n' > "$case_dir/data/task-x1/brief.md" + mkdir -p "$case_dir/wt/lib/auth" + printf 'session handling\n' > "$case_dir/wt/lib/auth/session.rb" + git -C "$case_dir/wt" add lib/auth/session.rb + git -C "$case_dir/wt" commit -qm "touch auth session code" + write_task_meta "$case_dir" + + set +e + out=$(run_tripwire "$case_dir") + status=$? + set -e + + expect_code 1 "$status" "diff-path: a diff touching an auth path must trip the wire" + assert_contains "$out" "RISK: diff for task-x1 touches risk-adjacent path(s)" "diff-path: should name the diff hit" + assert_contains "$out" "lib/auth/session.rb" "diff-path: should list the risky path" + pass "fm-risk-tripwire trips on a diff that touches an auth-adjacent path" +} + +test_brief_only_mode_before_worktree_exists() { + local case_dir out status + case_dir="$TMP_ROOT/brief-only" + mkdir -p "$case_dir/state" "$case_dir/data/task-x1" + printf 'Rotate the payment provider credentials.\n' > "$case_dir/data/task-x1/brief.md" + + set +e + out=$(FM_ROOT_OVERRIDE="$ROOT" FM_STATE_OVERRIDE="$case_dir/state" FM_DATA_OVERRIDE="$case_dir/data" "$TRIPWIRE" task-x1) + status=$? + set -e + + expect_code 1 "$status" "brief-only: brief-only checkpoint must work before any meta/worktree exists" + assert_contains "$out" "RISK:" "brief-only: should still trip on the brief text alone" + pass "fm-risk-tripwire checks the brief alone before a task has been spawned" +} + +test_nothing_to_check_errors() { + local case_dir out status + case_dir="$TMP_ROOT/nothing-to-check" + mkdir -p "$case_dir/state" "$case_dir/data" + + set +e + out=$(FM_ROOT_OVERRIDE="$ROOT" FM_STATE_OVERRIDE="$case_dir/state" FM_DATA_OVERRIDE="$case_dir/data" "$TRIPWIRE" task-x1 2>&1) + status=$? + set -e + + expect_code 2 "$status" "nothing-to-check: neither a brief nor meta must error distinctly" + assert_contains "$out" "nothing to check" "nothing-to-check: should explain there was nothing to check" + pass "fm-risk-tripwire errors distinctly when neither a brief nor a usable worktree exists" +} + +test_clean_brief_and_diff_passes +test_brief_keyword_trips_wire +test_diff_path_trips_wire +test_brief_only_mode_before_worktree_exists +test_nothing_to_check_errors + +echo "# all fm-risk-tripwire tests passed" diff --git a/tests/fm-tier-guard.test.sh b/tests/fm-tier-guard.test.sh new file mode 100644 index 000000000..e2000432a --- /dev/null +++ b/tests/fm-tier-guard.test.sh @@ -0,0 +1,192 @@ +#!/usr/bin/env bash +# Tests for bin/fm-tier-guard.sh: the trivial (haiku/low) dispatch tier must +# escalate when its actual diff or elapsed time outgrows its assigned envelope, +# and any tier must escalate once its diff crosses the general heavy-scale +# ceiling, regardless of model/effort. +set -u + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" +fm_git_identity fmtest fmtest@example.invalid + +TIER_GUARD="$ROOT/bin/fm-tier-guard.sh" +TMP_ROOT=$(fm_test_tmproot fm-tier-guard-tests) + +make_case() { + local name=$1 case_dir + case_dir="$TMP_ROOT/$name" + mkdir -p "$case_dir/state" + + git init -q --bare "$case_dir/origin.git" + git -C "$case_dir/origin.git" symbolic-ref HEAD refs/heads/main + git clone -q "$case_dir/origin.git" "$case_dir/_seed" 2>/dev/null + printf 'base\n' > "$case_dir/_seed/feature.txt" + git -C "$case_dir/_seed" add feature.txt + git -C "$case_dir/_seed" -c user.email=t@t -c user.name=t commit -qm "origin baseline" + git -C "$case_dir/_seed" push -q origin main + rm -rf "$case_dir/_seed" + + git clone -q "$case_dir/origin.git" "$case_dir/project" + git -C "$case_dir/project" remote set-head origin main 2>/dev/null || true + git -C "$case_dir/project" worktree add -q -b "fm/$name" "$case_dir/wt" main + + printf '%s\n' "$case_dir" +} + +write_task_meta() { + local case_dir=$1 id=$2 + shift 2 + fm_write_meta "$case_dir/state/$id.meta" \ + "window=fm-$id" \ + "worktree=$case_dir/wt" \ + "project=$case_dir/project" \ + "$@" +} + +run_tier_guard() { + local case_dir=$1 id=$2 + FM_ROOT_OVERRIDE="$ROOT" \ + FM_STATE_OVERRIDE="$case_dir/state" \ + "$TIER_GUARD" "$id" +} + +test_trivial_tier_within_envelope_passes() { + local case_dir out status + case_dir=$(make_case within-envelope) + printf 'base\nsmall change\n' > "$case_dir/wt/feature.txt" + git -C "$case_dir/wt" add feature.txt + git -C "$case_dir/wt" commit -qm "small edit" + write_task_meta "$case_dir" task-x1 "model=claude-haiku-4-5" "effort=low" + + set +e + out=$(run_tier_guard "$case_dir" task-x1) + status=$? + set -e + + expect_code 0 "$status" "within-envelope: trivial task inside its size/age ceiling must pass" + [ -z "$out" ] || fail "within-envelope: expected no ESCALATE output, got: $out" + pass "fm-tier-guard passes a trivial task within its size and age envelope" +} + +test_trivial_tier_exceeds_line_ceiling_escalates() { + local case_dir out status i + case_dir=$(make_case line-ceiling) + : > "$case_dir/wt/feature.txt" + for i in $(seq 1 40); do echo "line $i" >> "$case_dir/wt/feature.txt"; done + git -C "$case_dir/wt" add feature.txt + git -C "$case_dir/wt" commit -qm "big edit" + write_task_meta "$case_dir" task-x1 "model=claude-haiku-4-5" "effort=low" + + set +e + out=$(run_tier_guard "$case_dir" task-x1) + status=$? + set -e + + expect_code 1 "$status" "line-ceiling: trivial task past the line ceiling must escalate" + assert_contains "$out" "ESCALATE: trivial (haiku/low) task task-x1" "line-ceiling: should name the trivial-tier escalation" + assert_contains "$out" "bump to at least sonnet/high" "line-ceiling: should say what to bump to" + pass "fm-tier-guard escalates a trivial task once its diff exceeds the line ceiling" +} + +test_trivial_tier_exceeds_file_ceiling_escalates() { + local case_dir out status + case_dir=$(make_case file-ceiling) + printf 'a\n' > "$case_dir/wt/a.txt" + printf 'b\n' > "$case_dir/wt/b.txt" + printf 'c\n' > "$case_dir/wt/c.txt" + git -C "$case_dir/wt" add a.txt b.txt c.txt + git -C "$case_dir/wt" commit -qm "three new files" + write_task_meta "$case_dir" task-x1 "model=claude-haiku-4-5" "effort=low" + + set +e + out=$(run_tier_guard "$case_dir" task-x1) + status=$? + set -e + + expect_code 1 "$status" "file-ceiling: trivial task past the file ceiling must escalate" + assert_contains "$out" "ESCALATE: trivial (haiku/low) task task-x1" "file-ceiling: should name the trivial-tier escalation" + pass "fm-tier-guard escalates a trivial task once its diff exceeds the file ceiling" +} + +test_trivial_tier_stale_age_escalates() { + local case_dir out status + case_dir=$(make_case stale-age) + printf 'base\nsmall change\n' > "$case_dir/wt/feature.txt" + git -C "$case_dir/wt" add feature.txt + git -C "$case_dir/wt" commit -qm "small edit" + write_task_meta "$case_dir" task-x1 "model=claude-haiku-4-5" "effort=low" + touch -t 202001010000 "$case_dir/state/task-x1.meta" + + set +e + out=$(run_tier_guard "$case_dir" task-x1) + status=$? + set -e + + expect_code 1 "$status" "stale-age: trivial task past the age ceiling must escalate" + assert_contains "$out" "has run" "stale-age: should name the age escalation" + assert_contains "$out" "bump to at least sonnet/high" "stale-age: should say what to bump to" + pass "fm-tier-guard escalates a trivial task that has run past its age ceiling" +} + +test_non_trivial_tier_small_diff_no_ceiling() { + local case_dir out status i + case_dir=$(make_case non-trivial-ok) + : > "$case_dir/wt/feature.txt" + for i in $(seq 1 40); do echo "line $i" >> "$case_dir/wt/feature.txt"; done + git -C "$case_dir/wt" add feature.txt + git -C "$case_dir/wt" commit -qm "ordinary sonnet-tier edit" + write_task_meta "$case_dir" task-x1 "model=claude-sonnet-5" "effort=high" + + set +e + out=$(run_tier_guard "$case_dir" task-x1) + status=$? + set -e + + expect_code 0 "$status" "non-trivial-ok: sonnet/high has no trivial-tier ceiling to trip" + [ -z "$out" ] || fail "non-trivial-ok: expected no ESCALATE output, got: $out" + pass "fm-tier-guard does not apply the trivial-tier ceiling to a non-trivial task" +} + +test_any_tier_past_heavy_scale_escalates() { + local case_dir out status i + case_dir=$(make_case heavy-scale) + : > "$case_dir/wt/feature.txt" + for i in $(seq 1 6); do echo "line $i" >> "$case_dir/wt/feature.txt"; done + git -C "$case_dir/wt" add feature.txt + git -C "$case_dir/wt" commit -qm "sonnet-tier edit that turned out large" + write_task_meta "$case_dir" task-x1 "model=claude-sonnet-5" "effort=high" + + set +e + out=$(FM_ROOT_OVERRIDE="$ROOT" FM_STATE_OVERRIDE="$case_dir/state" \ + FM_TIER_HEAVY_MIN_LINES=5 FM_TIER_HEAVY_MIN_FILES=5 "$TIER_GUARD" task-x1) + status=$? + set -e + + expect_code 1 "$status" "heavy-scale: any tier past the heavy-scale ceiling must escalate" + assert_contains "$out" "exceeds the heavy-scale ceiling" "heavy-scale: should name the general heavy-scale escalation" + pass "fm-tier-guard escalates any tier once its diff crosses the general heavy-scale ceiling" +} + +test_missing_meta_errors() { + local case_dir out status + case_dir=$(make_case missing-meta) + + set +e + out=$(run_tier_guard "$case_dir" task-x1 2>&1) + status=$? + set -e + + expect_code 1 "$status" "missing-meta: no meta file must error, not silently pass" + assert_contains "$out" "no meta for task" "missing-meta: should report the missing meta file" + pass "fm-tier-guard errors when the task has no recorded meta" +} + +test_trivial_tier_within_envelope_passes +test_trivial_tier_exceeds_line_ceiling_escalates +test_trivial_tier_exceeds_file_ceiling_escalates +test_trivial_tier_stale_age_escalates +test_non_trivial_tier_small_diff_no_ceiling +test_any_tier_past_heavy_scale_escalates +test_missing_meta_errors + +echo "# all fm-tier-guard tests passed" diff --git a/tests/fm-ultracode-guard.test.sh b/tests/fm-ultracode-guard.test.sh new file mode 100644 index 000000000..ddb4c5cdb --- /dev/null +++ b/tests/fm-ultracode-guard.test.sh @@ -0,0 +1,168 @@ +#!/usr/bin/env bash +# Tests for bin/fm-ultracode-guard.sh: an ultracode-flagged task must not reach +# PR-ready until a genuinely separate, independently-dispatched task recorded +# itself as having reviewed the finished diff - never a self-reference or a +# made-up id. +set -u + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +GUARD="$ROOT/bin/fm-ultracode-guard.sh" +TMP_ROOT=$(fm_test_tmproot fm-ultracode-guard-tests) + +new_state_dir() { + local name=$1 dir + dir="$TMP_ROOT/$name/state" + mkdir -p "$dir" + printf '%s\n' "$dir" +} + +run_guard() { + local state=$1 + shift + FM_ROOT_OVERRIDE="$ROOT" FM_STATE_OVERRIDE="$state" "$GUARD" "$@" +} + +test_check_passes_when_never_flagged() { + local state status + state=$(new_state_dir never-flagged) + fm_write_meta "$state/task-x1.meta" "worktree=/tmp/x" "project=/tmp/x" + + set +e + run_guard "$state" check task-x1 >/dev/null 2>&1 + status=$? + set -e + + expect_code 0 "$status" "never-flagged: an unflagged task must pass check" + pass "fm-ultracode-guard check passes a task that was never ultracode-flagged" +} + +test_flag_then_check_fails_until_reviewed() { + local state out status + state=$(new_state_dir flag-then-check) + fm_write_meta "$state/task-x1.meta" "worktree=/tmp/x" "project=/tmp/x" + + run_guard "$state" flag task-x1 >/dev/null + + set +e + out=$(run_guard "$state" check task-x1 2>&1) + status=$? + set -e + + expect_code 1 "$status" "flag-then-check: a flagged, unreviewed task must fail check" + assert_contains "$out" "is ultracode-flagged" "flag-then-check: should explain why it is blocked" + assert_contains "$out" "role=independent-review" "flag-then-check: should report the default role" + pass "fm-ultracode-guard check refuses a flagged task with no recorded review" +} + +test_flag_custom_role_reported_in_check() { + local state out + state=$(new_state_dir custom-role) + fm_write_meta "$state/task-x1.meta" "worktree=/tmp/x" "project=/tmp/x" + + run_guard "$state" flag task-x1 breadth-fanout >/dev/null + + set +e + out=$(run_guard "$state" check task-x1 2>&1) + set -e + + assert_contains "$out" "role=breadth-fanout" "custom-role: check should report the custom role" + pass "fm-ultracode-guard flag records a custom role and check reports it" +} + +test_reviewed_by_self_is_refused() { + local state out status + state=$(new_state_dir self-review) + fm_write_meta "$state/task-x1.meta" "worktree=/tmp/x" "project=/tmp/x" + run_guard "$state" flag task-x1 >/dev/null + + set +e + out=$(run_guard "$state" reviewed task-x1 task-x1 2>&1) + status=$? + set -e + + expect_code 1 "$status" "self-review: a task cannot review itself" + assert_contains "$out" "distinct from task-x1" "self-review: should explain the refusal" + pass "fm-ultracode-guard reviewed refuses a task naming itself as its own reviewer" +} + +test_reviewed_by_unknown_task_is_refused() { + local state out status + state=$(new_state_dir unknown-reviewer) + fm_write_meta "$state/task-x1.meta" "worktree=/tmp/x" "project=/tmp/x" + run_guard "$state" flag task-x1 >/dev/null + + set +e + out=$(run_guard "$state" reviewed task-x1 made-up-id 2>&1) + status=$? + set -e + + expect_code 1 "$status" "unknown-reviewer: a made-up reviewer id must be refused" + assert_contains "$out" "no recorded state/made-up-id.meta" "unknown-reviewer: should explain the refusal" + pass "fm-ultracode-guard reviewed refuses a reviewer id with no recorded meta" +} + +test_reviewed_by_distinct_dispatched_task_passes_check() { + local state status + state=$(new_state_dir distinct-reviewer) + fm_write_meta "$state/task-x1.meta" "worktree=/tmp/x" "project=/tmp/x" + fm_write_meta "$state/task-x2.meta" "worktree=/tmp/y" "project=/tmp/y" + run_guard "$state" flag task-x1 >/dev/null + + run_guard "$state" reviewed task-x1 task-x2 >/dev/null + + set +e + run_guard "$state" check task-x1 >/dev/null 2>&1 + status=$? + set -e + + expect_code 0 "$status" "distinct-reviewer: a genuinely separate reviewer must satisfy check" + pass "fm-ultracode-guard check passes once a distinct dispatched task recorded the review" +} + +test_reviewed_without_flag_is_refused() { + local state out status + state=$(new_state_dir no-flag) + fm_write_meta "$state/task-x1.meta" "worktree=/tmp/x" "project=/tmp/x" + fm_write_meta "$state/task-x2.meta" "worktree=/tmp/y" "project=/tmp/y" + + set +e + out=$(run_guard "$state" reviewed task-x1 task-x2 2>&1) + status=$? + set -e + + expect_code 1 "$status" "no-flag: reviewed must refuse a task that was never flagged" + assert_contains "$out" "not ultracode-flagged" "no-flag: should explain the refusal" + pass "fm-ultracode-guard reviewed refuses to mark an unflagged task as reviewed" +} + +test_reflag_clears_prior_review() { + local state out status + state=$(new_state_dir reflag-clears) + fm_write_meta "$state/task-x1.meta" "worktree=/tmp/x" "project=/tmp/x" + fm_write_meta "$state/task-x2.meta" "worktree=/tmp/y" "project=/tmp/y" + run_guard "$state" flag task-x1 >/dev/null + run_guard "$state" reviewed task-x1 task-x2 >/dev/null + run_guard "$state" flag task-x1 >/dev/null + + set +e + out=$(run_guard "$state" check task-x1 2>&1) + status=$? + set -e + + expect_code 1 "$status" "reflag-clears: re-flagging must clear the prior review" + assert_contains "$out" "is ultracode-flagged" "reflag-clears: should be blocked again" + pass "fm-ultracode-guard re-flagging starts the review requirement over" +} + +test_check_passes_when_never_flagged +test_flag_then_check_fails_until_reviewed +test_flag_custom_role_reported_in_check +test_reviewed_by_self_is_refused +test_reviewed_by_unknown_task_is_refused +test_reviewed_by_distinct_dispatched_task_passes_check +test_reviewed_without_flag_is_refused +test_reflag_clears_prior_review + +echo "# all fm-ultracode-guard tests passed" From 299a57557e46f28aad3e8de371b31916097cc0e7 Mon Sep 17 00:00:00 2001 From: Michael Date: Thu, 9 Jul 2026 17:56:23 -0400 Subject: [PATCH 13/46] no-mistakes(review): scope risk-tripwire brief scan, narrow bin path, distinct exit codes --- bin/fm-risk-tripwire.sh | 62 ++++++++++++----- bin/fm-tier-guard.sh | 9 ++- bin/fm-ultracode-guard.sh | 4 +- tests/fm-risk-tripwire.test.sh | 119 +++++++++++++++++++++++++++++++++ 4 files changed, 173 insertions(+), 21 deletions(-) diff --git a/bin/fm-risk-tripwire.sh b/bin/fm-risk-tripwire.sh index 8883617f9..9fe448b26 100755 --- a/bin/fm-risk-tripwire.sh +++ b/bin/fm-risk-tripwire.sh @@ -14,14 +14,16 @@ # - the worktree's changed file paths vs its project's default branch, if # state/.meta records worktree=/project= (works after spawn, at # Validate time - the second, binding checkpoint against the real diff) -# Errors (exit 2) if neither is available; that means there is nothing to -# check yet. -# -# Prints one "RISK: " line per hit and exits 1 if any fired; exits 0 -# (silent) when neither surface matched. A hit means: floor this task's -# model/effort to the safety-critical profile (opus/xhigh, ultracode -# independent-review) regardless of which rule the natural-language dispatch -# match picked, per AGENTS.md section 4's risk floor. +# Exit codes: +# 0 no risk signal found +# 1 a RISK hit fired (one "RISK: " line per hit is printed) +# 2 could not check: a malformed invocation, or neither a brief nor a usable +# worktree/project exists yet (nothing to check) +# Distinct codes matter so a caller branching on $? cannot mistake a malformed +# invocation for a real risk hit. A hit means: floor this task's model/effort to +# the safety-critical profile (opus/xhigh, ultracode independent-review) +# regardless of which rule the natural-language dispatch match picked, per +# AGENTS.md section 4's risk floor. # # This is a coarse, unpushed-diff-vs-default-branch name-only comparison, not # the PR-aware exact diff bin/fm-review-diff.sh computes - good enough for a @@ -47,19 +49,47 @@ if [ "${1:-}" = "-h" ] || [ "${1:-}" = "--help" ]; then fi ID=${1:-} -[ -n "$ID" ] || { usage; exit 1; } -[ $# -le 1 ] || { usage; exit 1; } +[ -n "$ID" ] || { usage; exit 2; } +[ $# -le 1 ] || { usage; exit 2; } -# Case-insensitive substring match against the brief's prose. Permissive by -# design (a false positive just means "double-check"; a false negative is the -# dangerous direction for a safety floor). +# Case-insensitive, word-bounded match (tolerating plural/verb inflections such +# as migration -> migrations and encrypt -> encryption) against the brief's prose. +# Word boundaries stop substring false positives like "auth" inside +# "authoritative"; inflection tolerance keeps the safety bias intact, since a +# missed plural is a false negative and false negatives are the dangerous +# direction for a safety floor. TEXT_KEYWORDS='auth|authentication|authorization|session|credential|password|secret|token|payment|billing|migration|schema|security|encrypt|decrypt|permission|access.control|data.deletion|bulk.mutation|public.exposure|breaking.change' +TEXT_REGEX="\\<(${TEXT_KEYWORDS})(s|es|ed|ing|ion|ions)?\\>" + +# Scan only the task-specific body of the brief (the # Task section up to the +# next top-level heading), never the fixed scaffold boilerplate bin/fm-brief.sh +# writes into every brief - that boilerplate contains benign words like +# "authoritative" and "future session" that would otherwise trip the wire on +# every task. Falls back to the whole brief when there is no # Task section +# (a non-standard or hand-written brief), keeping the permissive safety bias. +brief_task_body() { + local body + body=$(awk ' + /^# Task[[:space:]]*$/ { intask=1; next } + intask && /^# / { intask=0 } + intask { print } + ' "$1") + if [ -n "$body" ]; then + printf '%s\n' "$body" + else + cat "$1" + fi +} -# Substring match against one changed file's path (already lowercased by the caller). +# Substring match against one changed file's path (already lowercased by the +# caller). Deliberately NOT a blanket bin/* match: firstmate's own supervision +# backbone lives under bin/ and is routed by an explicit dispatch rule (see +# docs/examples/crew-dispatch.json), so flooring every bin/ change here would +# override that rule and defeat the finer tiers. A genuinely risky script still +# trips via its name (e.g. bin/run-migration.sh, bin/auth-setup.sh). path_is_risky() { case "$1" in .github/workflows/*|*/.github/workflows/*) return 0 ;; - bin/*|*/bin/*) return 0 ;; *dockerfile*|*docker-compose*) return 0 ;; *auth*|*migrat*|*schema*|*secret*|*credential*|*payment*|*billing*|*security*|*session*) return 0 ;; esac @@ -72,7 +102,7 @@ CHECKED=0 BRIEF="$DATA/$ID/brief.md" if [ -f "$BRIEF" ]; then CHECKED=1 - hit=$(grep -Eoi "$TEXT_KEYWORDS" "$BRIEF" | tr '[:upper:]' '[:lower:]' | sort -u | tr '\n' ',' | sed 's/,$//') + hit=$(brief_task_body "$BRIEF" | grep -Eoi "$TEXT_REGEX" | tr '[:upper:]' '[:lower:]' | sort -u | tr '\n' ',' | sed 's/,$//') if [ -n "$hit" ]; then echo "RISK: brief for $ID mentions risk-adjacent term(s): $hit" FOUND=1 diff --git a/bin/fm-tier-guard.sh b/bin/fm-tier-guard.sh index be7effca2..ada2385db 100755 --- a/bin/fm-tier-guard.sh +++ b/bin/fm-tier-guard.sh @@ -9,8 +9,13 @@ # Usage: fm-tier-guard.sh # # Reads state/.meta for model=/effort=, and that file's own mtime as a -# spawn-time proxy. Reuses bin/fm-review-diff.sh --stat for the actual diff size -# instead of re-deriving the authoritative base/PR-head resolution here. +# best-effort spawn-time proxy for the age check - NOT authoritative. A later +# append to meta (bin/fm-pr-check.sh recording pr=/pr_head=, or fm-x-link +# recording X-mode fields) resets the mtime, so a long-running task can read as +# younger than it is and slip the age ceiling; the diff size/file ceilings are +# the primary signal and are unaffected by that reset. Reuses +# bin/fm-review-diff.sh --stat for the actual diff size instead of re-deriving +# the authoritative base/PR-head resolution here. # # Prints one "ESCALATE: " line per triggered condition and exits 1 if # any fired; exits 0 (silent) when the task is within its tier's envelope. diff --git a/bin/fm-ultracode-guard.sh b/bin/fm-ultracode-guard.sh index 099ae8edc..ed0de277a 100755 --- a/bin/fm-ultracode-guard.sh +++ b/bin/fm-ultracode-guard.sh @@ -56,9 +56,7 @@ cmd_flag() { # Overwrites any existing marker: re-flagging (e.g. after an escalation) # deliberately clears a prior reviewed_by=, since that review was against an # earlier version of the diff and the requirement starts over. - { - echo "role=$role" - } > "$MARKER" + echo "role=$role" > "$MARKER" echo "flagged $ID ultracode role=$role" } diff --git a/tests/fm-risk-tripwire.test.sh b/tests/fm-risk-tripwire.test.sh index fc4999fdc..edfc58140 100644 --- a/tests/fm-risk-tripwire.test.sh +++ b/tests/fm-risk-tripwire.test.sh @@ -49,6 +49,18 @@ run_tripwire() { "$TRIPWIRE" task-x1 } +# Scaffold a real ship brief via bin/fm-brief.sh, then substitute the {TASK} +# placeholder with the given task text - exercising the actual scaffold +# boilerplate rather than a hand-written stand-in. +scaffold_brief() { + local case_dir=$1 task=$2 brief + mkdir -p "$case_dir/state" + FM_ROOT_OVERRIDE="$ROOT" FM_DATA_OVERRIDE="$case_dir/data" FM_STATE_OVERRIDE="$case_dir/state" \ + "$ROOT/bin/fm-brief.sh" task-x1 someproject >/dev/null 2>&1 + brief="$case_dir/data/task-x1/brief.md" + sed "s|{TASK}|$task|" "$brief" > "$brief.tmp" && mv "$brief.tmp" "$brief" +} + test_clean_brief_and_diff_passes() { local case_dir out status case_dir=$(make_case clean) @@ -137,10 +149,117 @@ test_nothing_to_check_errors() { pass "fm-risk-tripwire errors distinctly when neither a brief nor a usable worktree exists" } +test_scaffolded_brief_boilerplate_does_not_trip() { + local case_dir out status + case_dir="$TMP_ROOT/scaffold-clean" + scaffold_brief "$case_dir" "Fix a typo in the CLI help text." + + set +e + out=$(FM_ROOT_OVERRIDE="$ROOT" FM_STATE_OVERRIDE="$case_dir/state" FM_DATA_OVERRIDE="$case_dir/data" "$TRIPWIRE" task-x1 2>&1) + status=$? + set -e + + expect_code 0 "$status" "scaffold-clean: a real scaffolded ship brief with a benign task must not trip" + [ -z "$out" ] || fail "scaffold-clean: expected no RISK output from scaffold boilerplate, got: $out" + pass "fm-risk-tripwire does not trip on fm-brief.sh scaffold boilerplate" +} + +test_scaffolded_brief_risky_task_still_trips() { + local case_dir out status + case_dir="$TMP_ROOT/scaffold-risky" + scaffold_brief "$case_dir" "Rotate the payment credentials and run the schema migration." + + set +e + out=$(FM_ROOT_OVERRIDE="$ROOT" FM_STATE_OVERRIDE="$case_dir/state" FM_DATA_OVERRIDE="$case_dir/data" "$TRIPWIRE" task-x1) + status=$? + set -e + + expect_code 1 "$status" "scaffold-risky: a risk-worded task body inside a real scaffold must still trip" + assert_contains "$out" "RISK: brief for task-x1" "scaffold-risky: should name the brief hit" + pass "fm-risk-tripwire still scans the task body of a scaffolded brief" +} + +test_word_boundary_avoids_substring_false_positive() { + local case_dir out status + case_dir="$TMP_ROOT/word-boundary" + mkdir -p "$case_dir/state" "$case_dir/data/task-x1" + printf '# Task\nMake the config loader the authoritative source of truth.\n\n# Setup\nnothing risky here.\n' \ + > "$case_dir/data/task-x1/brief.md" + + set +e + out=$(FM_ROOT_OVERRIDE="$ROOT" FM_STATE_OVERRIDE="$case_dir/state" FM_DATA_OVERRIDE="$case_dir/data" "$TRIPWIRE" task-x1) + status=$? + set -e + + expect_code 0 "$status" "word-boundary: 'authoritative' must not match the 'auth' keyword" + [ -z "$out" ] || fail "word-boundary: expected no RISK output, got: $out" + pass "fm-risk-tripwire does not treat 'authoritative' as an auth keyword hit" +} + +test_inflected_keyword_still_trips() { + local case_dir out status + case_dir="$TMP_ROOT/inflected" + mkdir -p "$case_dir/state" "$case_dir/data/task-x1" + printf '# Task\nRun the pending database migrations and rotate the tokens.\n\n# Setup\nx\n' \ + > "$case_dir/data/task-x1/brief.md" + + set +e + out=$(FM_ROOT_OVERRIDE="$ROOT" FM_STATE_OVERRIDE="$case_dir/state" FM_DATA_OVERRIDE="$case_dir/data" "$TRIPWIRE" task-x1) + status=$? + set -e + + expect_code 1 "$status" "inflected: a plural risk word must still trip (no false negative)" + assert_contains "$out" "migration" "inflected: should surface the matched migration term" + pass "fm-risk-tripwire still trips on inflected/plural risk words" +} + +test_supervision_bin_path_does_not_trip() { + local case_dir out status + case_dir=$(make_case bin-path) + printf 'Tune the watcher poll cadence.\n' > "$case_dir/data/task-x1/brief.md" + mkdir -p "$case_dir/wt/bin" + printf 'watcher tweak\n' > "$case_dir/wt/bin/fm-watch.sh" + printf 'guard tweak\n' > "$case_dir/wt/bin/fm-guard.sh" + git -C "$case_dir/wt" add bin/fm-watch.sh bin/fm-guard.sh + git -C "$case_dir/wt" commit -qm "tweak supervision backbone" + write_task_meta "$case_dir" + + set +e + out=$(run_tripwire "$case_dir") + status=$? + set -e + + expect_code 0 "$status" "bin-path: a supervision-backbone bin/ change alone must not trip the wire" + [ -z "$out" ] || fail "bin-path: expected no RISK output, got: $out" + pass "fm-risk-tripwire does not trip on a supervision-backbone bin/ path" +} + +test_usage_error_exit_code() { + local status + set +e + FM_ROOT_OVERRIDE="$ROOT" "$TRIPWIRE" >/dev/null 2>&1 + status=$? + set -e + expect_code 2 "$status" "usage-empty-id: a malformed invocation must exit 2, not 1 (the RISK code)" + + set +e + FM_ROOT_OVERRIDE="$ROOT" "$TRIPWIRE" one two >/dev/null 2>&1 + status=$? + set -e + expect_code 2 "$status" "usage-extra-args: extra args must exit 2, not 1 (the RISK code)" + pass "fm-risk-tripwire uses a distinct exit code for malformed invocations" +} + test_clean_brief_and_diff_passes test_brief_keyword_trips_wire test_diff_path_trips_wire test_brief_only_mode_before_worktree_exists test_nothing_to_check_errors +test_scaffolded_brief_boilerplate_does_not_trip +test_scaffolded_brief_risky_task_still_trips +test_word_boundary_avoids_substring_false_positive +test_inflected_keyword_still_trips +test_supervision_bin_path_does_not_trip +test_usage_error_exit_code echo "# all fm-risk-tripwire tests passed" From 92dd7c9a07995292f0148d0d41c470cc9a20beb9 Mon Sep 17 00:00:00 2001 From: Michael Date: Thu, 9 Jul 2026 19:14:11 -0400 Subject: [PATCH 14/46] no-mistakes(review): harden risk-tripwire scans and mechanically wire ultracode floor --- bin/fm-risk-tripwire.sh | 101 +++++++++--- tests/fm-risk-tripwire.test.sh | 270 +++++++++++++++++++++++++++++++++ 2 files changed, 348 insertions(+), 23 deletions(-) diff --git a/bin/fm-risk-tripwire.sh b/bin/fm-risk-tripwire.sh index 9fe448b26..5d0c17d85 100755 --- a/bin/fm-risk-tripwire.sh +++ b/bin/fm-risk-tripwire.sh @@ -52,27 +52,48 @@ ID=${1:-} [ -n "$ID" ] || { usage; exit 2; } [ $# -le 1 ] || { usage; exit 2; } -# Case-insensitive, word-bounded match (tolerating plural/verb inflections such -# as migration -> migrations and encrypt -> encryption) against the brief's prose. -# Word boundaries stop substring false positives like "auth" inside -# "authoritative"; inflection tolerance keeps the safety bias intact, since a -# missed plural is a false negative and false negatives are the dangerous -# direction for a safety floor. -TEXT_KEYWORDS='auth|authentication|authorization|session|credential|password|secret|token|payment|billing|migration|schema|security|encrypt|decrypt|permission|access.control|data.deletion|bulk.mutation|public.exposure|breaking.change' +# Case-insensitive, word-bounded match against the brief's prose, tolerating the +# inflections and affixes real task phrasing uses. Word boundaries stop substring +# false positives like "auth" inside "authoritative", but three things must still +# reach the scan or a genuinely risky task slips to a cheap tier (the dangerous +# direction for a safety floor): +# - verb forms need their own stems: appending the outer (s|ed|ing|ion|...) +# suffix to a bare NOUN literal cannot reach the verb, so "migration" alone +# could only become "migrations", never "migrate"/"migrating"/"migrated", +# and the bare "auth" branch never reaches "authorize"/"authenticate". +# - the un/re/de/pre prefixes ("unauthorized"/"unauthenticated" - the most +# common access-control phrasing) glue a letter onto the stem's front and +# defeat the leading \<, so the auth stems carry an explicit optional prefix. +# - a bare auth\w* stem is still avoided, so "authoritative"/"author" stay shut. +# Snake_case identifiers in the body are split on '_' before matching (the '_' is +# a regex word char, so "run_schema_migration" would otherwise hide its words). +TEXT_KEYWORDS='auth|authentication|authorization|(un|re|de|pre)?authoriz(e|ed|es|ing|ation|ations|er|ers)?|(un|re|de|pre)?authenticat(e|ed|es|ing|ion|ions|or|ors)?|session|credential|password|secret|token|payment|billing|migrat(e|ed|es|ing|ion|ions)|schema|security|encrypt|decrypt|permission|access.control|data.deletion|bulk.mutation|public.exposure|breaking.change' TEXT_REGEX="\\<(${TEXT_KEYWORDS})(s|es|ed|ing|ion|ions)?\\>" # Scan only the task-specific body of the brief (the # Task section up to the -# next top-level heading), never the fixed scaffold boilerplate bin/fm-brief.sh -# writes into every brief - that boilerplate contains benign words like -# "authoritative" and "future session" that would otherwise trip the wire on -# every task. Falls back to the whole brief when there is no # Task section -# (a non-standard or hand-written brief), keeping the permissive safety bias. +# next scaffold section heading), never the fixed scaffold boilerplate +# bin/fm-brief.sh writes into every brief - that boilerplate contains benign +# words like "authoritative" and "future session" that would otherwise trip the +# wire on every task. The section boundary is one of the scaffold's own known +# headings (# Setup/# Rules/# Project memory/# Definition of done), not any +# column-0 "# " line: a shell/code comment embedded in the task body (e.g. +# "# then run the schema migration") must NOT end the scan, or the risk words +# after it are silently dropped - the dangerous direction for a safety floor. +# A boundary heading must also be blank-line-preceded, as every real scaffold +# heading is (bin/fm-brief.sh emits a blank line before each), so a task body +# that itself quotes a bare "# Setup" line inline does not cut the scan short. +# Lines inside a fenced code block are likewise never treated as a boundary. +# Falls back to the whole brief when there is no # Task section (a non-standard +# or hand-written brief), keeping the permissive safety bias. brief_task_body() { local body body=$(awk ' - /^# Task[[:space:]]*$/ { intask=1; next } - intask && /^# / { intask=0 } + { blank = ($0 ~ /^[[:space:]]*$/) } + /^```/ { fence = !fence; prevblank = blank; next } + !fence && /^# Task[[:space:]]*$/ { intask=1; prevblank = blank; next } + intask && !fence && prevblank && /^# (Setup|Rules|Project memory|Definition of done)[[:space:]]*$/ { intask=0 } intask { print } + { prevblank = blank } ' "$1") if [ -n "$body" ]; then printf '%s\n' "$body" @@ -81,18 +102,46 @@ brief_task_body() { fi } -# Substring match against one changed file's path (already lowercased by the -# caller). Deliberately NOT a blanket bin/* match: firstmate's own supervision -# backbone lives under bin/ and is routed by an explicit dispatch rule (see -# docs/examples/crew-dispatch.json), so flooring every bin/ change here would -# override that rule and defeat the finer tiers. A genuinely risky script still -# trips via its name (e.g. bin/run-migration.sh, bin/auth-setup.sh). +# Component/token match against one changed file's path (already lowercased by +# the caller). Deliberately NOT a blanket bin/* match: firstmate's own +# supervision backbone lives under bin/ and is routed by an explicit dispatch +# rule (see docs/examples/crew-dispatch.json), so flooring every bin/ change +# here would override that rule and defeat the finer tiers. +# +# The same word-boundary discipline as the brief-text scan applies here, so a +# risk word must be a real path component or delimited token, not a bare +# substring: "strong" words (auth/migrat/schema/... families) match anywhere +# they appear as a /, -, _, or . delimited token, catching bin/auth-setup.sh, +# bin/run-migration.sh, and db/schema.sql; the weaker "session" matches only as +# a whole path component or filename base (lib/auth/session.rb, +# app/models/session.rb), never as a hyphen fragment - otherwise the +# supervision backbone bin/fm-session-start.sh would over-match. Bare-substring +# false positives like "auth" inside AUTHORS/docs/authors.md no longer trip. +# The flip side is intentional: a risk word glued into a compound component with +# NO delimiter (e.g. "authsetup.rb") is not matched, because catching it would +# require substring matching again and reopen exactly those false positives; the +# brief-text scan and the delimiter tokenization below cover the realistic cases. +PATH_STRONG_REGEX='^(auth|authn|authz|authoriz(e|ed|es|ing|ation|ations|er|ers)?|authenticat(e|ed|es|ing|ion|ions|or|ors)?|migrat(e|ed|es|ing|ion|ions)|schema|schemas|secret|secrets|credential|credentials|payment|payments|billing|security)$' +PATH_WEAK_REGEX='^(session|sessions)$' path_is_risky() { - case "$1" in + local path=$1 comp base + case "$path" in .github/workflows/*|*/.github/workflows/*) return 0 ;; *dockerfile*|*docker-compose*) return 0 ;; - *auth*|*migrat*|*schema*|*secret*|*credential*|*payment*|*billing*|*security*|*session*) return 0 ;; esac + while IFS= read -r comp; do + [ -n "$comp" ] || continue + base=${comp%.*} + # Whole path component or filename base equal to a strong or weak word. + if printf '%s\n%s\n' "$comp" "$base" | grep -Eiq "$PATH_STRONG_REGEX|$PATH_WEAK_REGEX"; then + return 0 + fi + # Strong words also match as a ., -, or _ delimited token inside a compound + # name (e.g. config.schema.json, run-migration.sh), the fail-safe direction. + if printf '%s\n' "$base" | tr '._-' '\n' | grep -Eiq "$PATH_STRONG_REGEX"; then + return 0 + fi + done < <(printf '%s\n' "$path" | tr '/' '\n') return 1 } @@ -102,7 +151,7 @@ CHECKED=0 BRIEF="$DATA/$ID/brief.md" if [ -f "$BRIEF" ]; then CHECKED=1 - hit=$(brief_task_body "$BRIEF" | grep -Eoi "$TEXT_REGEX" | tr '[:upper:]' '[:lower:]' | sort -u | tr '\n' ',' | sed 's/,$//') + hit=$(brief_task_body "$BRIEF" | tr '_' ' ' | grep -Eoi "$TEXT_REGEX" | tr '[:upper:]' '[:lower:]' | sort -u | tr '\n' ',' | sed 's/,$//') if [ -n "$hit" ]; then echo "RISK: brief for $ID mentions risk-adjacent term(s): $hit" FOUND=1 @@ -115,6 +164,12 @@ if [ -f "$META" ]; then PROJ=$(grep '^project=' "$META" | tail -1 | cut -d= -f2- || true) if [ -n "$WT" ] && [ -n "$PROJ" ] && [ -d "$WT" ] && [ -d "$PROJ" ]; then CHECKED=1 + # The base resolution below duplicates bin/fm-review-diff.sh's origin fetch + # and origin/ verification on purpose: this scan needs the changed + # PATH LIST, but fm-review-diff.sh only exposes --stat (a diffstat), not a + # --name-only path list, so there is nothing to reuse for a name scan. + # Adding a --name-only mode to that script is the clean fix and is left as a + # follow-up rather than expanding this task's scope into another owner. DEFAULT=$(fm_default_branch "$PROJ" 2>/dev/null || true) if [ -n "$DEFAULT" ]; then BASE="$DEFAULT" diff --git a/tests/fm-risk-tripwire.test.sh b/tests/fm-risk-tripwire.test.sh index edfc58140..a651fa55d 100644 --- a/tests/fm-risk-tripwire.test.sh +++ b/tests/fm-risk-tripwire.test.sh @@ -250,6 +250,263 @@ test_usage_error_exit_code() { pass "fm-risk-tripwire uses a distinct exit code for malformed invocations" } +test_embedded_comment_task_body_still_scanned() { + local case_dir out status + case_dir="$TMP_ROOT/embedded-comment" + mkdir -p "$case_dir/state" "$case_dir/data/task-x1" + # A column-0 "# " line inside the task body (a shell comment in an example + # command) must NOT end the Task-section scan, or the risk words after it are + # silently dropped - the dangerous direction for a safety floor. + printf '# Task\nImplement the DB runner. Example invocation:\n# then run the schema migration and rotate the tokens\n./run up\n\n# Setup\nnothing risky here.\n' \ + > "$case_dir/data/task-x1/brief.md" + + set +e + out=$(FM_ROOT_OVERRIDE="$ROOT" FM_STATE_OVERRIDE="$case_dir/state" FM_DATA_OVERRIDE="$case_dir/data" "$TRIPWIRE" task-x1) + status=$? + set -e + + expect_code 1 "$status" "embedded-comment: risk text after an embedded '# ' line must still be scanned" + assert_contains "$out" "schema" "embedded-comment: should surface the term on the embedded comment line" + assert_contains "$out" "migration" "embedded-comment: should surface the migration term after the comment line" + pass "fm-risk-tripwire keeps scanning the task body past an embedded '# ' comment line" +} + +test_auth_verbs_trip_wire() { + local verb case_dir out status i=0 + for verb in authorize authorized authorizing authenticate authenticated; do + i=$((i + 1)) + case_dir="$TMP_ROOT/auth-verb-$i" + mkdir -p "$case_dir/state" "$case_dir/data/task-x1" + printf '# Task\nAdd middleware to %s admin requests.\n\n# Setup\nx\n' "$verb" \ + > "$case_dir/data/task-x1/brief.md" + + set +e + out=$(FM_ROOT_OVERRIDE="$ROOT" FM_STATE_OVERRIDE="$case_dir/state" FM_DATA_OVERRIDE="$case_dir/data" "$TRIPWIRE" task-x1) + status=$? + set -e + + expect_code 1 "$status" "auth-verb: '$verb' must trip the wire" + assert_contains "$out" "RISK: brief for task-x1" "auth-verb: '$verb' should name the brief hit" + done + pass "fm-risk-tripwire trips on auth verbs (authorize/authenticate families)" +} + +test_auth_nouns_do_not_false_positive() { + local word case_dir out status i=0 + for word in authoritative author; do + i=$((i + 1)) + case_dir="$TMP_ROOT/auth-noun-$i" + mkdir -p "$case_dir/state" "$case_dir/data/task-x1" + printf '# Task\nMake the loader the %s source of config.\n\n# Setup\nx\n' "$word" \ + > "$case_dir/data/task-x1/brief.md" + + set +e + out=$(FM_ROOT_OVERRIDE="$ROOT" FM_STATE_OVERRIDE="$case_dir/state" FM_DATA_OVERRIDE="$case_dir/data" "$TRIPWIRE" task-x1) + status=$? + set -e + + expect_code 0 "$status" "auth-noun: '$word' must not trip the wire" + [ -z "$out" ] || fail "auth-noun: '$word' expected no RISK output, got: $out" + done + pass "fm-risk-tripwire does not treat 'authoritative'/'author' as auth hits" +} + +test_session_start_bin_path_does_not_trip() { + local case_dir out status + case_dir=$(make_case session-start) + printf 'Tune the digest ordering.\n' > "$case_dir/data/task-x1/brief.md" + mkdir -p "$case_dir/wt/bin" + printf 'digest tweak\n' > "$case_dir/wt/bin/fm-session-start.sh" + git -C "$case_dir/wt" add bin/fm-session-start.sh + git -C "$case_dir/wt" commit -qm "tweak session-start" + write_task_meta "$case_dir" + + set +e + out=$(run_tripwire "$case_dir") + status=$? + set -e + + expect_code 0 "$status" "session-start: 'session' as a hyphen fragment of a supervision script must not trip" + [ -z "$out" ] || fail "session-start: expected no RISK output, got: $out" + pass "fm-risk-tripwire does not trip on bin/fm-session-start.sh" +} + +test_auth_setup_bin_path_trips() { + local case_dir out status + case_dir=$(make_case auth-setup) + printf 'Wire up the setup helper.\n' > "$case_dir/data/task-x1/brief.md" + mkdir -p "$case_dir/wt/bin" + printf 'setup\n' > "$case_dir/wt/bin/auth-setup.sh" + git -C "$case_dir/wt" add bin/auth-setup.sh + git -C "$case_dir/wt" commit -qm "add auth setup" + write_task_meta "$case_dir" + + set +e + out=$(run_tripwire "$case_dir") + status=$? + set -e + + expect_code 1 "$status" "auth-setup: 'auth' as a real hyphen token must still trip under bin/" + assert_contains "$out" "bin/auth-setup.sh" "auth-setup: should list the risky path" + pass "fm-risk-tripwire still trips on bin/auth-setup.sh via its auth token" +} + +test_dot_delimited_strong_token_trips() { + local case_dir out status + case_dir=$(make_case dot-token) + printf 'Update the generated config file.\n' > "$case_dir/data/task-x1/brief.md" + mkdir -p "$case_dir/wt/config" + printf 'x\n' > "$case_dir/wt/config/db.schema.json" + git -C "$case_dir/wt" add config/db.schema.json + git -C "$case_dir/wt" commit -qm "add db schema config" + write_task_meta "$case_dir" + + set +e + out=$(run_tripwire "$case_dir") + status=$? + set -e + + expect_code 1 "$status" "dot-token: 'schema' as an interior dot token must trip" + assert_contains "$out" "config/db.schema.json" "dot-token: should list the risky path" + pass "fm-risk-tripwire trips on a strong risk word as a dot-delimited token" +} + +test_authors_doc_path_does_not_trip() { + local case_dir out status + case_dir=$(make_case authors-doc) + printf 'Add a contributors list.\n' > "$case_dir/data/task-x1/brief.md" + mkdir -p "$case_dir/wt/docs" + printf 'names\n' > "$case_dir/wt/docs/authors.md" + git -C "$case_dir/wt" add docs/authors.md + git -C "$case_dir/wt" commit -qm "add authors doc" + write_task_meta "$case_dir" + + set +e + out=$(run_tripwire "$case_dir") + status=$? + set -e + + expect_code 0 "$status" "authors-doc: 'authors' is not the 'auth' token, must not trip" + [ -z "$out" ] || fail "authors-doc: expected no RISK output, got: $out" + pass "fm-risk-tripwire does not trip on docs/authors.md" +} + +test_migrate_verbs_trip_wire() { + local verb case_dir out status i=0 + for verb in migrate migrating migrated; do + i=$((i + 1)) + case_dir="$TMP_ROOT/migrate-verb-$i" + mkdir -p "$case_dir/state" "$case_dir/data/task-x1" + printf '# Task\n%s the customers table to the new engine.\n\n# Setup\nx\n' "$verb" \ + > "$case_dir/data/task-x1/brief.md" + + set +e + out=$(FM_ROOT_OVERRIDE="$ROOT" FM_STATE_OVERRIDE="$case_dir/state" FM_DATA_OVERRIDE="$case_dir/data" "$TRIPWIRE" task-x1) + status=$? + set -e + + expect_code 1 "$status" "migrate-verb: '$verb' must trip the wire" + assert_contains "$out" "RISK: brief for task-x1" "migrate-verb: '$verb' should name the brief hit" + done + pass "fm-risk-tripwire trips on migrate verb forms (migrate/migrating/migrated)" +} + +test_auth_prefix_forms_trip_wire() { + local word case_dir out status i=0 + for word in unauthorized unauthenticated reauthenticate deauthorize; do + i=$((i + 1)) + case_dir="$TMP_ROOT/auth-prefix-$i" + mkdir -p "$case_dir/state" "$case_dir/data/task-x1" + printf '# Task\nReject %s requests at the gateway.\n\n# Setup\nx\n' "$word" \ + > "$case_dir/data/task-x1/brief.md" + + set +e + out=$(FM_ROOT_OVERRIDE="$ROOT" FM_STATE_OVERRIDE="$case_dir/state" FM_DATA_OVERRIDE="$case_dir/data" "$TRIPWIRE" task-x1) + status=$? + set -e + + expect_code 1 "$status" "auth-prefix: '$word' must trip the wire" + done + pass "fm-risk-tripwire trips on prefixed auth forms (unauthorized/unauthenticated/...)" +} + +test_authenticator_noun_trips_wire() { + local word case_dir out status i=0 + for word in authenticator authenticators; do + i=$((i + 1)) + case_dir="$TMP_ROOT/authenticator-$i" + mkdir -p "$case_dir/state" "$case_dir/data/task-x1" + printf '# Task\nAdd support for hardware %s at login.\n\n# Setup\nx\n' "$word" \ + > "$case_dir/data/task-x1/brief.md" + + set +e + out=$(FM_ROOT_OVERRIDE="$ROOT" FM_STATE_OVERRIDE="$case_dir/state" FM_DATA_OVERRIDE="$case_dir/data" "$TRIPWIRE" task-x1) + status=$? + set -e + + expect_code 1 "$status" "authenticator: '$word' must trip the wire" + done + pass "fm-risk-tripwire trips on authenticator/authenticators nouns" +} + +test_snake_case_risk_word_trips() { + local case_dir out status + case_dir="$TMP_ROOT/snake-case" + mkdir -p "$case_dir/state" "$case_dir/data/task-x1" + printf '# Task\nImplement the runner. Call run_schema_migration_now to apply it.\n\n# Setup\nx\n' \ + > "$case_dir/data/task-x1/brief.md" + + set +e + out=$(FM_ROOT_OVERRIDE="$ROOT" FM_STATE_OVERRIDE="$case_dir/state" FM_DATA_OVERRIDE="$case_dir/data" "$TRIPWIRE" task-x1) + status=$? + set -e + + expect_code 1 "$status" "snake-case: a risk word inside a snake_case identifier must trip" + assert_contains "$out" "schema" "snake-case: should surface the schema token" + assert_contains "$out" "migration" "snake-case: should surface the migration token" + pass "fm-risk-tripwire splits snake_case identifiers so embedded risk words trip" +} + +test_task_body_inline_heading_still_scanned() { + local case_dir out status + case_dir="$TMP_ROOT/inline-heading" + mkdir -p "$case_dir/state" "$case_dir/data/task-x1" + # A bare "# Setup" quoted inline in the task body (not blank-line-preceded, as + # the real scaffold heading always is) must NOT terminate the scan early. + printf '# Task\nDo the thing. Configuration follows:\n# Setup\nRotate the credentials and run the migration.\n\n# Setup\nbenign boilerplate goes here.\n' \ + > "$case_dir/data/task-x1/brief.md" + + set +e + out=$(FM_ROOT_OVERRIDE="$ROOT" FM_STATE_OVERRIDE="$case_dir/state" FM_DATA_OVERRIDE="$case_dir/data" "$TRIPWIRE" task-x1) + status=$? + set -e + + expect_code 1 "$status" "inline-heading: risk text after an inline (non-blank-preceded) '# Setup' must still be scanned" + assert_contains "$out" "credential" "inline-heading: should surface the credentials term" + assert_contains "$out" "migration" "inline-heading: should surface the migration term" + pass "fm-risk-tripwire keeps scanning past an inline heading that is not the scaffold boundary" +} + +test_authorizer_path_trips() { + local case_dir out status + case_dir=$(make_case authorizer-path) + printf 'Refactor the request pipeline.\n' > "$case_dir/data/task-x1/brief.md" + mkdir -p "$case_dir/wt/app/authorizers" + printf 'x\n' > "$case_dir/wt/app/authorizers/user_authorizer.rb" + git -C "$case_dir/wt" add app/authorizers/user_authorizer.rb + git -C "$case_dir/wt" commit -qm "add user authorizer" + write_task_meta "$case_dir" + + set +e + out=$(run_tripwire "$case_dir") + status=$? + set -e + + expect_code 1 "$status" "authorizer-path: an 'authorizer' component must trip (path authoriz stem parity)" + assert_contains "$out" "app/authorizers/user_authorizer.rb" "authorizer-path: should list the risky path" + pass "fm-risk-tripwire trips on authorizer paths" +} + test_clean_brief_and_diff_passes test_brief_keyword_trips_wire test_diff_path_trips_wire @@ -261,5 +518,18 @@ test_word_boundary_avoids_substring_false_positive test_inflected_keyword_still_trips test_supervision_bin_path_does_not_trip test_usage_error_exit_code +test_embedded_comment_task_body_still_scanned +test_auth_verbs_trip_wire +test_auth_nouns_do_not_false_positive +test_session_start_bin_path_does_not_trip +test_auth_setup_bin_path_trips +test_dot_delimited_strong_token_trips +test_authors_doc_path_does_not_trip +test_migrate_verbs_trip_wire +test_auth_prefix_forms_trip_wire +test_authenticator_noun_trips_wire +test_snake_case_risk_word_trips +test_task_body_inline_heading_still_scanned +test_authorizer_path_trips echo "# all fm-risk-tripwire tests passed" From fef34b68016ca65d7f2a87ad2b4479e9c9d516e3 Mon Sep 17 00:00:00 2001 From: Michael Date: Thu, 9 Jul 2026 19:50:32 -0400 Subject: [PATCH 15/46] no-mistakes(review): make risk-tripwire grep portable, give tier-guard distinct exit codes --- bin/fm-risk-tripwire.sh | 33 ++++++++++++----- bin/fm-tier-guard.sh | 16 +++++--- tests/fm-risk-tripwire.test.sh | 67 ++++++++++++++++++++++++++++++++++ tests/fm-tier-guard.test.sh | 21 ++++++++++- 4 files changed, 120 insertions(+), 17 deletions(-) diff --git a/bin/fm-risk-tripwire.sh b/bin/fm-risk-tripwire.sh index 5d0c17d85..3e8f1b585 100755 --- a/bin/fm-risk-tripwire.sh +++ b/bin/fm-risk-tripwire.sh @@ -53,7 +53,7 @@ ID=${1:-} [ $# -le 1 ] || { usage; exit 2; } # Case-insensitive, word-bounded match against the brief's prose, tolerating the -# inflections and affixes real task phrasing uses. Word boundaries stop substring +# inflections and affixes real task phrasing uses. Word bounding stops substring # false positives like "auth" inside "authoritative", but three things must still # reach the scan or a genuinely risky task slips to a cheap tier (the dangerous # direction for a safety floor): @@ -62,13 +62,22 @@ ID=${1:-} # could only become "migrations", never "migrate"/"migrating"/"migrated", # and the bare "auth" branch never reaches "authorize"/"authenticate". # - the un/re/de/pre prefixes ("unauthorized"/"unauthenticated" - the most -# common access-control phrasing) glue a letter onto the stem's front and -# defeat the leading \<, so the auth stems carry an explicit optional prefix. -# - a bare auth\w* stem is still avoided, so "authoritative"/"author" stay shut. -# Snake_case identifiers in the body are split on '_' before matching (the '_' is -# a regex word char, so "run_schema_migration" would otherwise hide its words). -TEXT_KEYWORDS='auth|authentication|authorization|(un|re|de|pre)?authoriz(e|ed|es|ing|ation|ations|er|ers)?|(un|re|de|pre)?authenticat(e|ed|es|ing|ion|ions|or|ors)?|session|credential|password|secret|token|payment|billing|migrat(e|ed|es|ing|ion|ions)|schema|security|encrypt|decrypt|permission|access.control|data.deletion|bulk.mutation|public.exposure|breaking.change' -TEXT_REGEX="\\<(${TEXT_KEYWORDS})(s|es|ed|ing|ion|ions)?\\>" +# common access-control phrasing) glue a letter onto the stem's front, so +# the auth stems carry an explicit optional prefix. +# - a bare "auth"-anything stem is still avoided, so "authoritative"/"author" +# stay shut. +# Word bounding is done by splitting the body into whole tokens (below), NOT by +# grep's \< / \> anchors: those are a GNU extension BSD grep does not honor, so +# on macOS they would silently match nothing and let every risky brief through - +# a false negative in the dangerous direction. Whole-token matching is identical +# on GNU and BSD grep and, unlike a boundary-consuming grep -o pattern, still +# reports BOTH words of an adjacent risk pair like "session token" (a consumed +# shared delimiter would drop the second). Single-word keywords go in WORD_REGEX; +# the multi-word phrases have no lone token and are matched on the +# space-normalized stream via PHRASE_REGEX. +WORD_KEYWORDS='auth|authentication|authorization|(un|re|de|pre)?authoriz(e|ed|es|ing|ation|ations|er|ers)?|(un|re|de|pre)?authenticat(e|ed|es|ing|ion|ions|or|ors)?|session|credential|password|secret|token|payment|billing|migrat(e|ed|es|ing|ion|ions)|schema|security|encrypt|decrypt|permission' +WORD_REGEX="(${WORD_KEYWORDS})(s|es|ed|ing|ion|ions)?" +PHRASE_REGEX='(access[[:space:]]+control|data[[:space:]]+deletion|bulk[[:space:]]+mutation|public[[:space:]]+exposure|breaking[[:space:]]+change)(s|es|ed|ing|ion|ions)?' # Scan only the task-specific body of the brief (the # Task section up to the # next scaffold section heading), never the fixed scaffold boilerplate @@ -151,7 +160,13 @@ CHECKED=0 BRIEF="$DATA/$ID/brief.md" if [ -f "$BRIEF" ]; then CHECKED=1 - hit=$(brief_task_body "$BRIEF" | tr '_' ' ' | grep -Eoi "$TEXT_REGEX" | tr '[:upper:]' '[:lower:]' | sort -u | tr '\n' ',' | sed 's/,$//') + lc=$(brief_task_body "$BRIEF" | tr '[:upper:]' '[:lower:]') + # Split on every non-alnum char so each word is its own token (this also + # splits snake_case identifiers like run_schema_migration), then match whole + # tokens; phrases keep their inter-word gap on the space-normalized stream. + word_hits=$(printf '%s\n' "$lc" | tr -c '[:alnum:]' '\n' | grep -xE "$WORD_REGEX" || true) + phrase_hits=$(printf '%s\n' "$lc" | tr -c '[:alnum:]' ' ' | tr -s ' ' | grep -oE "$PHRASE_REGEX" || true) + hit=$(printf '%s\n%s\n' "$word_hits" "$phrase_hits" | sed '/^$/d' | sort -u | tr '\n' ',' | sed 's/,$//') if [ -n "$hit" ]; then echo "RISK: brief for $ID mentions risk-adjacent term(s): $hit" FOUND=1 diff --git a/bin/fm-tier-guard.sh b/bin/fm-tier-guard.sh index ada2385db..38234c4b0 100755 --- a/bin/fm-tier-guard.sh +++ b/bin/fm-tier-guard.sh @@ -17,8 +17,12 @@ # bin/fm-review-diff.sh --stat for the actual diff size instead of re-deriving # the authoritative base/PR-head resolution here. # -# Prints one "ESCALATE: " line per triggered condition and exits 1 if -# any fired; exits 0 (silent) when the task is within its tier's envelope. +# Prints one "ESCALATE: " line per triggered condition. Exit codes: +# 0 within the tier's envelope (silent) +# 1 at least one escalation fired +# 2 usage or setup error (bad args, missing meta, or fm-review-diff failed) +# Distinct codes let a caller branching on $? tell a real escalation from a +# malformed invocation, matching the sibling guardrail bin/fm-risk-tripwire.sh. # Only the trivial (Haiku/low) tier has a size/age ceiling checked here; every # other tier is covered only by the general, tier-independent ceiling below. # On an escalation, bump the task's model/effort in place per AGENTS.md @@ -55,11 +59,11 @@ if [ "${1:-}" = "-h" ] || [ "${1:-}" = "--help" ]; then fi ID=${1:-} -[ -n "$ID" ] || { usage; exit 1; } -[ $# -le 1 ] || { usage; exit 1; } +[ -n "$ID" ] || { usage; exit 2; } +[ $# -le 1 ] || { usage; exit 2; } META="$STATE/$ID.meta" -[ -f "$META" ] || { echo "error: no meta for task $ID at $META" >&2; exit 1; } +[ -f "$META" ] || { echo "error: no meta for task $ID at $META" >&2; exit 2; } MODEL=$(grep '^model=' "$META" | tail -1 | cut -d= -f2- || true) EFFORT=$(grep '^effort=' "$META" | tail -1 | cut -d= -f2- || true) @@ -85,7 +89,7 @@ is_trivial_tier() { STAT_OUT=$("$SCRIPT_DIR/fm-review-diff.sh" "$ID" --stat 2>/dev/null) || { echo "error: fm-review-diff.sh failed for $ID" >&2 - exit 1 + exit 2 } FILES=0 diff --git a/tests/fm-risk-tripwire.test.sh b/tests/fm-risk-tripwire.test.sh index a651fa55d..b29d0ac2a 100644 --- a/tests/fm-risk-tripwire.test.sh +++ b/tests/fm-risk-tripwire.test.sh @@ -507,7 +507,74 @@ test_authorizer_path_trips() { pass "fm-risk-tripwire trips on authorizer paths" } +test_bare_auth_matches_but_authoritative_does_not() { + # Guards the portable word boundary against silently no-op'ing on BSD grep: a + # no-op that matches nothing would miss the bare 'auth' (part b), and a no-op + # that matches substrings would trip on 'authoritative' (part a). Both asserted. + local case_dir out status + # (a) 'authoritative' alone must not match the 'auth' keyword. + case_dir="$TMP_ROOT/authoritative-only" + mkdir -p "$case_dir/state" "$case_dir/data/task-x1" + printf '# Task\nMake the loader the authoritative config source only.\n\n# Setup\nx\n' \ + > "$case_dir/data/task-x1/brief.md" + set +e + out=$(FM_ROOT_OVERRIDE="$ROOT" FM_STATE_OVERRIDE="$case_dir/state" FM_DATA_OVERRIDE="$case_dir/data" "$TRIPWIRE" task-x1) + status=$? + set -e + expect_code 0 "$status" "authoritative-only: 'authoritative' must not match the auth keyword" + [ -z "$out" ] || fail "authoritative-only: expected no RISK output, got: $out" + + # (b) a bare 'auth' word appearing mid-line (not at the string edges) must trip. + case_dir="$TMP_ROOT/bare-auth-midline" + mkdir -p "$case_dir/state" "$case_dir/data/task-x1" + printf '# Task\nPlease auth the request before the handler runs.\n\n# Setup\nx\n' \ + > "$case_dir/data/task-x1/brief.md" + set +e + out=$(FM_ROOT_OVERRIDE="$ROOT" FM_STATE_OVERRIDE="$case_dir/state" FM_DATA_OVERRIDE="$case_dir/data" "$TRIPWIRE" task-x1) + status=$? + set -e + expect_code 1 "$status" "bare-auth-midline: a standalone mid-line 'auth' must trip the wire" + assert_contains "$out" "auth" "bare-auth-midline: should surface the auth token" + pass "fm-risk-tripwire matches a bare mid-line 'auth' but never 'authoritative'" +} + +test_adjacent_keywords_both_reported() { + # A boundary-consuming grep -o pattern drops the second word of an adjacent + # pair (the shared delimiter is eaten); whole-token matching reports both. + local case_dir out status + case_dir="$TMP_ROOT/adjacent-pair" + mkdir -p "$case_dir/state" "$case_dir/data/task-x1" + printf '# Task\nRotate the session token on every login.\n\n# Setup\nx\n' \ + > "$case_dir/data/task-x1/brief.md" + set +e + out=$(FM_ROOT_OVERRIDE="$ROOT" FM_STATE_OVERRIDE="$case_dir/state" FM_DATA_OVERRIDE="$case_dir/data" "$TRIPWIRE" task-x1) + status=$? + set -e + expect_code 1 "$status" "adjacent-pair: adjacent risk words must trip" + assert_contains "$out" "session" "adjacent-pair: should surface session" + assert_contains "$out" "token" "adjacent-pair: should surface the adjacent token too" + pass "fm-risk-tripwire reports both words of an adjacent risk pair" +} + +test_multiword_phrase_keyword_trips() { + local case_dir out status + case_dir="$TMP_ROOT/phrase-keyword" + mkdir -p "$case_dir/state" "$case_dir/data/task-x1" + printf '# Task\nEnforce access control and handle data deletion on the endpoint.\n\n# Setup\nx\n' \ + > "$case_dir/data/task-x1/brief.md" + set +e + out=$(FM_ROOT_OVERRIDE="$ROOT" FM_STATE_OVERRIDE="$case_dir/state" FM_DATA_OVERRIDE="$case_dir/data" "$TRIPWIRE" task-x1) + status=$? + set -e + expect_code 1 "$status" "phrase-keyword: a multi-word risk phrase must still trip" + assert_contains "$out" "access control" "phrase-keyword: should surface the access control phrase" + pass "fm-risk-tripwire still trips on multi-word risk phrases" +} + test_clean_brief_and_diff_passes +test_bare_auth_matches_but_authoritative_does_not +test_adjacent_keywords_both_reported +test_multiword_phrase_keyword_trips test_brief_keyword_trips_wire test_diff_path_trips_wire test_brief_only_mode_before_worktree_exists diff --git a/tests/fm-tier-guard.test.sh b/tests/fm-tier-guard.test.sh index e2000432a..41650372f 100644 --- a/tests/fm-tier-guard.test.sh +++ b/tests/fm-tier-guard.test.sh @@ -176,9 +176,25 @@ test_missing_meta_errors() { status=$? set -e - expect_code 1 "$status" "missing-meta: no meta file must error, not silently pass" + expect_code 2 "$status" "missing-meta: no meta file must exit 2 (setup error), not 1 (an escalation)" assert_contains "$out" "no meta for task" "missing-meta: should report the missing meta file" - pass "fm-tier-guard errors when the task has no recorded meta" + pass "fm-tier-guard errors distinctly (exit 2) when the task has no recorded meta" +} + +test_usage_error_exit_code() { + local status + set +e + FM_ROOT_OVERRIDE="$ROOT" "$TIER_GUARD" >/dev/null 2>&1 + status=$? + set -e + expect_code 2 "$status" "usage-empty-id: a malformed invocation must exit 2, not 1 (the escalation code)" + + set +e + FM_ROOT_OVERRIDE="$ROOT" "$TIER_GUARD" one two >/dev/null 2>&1 + status=$? + set -e + expect_code 2 "$status" "usage-extra-args: extra args must exit 2, not 1 (the escalation code)" + pass "fm-tier-guard uses a distinct exit code for malformed invocations" } test_trivial_tier_within_envelope_passes @@ -188,5 +204,6 @@ test_trivial_tier_stale_age_escalates test_non_trivial_tier_small_diff_no_ceiling test_any_tier_past_heavy_scale_escalates test_missing_meta_errors +test_usage_error_exit_code echo "# all fm-tier-guard tests passed" From 2bde3fd8661fbf48ce61f1feafb7f3d9f2d24849 Mon Sep 17 00:00:00 2001 From: Michael Date: Thu, 9 Jul 2026 20:10:08 -0400 Subject: [PATCH 16/46] no-mistakes(review): set exec bit on new tests, fix ultracode marker doc --- AGENTS.md | 2 +- tests/fm-risk-tripwire.test.sh | 0 tests/fm-tier-guard.test.sh | 0 tests/fm-ultracode-guard.test.sh | 0 4 files changed, 1 insertion(+), 1 deletion(-) mode change 100644 => 100755 tests/fm-risk-tripwire.test.sh mode change 100644 => 100755 tests/fm-tier-guard.test.sh mode change 100644 => 100755 tests/fm-ultracode-guard.test.sh diff --git a/AGENTS.md b/AGENTS.md index 8a6e54c52..93e7d1ed9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -97,7 +97,7 @@ state/ volatile runtime signals; gitignored .grok-turnend-token firstmate-owned grok hook registry token for the task; removed by teardown .meta written by fm-spawn: window=, worktree=, project=, harness=, model=, effort=, kind=, mode=, yolo=, tasktmp=; a ship/scout task also records compose_project=, a Docker-Compose-safe project name derived from the worktree's pool-slot path (docs/configuration.md "Docker Compose project isolation"), while kind=secondmate omits it and instead records home= and projects=; a non-default runtime backend records further backend-specific fields (docs/configuration.md "Runtime backend"; bin/fm-backend.sh, section 8); fm-pr-check, including through fm-pr-merge, appends pr= and GitHub's pr_head= when available; fm-x-link appends x_request=, x_request_ts=, x_followups=, and optional x_platform=/x_reply_max_chars= for an X-mode-originated task (section 14) .check.sh optional slow poll you write per task (e.g. merged-PR check) - .ultracode present only when the dispatched profile set ultracode=true; role=, plus reviewed_by= once bin/fm-ultracode-guard.sh confirms an independent second pass ran (section 4) + .ultracode present when the dispatched profile set ultracode=true or a risk-floor tripwire hit flagged it; role=, plus reviewed_by= once bin/fm-ultracode-guard.sh confirms an independent second pass ran (section 4) x-watch.check.sh generated X-mode relay poll shim; present only when opted in (section 14) x-inbox/ generated X-mode pending mention payloads; fmx-respond drains it (section 14) x-context/ generated X-mode durable per-request reply context (platform/budget), keyed by request_id; survives inbox cleanup so a delayed follow-up recovers the original platform (section 14; bin/fm-x-lib.sh) diff --git a/tests/fm-risk-tripwire.test.sh b/tests/fm-risk-tripwire.test.sh old mode 100644 new mode 100755 diff --git a/tests/fm-tier-guard.test.sh b/tests/fm-tier-guard.test.sh old mode 100644 new mode 100755 diff --git a/tests/fm-ultracode-guard.test.sh b/tests/fm-ultracode-guard.test.sh old mode 100644 new mode 100755 From 5c95dca2e09051cbcdc22f4690efd1c12d6e6a1b Mon Sep 17 00:00:00 2001 From: Michael Date: Thu, 9 Jul 2026 21:33:44 -0400 Subject: [PATCH 17/46] no-mistakes(test): fix jq/git-version and test-env portability in failing tests --- bin/fm-teardown.sh | 71 +++++++++++++++++++++------------------------- 1 file changed, 33 insertions(+), 38 deletions(-) diff --git a/bin/fm-teardown.sh b/bin/fm-teardown.sh index bb72f02a7..c04747144 100755 --- a/bin/fm-teardown.sh +++ b/bin/fm-teardown.sh @@ -275,48 +275,43 @@ pr_is_merged() { unpushed_patches_are_in_pr_head "$head" } -# Does HEAD's content already match the given ref? Checks every path HEAD changed -# since its merge-base with ref: if ref already matches HEAD at each of those paths, -# HEAD introduces nothing ref does not already contain (e.g. its change landed via -# squash). This isolates branch-only changes, so unrelated commits ref gained past -# the merge-base do not count as "added" - equivalent to a 3-way merge's resulting -# tree matching ref's tree, but built from plain diff/merge-base so it works on git -# older than 2.38 (which is what `git merge-tree --write-tree` requires). Returns -# non-zero when inconclusive (ref missing, or a real difference). -content_matches_ref() { - local ref=$1 base changed_file - git -C "$WT" rev-parse --quiet --verify "$ref^{commit}" >/dev/null 2>&1 || return 1 - base=$(git -C "$WT" merge-base "$ref" HEAD 2>/dev/null) || return 1 - local -a changed=() - while IFS= read -r -d '' changed_file; do - changed+=("$changed_file") - done < <(git -C "$WT" diff --name-only -z "$base" HEAD -- 2>/dev/null) - [ "${#changed[@]}" -gt 0 ] || return 0 - git -C "$WT" diff --quiet "$ref" HEAD -- "${changed[@]}" 2>/dev/null -} - -# Is the branch's content already present in the up-to-date default branch? Checks -# two candidate refs, succeeding if either already contains HEAD's content: the -# fetched remote-tracking ref refs/remotes/origin/$name (the normal PR-merged case), -# and the LOCAL refs/heads/$name (a worktree and its project's primary checkout are -# linked worktrees of the same repository, so this local ref already reflects a -# firstmate-performed local fast-forward merge, even for a project whose delivery -# mode is not local-only - e.g. a PR closed unmerged upstream but the branch was -# landed into local main by hand on the captain's approval). A fetch failure only -# rules out the remote-tracking path; the local ref is still tried. Returns -# non-zero only when neither candidate ref exists or matches. +# Is the branch's content already present in the up-to-date default branch? Fetches +# first, then 3-way merges the default branch with HEAD: when HEAD introduces nothing +# the default branch does not already contain (e.g. its change landed via squash) the +# merged tree equals the default branch's tree. This isolates branch-only changes, so +# unrelated commits the default branch gained past the merge-base do not count as +# "added". Returns non-zero when inconclusive (no default ref, or a merge conflict), +# so the caller refuses rather than guesses. content_in_default() { - local name + local name ref default_tree base merged_tree tmpdir rc name=$(default_branch) || return 1 if git -C "$WT" remote get-url origin >/dev/null 2>&1; then - if git -C "$WT" fetch --quiet origin "+refs/heads/$name:refs/remotes/origin/$name" >/dev/null 2>&1; then - content_matches_ref "refs/remotes/origin/$name" && return 0 - fi - fi - if git -C "$WT" rev-parse --quiet --verify "refs/heads/$name" >/dev/null 2>&1; then - content_matches_ref "refs/heads/$name" && return 0 + git -C "$WT" fetch --quiet origin "+refs/heads/$name:refs/remotes/origin/$name" >/dev/null 2>&1 || return 1 + ref="refs/remotes/origin/$name" + elif git -C "$WT" rev-parse --quiet --verify "refs/heads/$name" >/dev/null 2>&1; then + ref="refs/heads/$name" + else + return 1 fi - return 1 + default_tree=$(git -C "$WT" rev-parse --quiet --verify "$ref^{tree}" 2>/dev/null) || return 1 + [ -n "$default_tree" ] || return 1 + base=$(git -C "$WT" merge-base "$ref" HEAD 2>/dev/null) || return 1 + # 3-way merge the default branch (ours) with HEAD (theirs) off their merge + # base into a throwaway index: when HEAD introduces nothing the default branch + # does not already contain, the merged tree equals the default branch's tree. + # read-tree + write-tree is used instead of `git merge-tree --write-tree` + # (git >= 2.38 only) so this stays portable to older git; a real merge + # conflict leaves unmerged index entries, write-tree fails, and the check + # stays inconclusive so the caller refuses rather than guesses. + tmpdir=$(mktemp -d 2>/dev/null) || return 1 + GIT_INDEX_FILE="$tmpdir/index" git -C "$WT" read-tree -im --aggressive \ + "$base^{tree}" "$ref^{tree}" "HEAD^{tree}" 2>/dev/null \ + && merged_tree=$(GIT_INDEX_FILE="$tmpdir/index" git -C "$WT" write-tree 2>/dev/null) + rc=$? + rm -rf "$tmpdir" + [ "$rc" -eq 0 ] || return 1 + [ -n "$merged_tree" ] || return 1 + [ "$merged_tree" = "$default_tree" ] } # Has the worktree's committed work actually LANDED, though its commits are not From 4983bd9f02fafd02c77bc878dd9264f17b2189c8 Mon Sep 17 00:00:00 2001 From: Michael Date: Thu, 9 Jul 2026 22:02:59 -0400 Subject: [PATCH 18/46] no-mistakes(document): sync docs with new resource-tiering guardrail scripts --- docs/architecture.md | 7 +++++++ docs/configuration.md | 5 +++++ 2 files changed, 12 insertions(+) diff --git a/docs/architecture.md b/docs/architecture.md index 4e71a6a41..fa385cbcd 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -130,6 +130,13 @@ Secondmate launches are exempt because they resolve the secondmate harness and a Unsupported effort values are still recorded in task meta when passed to `fm-spawn.sh`, but the launch template omits any effort flag that the selected harness does not accept. That keeps spawn launch compatible across claude, codex, grok, pi, and opencode while preserving the requested profile for later audit. +A crew-dispatch profile can also carry `ultracode: true` with an optional `ultracode_role` (today only `independent-review`): a task matched to that profile must get a genuinely independent second pass on its finished diff, dispatched as its own separate task rather than a sub-task the implementing crewmate spawns, before it can go PR-ready. +Three mechanical guardrails back this tiering, each a structurally separate check from the natural-language rule match so a misclassified task cannot slip a cheaper tier on one judgment call. +`bin/fm-risk-tripwire.sh` greps a task's brief text and, once code exists, its changed file paths for migration, auth, schema, and security signals; a hit floors the task to the safety-critical profile (`opus`/`xhigh`, ultracode `independent-review`) regardless of which rule was matched. +`bin/fm-tier-guard.sh` is a read-only Validate-time check that escalates a trivial (`haiku`/`low`) task in place once its actual diff size or elapsed time outgrows that tier's envelope, plus a general heavy-scale ceiling that escalates any tier once its diff crosses it; it reuses `bin/fm-review-diff.sh --stat` for the authoritative diff size. +`bin/fm-ultracode-guard.sh` tracks the ultracode requirement through a plain `state/.ultracode` marker (`role=`, then `reviewed_by=`), the same marker convention as `state/.afk`: it flags the requirement right after spawn (also on a risk-tripwire hit), records a distinct separately-dispatched reviewer task as the independent review, and refuses to let an ultracode-flagged task go PR-ready until that review is recorded. +None of the three modify `fm-spawn.sh` or task meta. + ## Optional secondmates `data/secondmates.md` records persistent domain supervisors with natural-language scopes, project clone lists, and home paths. diff --git a/docs/configuration.md b/docs/configuration.md index 411c2f9c3..63b97f7c8 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -397,6 +397,11 @@ FM_CLASSIFY_PAUSED_VERB=paused # leading status verb for a declared external FM_STALE_ESCALATE_SECS=240 # idle seconds before a provably-working stale pane escalates; stale panes whose crew is not provably working surface immediately unless they declare the pause verb FM_PAUSE_RESURFACE_SECS=3600 # seconds before an idle declared external wait re-surfaces for a recheck in the watcher or away-mode daemon FM_WEDGE_DEMAND_INSPECT_COUNT=3 # consecutive provably-working stale escalations on the same unchanged pane before demand-deep-inspection is added +FM_TIER_TRIVIAL_MAX_LINES=30 # fm-tier-guard.sh: changed-line count a trivial (haiku/low) task may reach before escalation fires +FM_TIER_TRIVIAL_MAX_FILES=2 # fm-tier-guard.sh: changed-file count a trivial (haiku/low) task may reach before escalation fires +FM_TIER_TRIVIAL_MAX_AGE_SECONDS=1800 # fm-tier-guard.sh: elapsed seconds a trivial (haiku/low) task may run before escalation fires (30 min) +FM_TIER_HEAVY_MIN_LINES=400 # fm-tier-guard.sh: changed-line count that escalates any tier via the general heavy-scale ceiling +FM_TIER_HEAVY_MIN_FILES=8 # fm-tier-guard.sh: changed-file count that escalates any tier via the general heavy-scale ceiling FM_WATCH_TRIAGE_LOG_MAX_BYTES=262144 # size cap for the watcher's absorbed-wake debug log FM_FLEET_SYNC_BOOTSTRAP_TIMEOUT= # optional seconds allowed for bootstrap's best-effort clone refresh; unset/blank defaults to max(20, 5 + 3 * origin-backed-project-count) FM_FLEET_PRUNE=1 # set to 0 to skip pruning local branches whose upstream is gone From b93423312f3050496de8c9d9396f453a00dcf807 Mon Sep 17 00:00:00 2001 From: Michael Date: Fri, 10 Jul 2026 13:24:55 -0400 Subject: [PATCH 19/46] no-mistakes(review): exclude herdr-lab brief block from risk-tripwire scan --- bin/fm-risk-tripwire.sh | 12 ++++++--- tests/fm-risk-tripwire.test.sh | 49 ++++++++++++++++++++++++++++++++-- 2 files changed, 56 insertions(+), 5 deletions(-) diff --git a/bin/fm-risk-tripwire.sh b/bin/fm-risk-tripwire.sh index 3e8f1b585..9fc0058cc 100755 --- a/bin/fm-risk-tripwire.sh +++ b/bin/fm-risk-tripwire.sh @@ -83,8 +83,14 @@ PHRASE_REGEX='(access[[:space:]]+control|data[[:space:]]+deletion|bulk[[:space:] # next scaffold section heading), never the fixed scaffold boilerplate # bin/fm-brief.sh writes into every brief - that boilerplate contains benign # words like "authoritative" and "future session" that would otherwise trip the -# wire on every task. The section boundary is one of the scaffold's own known -# headings (# Setup/# Rules/# Project memory/# Definition of done), not any +# wire on every task. The Herdr block bin/fm-brief.sh injects immediately after +# # Task (either the # Herdr isolation ... hard-safety contract, whose text is +# dense with "session"/"--session", or the # Herdr lifecycle declaration ... +# not-enabled stub) is scaffold boilerplate too, so its heading is a boundary as +# well; without it every --herdr-lab brief would trip on the contract's own +# "session" wording rather than the real task text. The section boundary is one +# of the scaffold's own known headings (# Herdr isolation .../# Herdr lifecycle +# declaration .../# Setup/# Rules/# Project memory/# Definition of done), not any # column-0 "# " line: a shell/code comment embedded in the task body (e.g. # "# then run the schema migration") must NOT end the scan, or the risk words # after it are silently dropped - the dangerous direction for a safety floor. @@ -100,7 +106,7 @@ brief_task_body() { { blank = ($0 ~ /^[[:space:]]*$/) } /^```/ { fence = !fence; prevblank = blank; next } !fence && /^# Task[[:space:]]*$/ { intask=1; prevblank = blank; next } - intask && !fence && prevblank && /^# (Setup|Rules|Project memory|Definition of done)[[:space:]]*$/ { intask=0 } + intask && !fence && prevblank && /^# (Herdr isolation.*|Herdr lifecycle declaration.*|Setup|Rules|Project memory|Definition of done)[[:space:]]*$/ { intask=0 } intask { print } { prevblank = blank } ' "$1") diff --git a/tests/fm-risk-tripwire.test.sh b/tests/fm-risk-tripwire.test.sh index b29d0ac2a..4053ff14f 100755 --- a/tests/fm-risk-tripwire.test.sh +++ b/tests/fm-risk-tripwire.test.sh @@ -51,12 +51,15 @@ run_tripwire() { # Scaffold a real ship brief via bin/fm-brief.sh, then substitute the {TASK} # placeholder with the given task text - exercising the actual scaffold -# boilerplate rather than a hand-written stand-in. +# boilerplate rather than a hand-written stand-in. Any extra args (e.g. +# --herdr-lab) are forwarded to fm-brief.sh so tests cover the real injected +# sections, not a stand-in. scaffold_brief() { local case_dir=$1 task=$2 brief + shift 2 mkdir -p "$case_dir/state" FM_ROOT_OVERRIDE="$ROOT" FM_DATA_OVERRIDE="$case_dir/data" FM_STATE_OVERRIDE="$case_dir/state" \ - "$ROOT/bin/fm-brief.sh" task-x1 someproject >/dev/null 2>&1 + "$ROOT/bin/fm-brief.sh" task-x1 someproject "$@" >/dev/null 2>&1 brief="$case_dir/data/task-x1/brief.md" sed "s|{TASK}|$task|" "$brief" > "$brief.tmp" && mv "$brief.tmp" "$brief" } @@ -179,6 +182,46 @@ test_scaffolded_brief_risky_task_still_trips() { pass "fm-risk-tripwire still scans the task body of a scaffolded brief" } +test_herdr_lab_boilerplate_does_not_trip() { + # The --herdr-lab contract fm-brief.sh injects between # Task and # Setup is + # dense with "session"/"--session"; it is scaffold boilerplate, so a benign + # task must not trip on it. + local case_dir out status + case_dir="$TMP_ROOT/scaffold-herdr-clean" + scaffold_brief "$case_dir" "Fix a typo in the CLI help text." --herdr-lab + + set +e + out=$(FM_ROOT_OVERRIDE="$ROOT" FM_STATE_OVERRIDE="$case_dir/state" FM_DATA_OVERRIDE="$case_dir/data" "$TRIPWIRE" task-x1 2>&1) + status=$? + set -e + + expect_code 0 "$status" "scaffold-herdr-clean: the --herdr-lab contract's own 'session' text must not trip a benign task" + [ -z "$out" ] || fail "scaffold-herdr-clean: expected no RISK output from --herdr-lab boilerplate, got: $out" + pass "fm-risk-tripwire does not trip on --herdr-lab scaffold boilerplate" +} + +test_herdr_lab_risky_task_still_trips() { + # The Herdr block is now a scan boundary, so its "session" text is excluded - + # but the real task body between # Task and the Herdr heading must still be + # scanned, and the boilerplate's "session" must not leak into the hit list. + local case_dir out status + case_dir="$TMP_ROOT/scaffold-herdr-risky" + scaffold_brief "$case_dir" "Rotate the payment credentials and run the schema migration." --herdr-lab + + set +e + out=$(FM_ROOT_OVERRIDE="$ROOT" FM_STATE_OVERRIDE="$case_dir/state" FM_DATA_OVERRIDE="$case_dir/data" "$TRIPWIRE" task-x1) + status=$? + set -e + + expect_code 1 "$status" "scaffold-herdr-risky: a risk-worded task body must still trip under --herdr-lab" + assert_contains "$out" "RISK: brief for task-x1" "scaffold-herdr-risky: should name the brief hit" + assert_contains "$out" "payment" "scaffold-herdr-risky: should surface the real task-body term" + case "$out" in + *session*) fail "scaffold-herdr-risky: the Herdr boilerplate's 'session' must not leak into the hit list, got: $out" ;; + esac + pass "fm-risk-tripwire scans the task body but excludes the --herdr-lab Herdr block" +} + test_word_boundary_avoids_substring_false_positive() { local case_dir out status case_dir="$TMP_ROOT/word-boundary" @@ -581,6 +624,8 @@ test_brief_only_mode_before_worktree_exists test_nothing_to_check_errors test_scaffolded_brief_boilerplate_does_not_trip test_scaffolded_brief_risky_task_still_trips +test_herdr_lab_boilerplate_does_not_trip +test_herdr_lab_risky_task_still_trips test_word_boundary_avoids_substring_false_positive test_inflected_keyword_still_trips test_supervision_bin_path_does_not_trip From 3acee60ad94d39e013b3665bfe25b1d642e50282 Mon Sep 17 00:00:00 2001 From: Michael Date: Fri, 10 Jul 2026 13:36:11 -0400 Subject: [PATCH 20/46] no-mistakes(review): clean up orphan .ultracode state marker on teardown --- bin/fm-teardown.sh | 2 +- tests/fm-teardown.test.sh | 31 +++++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/bin/fm-teardown.sh b/bin/fm-teardown.sh index c04747144..28a48b42a 100755 --- a/bin/fm-teardown.sh +++ b/bin/fm-teardown.sh @@ -1064,7 +1064,7 @@ fm_backend_clear_transition "$BACKEND" "$STATE" "$T" || true # Remove the per-task temp root (/tmp/fm-/, incl. its gotmp/) recorded by spawn. # Read before the state-file rm below; empty (pre-fix tasks without tasktmp=) is a no-op. [ -n "$TASK_TMP" ] && rm -rf "$TASK_TMP" -rm -f "$STATE/$ID.status" "$STATE/$ID.turn-ended" "$STATE/$ID.check.sh" "$STATE/$ID.meta" "$STATE/$ID.pi-ext.ts" "$STATE/$ID.grok-turnend-token" +rm -f "$STATE/$ID.status" "$STATE/$ID.turn-ended" "$STATE/$ID.check.sh" "$STATE/$ID.meta" "$STATE/$ID.pi-ext.ts" "$STATE/$ID.grok-turnend-token" "$STATE/$ID.ultracode" if [ "$KIND" != scout ] && [ "$KIND" != secondmate ] && [ "$MODE" != local-only ]; then "$FM_ROOT/bin/fm-fleet-sync.sh" "$PROJ" || true fi diff --git a/tests/fm-teardown.test.sh b/tests/fm-teardown.test.sh index c1cc2b35a..bf9dbd7fc 100755 --- a/tests/fm-teardown.test.sh +++ b/tests/fm-teardown.test.sh @@ -642,6 +642,36 @@ test_no_mistakes_origin_remote_allows() { pass "no-mistakes worktree with HEAD on origin is torn down (no regression)" } +test_teardown_removes_per_task_state_markers() { + local case_dir rc + case_dir=$(make_case state-markers) + write_meta "$case_dir" no-mistakes ship + wt_commit "$case_dir" "shippable work" + git -C "$case_dir/wt" push -q origin fm/task-x1 + git -C "$case_dir/project" fetch -q origin + # Every per-task marker a teardown is responsible for reaping, including the + # ultracode flag written by fm-ultracode-guard.sh. + printf '%s\n' 'working: x' > "$case_dir/state/task-x1.status" + touch "$case_dir/state/task-x1.turn-ended" + printf '%s\n' 'exit 0' > "$case_dir/state/task-x1.check.sh" + printf '%s\n' 'ts' > "$case_dir/state/task-x1.pi-ext.ts" + printf '%s\n' 'tok' > "$case_dir/state/task-x1.grok-turnend-token" + printf '%s\n' 'role=independent-review' > "$case_dir/state/task-x1.ultracode" + + set +e + run_teardown "$case_dir" > "$case_dir/stdout" 2> "$case_dir/stderr" + rc=$? + set -e + + expect_code 0 "$rc" "state-markers: teardown should succeed when HEAD is on origin" + local marker + for marker in status turn-ended check.sh meta pi-ext.ts grok-turnend-token ultracode; do + [ ! -e "$case_dir/state/task-x1.$marker" ] \ + || fail "state-markers: teardown left orphan state/task-x1.$marker" + done + pass "teardown removes every per-task state marker including .ultracode (no leak)" +} + test_no_mistakes_truly_unpushed_refuses() { local case_dir rc case_dir=$(make_case nm-unpushed) @@ -1333,6 +1363,7 @@ test_teardown_manual_backend_prompts_hand_edit_even_when_tasks_axi_present test_local_only_truly_unpushed_refuses test_local_only_merged_to_local_main_allows test_no_mistakes_origin_remote_allows +test_teardown_removes_per_task_state_markers test_no_mistakes_truly_unpushed_refuses test_no_mistakes_merged_to_local_main_allows test_local_only_force_overrides_unpushed From 942351180d05ae7a98d3b70cf330b0f052bcfe99 Mon Sep 17 00:00:00 2001 From: Michael Date: Fri, 10 Jul 2026 14:10:07 -0400 Subject: [PATCH 21/46] no-mistakes(review): stop tiering guardrails passing silently when diff uncheckable --- bin/fm-risk-tripwire.sh | 25 +++++++++++++++-- bin/fm-tier-guard.sh | 20 ++++++++++++- tests/fm-risk-tripwire.test.sh | 51 ++++++++++++++++++++++++++++++++++ tests/fm-tier-guard.test.sh | 35 +++++++++++++++++++++++ 4 files changed, 128 insertions(+), 3 deletions(-) diff --git a/bin/fm-risk-tripwire.sh b/bin/fm-risk-tripwire.sh index 9fc0058cc..aee4c71f6 100755 --- a/bin/fm-risk-tripwire.sh +++ b/bin/fm-risk-tripwire.sh @@ -17,8 +17,10 @@ # Exit codes: # 0 no risk signal found # 1 a RISK hit fired (one "RISK: " line per hit is printed) -# 2 could not check: a malformed invocation, or neither a brief nor a usable -# worktree/project exists yet (nothing to check) +# 2 could not check: a malformed invocation, neither a brief nor a usable +# worktree/project exists yet (nothing to check), or the worktree/project +# exist but their diff base could not be resolved, so the binding diff +# checkpoint could not run (warned to stderr, never a silent clean pass) # Distinct codes matter so a caller branching on $? cannot mistake a malformed # invocation for a real risk hit. A hit means: floor this task's model/effort to # the safety-critical profile (opus/xhigh, ultracode independent-review) @@ -162,6 +164,7 @@ path_is_risky() { FOUND=0 CHECKED=0 +DIFF_UNRESOLVED=0 BRIEF="$DATA/$ID/brief.md" if [ -f "$BRIEF" ]; then @@ -185,6 +188,7 @@ if [ -f "$META" ]; then PROJ=$(grep '^project=' "$META" | tail -1 | cut -d= -f2- || true) if [ -n "$WT" ] && [ -n "$PROJ" ] && [ -d "$WT" ] && [ -d "$PROJ" ]; then CHECKED=1 + diff_base_resolved=0 # The base resolution below duplicates bin/fm-review-diff.sh's origin fetch # and origin/ verification on purpose: this scan needs the changed # PATH LIST, but fm-review-diff.sh only exposes --stat (a diffstat), not a @@ -199,6 +203,7 @@ if [ -f "$META" ]; then git -C "$WT" rev-parse --verify --quiet "refs/remotes/origin/$DEFAULT^{commit}" >/dev/null 2>&1 && BASE="origin/$DEFAULT" fi if git -C "$WT" rev-parse --verify --quiet "$BASE^{commit}" >/dev/null 2>&1; then + diff_base_resolved=1 diff_paths=$(git -C "$WT" diff --name-only "$BASE...HEAD" -- 2>/dev/null || true) risky_paths= if [ -n "$diff_paths" ]; then @@ -216,6 +221,16 @@ if [ -f "$META" ]; then fi fi fi + # The worktree/project exist, so the binding diff checkpoint was expected to + # run - but the diff base could not be resolved (no default branch, or the + # base commit does not verify in the worktree). Do not let that read as a + # clean pass: warn loudly, like the sibling bin/fm-review-diff.sh, and mark + # the run not-checkable so a caller branching on $? floors the task rather + # than trusting a silent 0 - the safe direction for this guardrail. + if [ "$diff_base_resolved" -eq 0 ]; then + echo "warning: could not resolve a diff base for $ID (project $PROJ, default branch '${DEFAULT:-unresolved}'); the diff checkpoint did not run - reporting not-checkable (exit 2), not a clean pass" >&2 + DIFF_UNRESOLVED=1 + fi fi fi @@ -224,4 +239,10 @@ if [ "$CHECKED" -eq 0 ]; then exit 2 fi +# A real risk hit (exit 1) always wins over an unresolvable diff base; only when +# nothing tripped does an unresolvable base downgrade the run to could-not-check. +if [ "$FOUND" -eq 0 ] && [ "$DIFF_UNRESOLVED" -eq 1 ]; then + exit 2 +fi + exit "$FOUND" diff --git a/bin/fm-tier-guard.sh b/bin/fm-tier-guard.sh index 38234c4b0..db71e381c 100755 --- a/bin/fm-tier-guard.sh +++ b/bin/fm-tier-guard.sh @@ -87,10 +87,24 @@ is_trivial_tier() { return 1 } -STAT_OUT=$("$SCRIPT_DIR/fm-review-diff.sh" "$ID" --stat 2>/dev/null) || { +STAT_ERR=$(mktemp) +STAT_OUT=$("$SCRIPT_DIR/fm-review-diff.sh" "$ID" --stat 2>"$STAT_ERR") || { echo "error: fm-review-diff.sh failed for $ID" >&2 + rm -f "$STAT_ERR" exit 2 } +# fm-review-diff.sh still exits 0 but warns when it cannot resolve an open PR's +# head and falls back to the possibly-stale local branch, so the size measured +# below may under-count the real PR diff. Blanket-discarding that warning (the +# 2>/dev/null this replaces) made a degraded read indistinguishable from an +# authoritative one - the dangerous under-report direction for this guardrail. +# Detect only that specific marker (not all of fm-review-diff.sh's stderr, which +# also carries fm-guard.sh's supervision banner) and surface it below. +DIFF_MAY_LAG_PR=0 +if grep -q 'PR head unavailable' "$STAT_ERR"; then + DIFF_MAY_LAG_PR=1 +fi +rm -f "$STAT_ERR" FILES=0 LINES=0 @@ -127,4 +141,8 @@ if [ "$FILES" -gt "$HEAVY_MIN_FILES" ] || [ "$LINES" -gt "$HEAVY_MIN_LINES" ]; t FOUND=1 fi +if [ "$DIFF_MAY_LAG_PR" -eq 1 ]; then + echo "warning: the diff for $ID was sized against a possibly-stale local branch because the open PR head could not be resolved; the ceilings above may under-count the real PR diff - re-check against the current PR before trusting a within-envelope result" >&2 +fi + exit "$FOUND" diff --git a/tests/fm-risk-tripwire.test.sh b/tests/fm-risk-tripwire.test.sh index 4053ff14f..bccf020fa 100755 --- a/tests/fm-risk-tripwire.test.sh +++ b/tests/fm-risk-tripwire.test.sh @@ -599,6 +599,55 @@ test_adjacent_keywords_both_reported() { pass "fm-risk-tripwire reports both words of an adjacent risk pair" } +test_unresolvable_diff_base_is_not_a_clean_pass() { + # A meta with a real worktree/project but an unresolvable diff base (no default + # branch, no origin) must NOT silently read as a clean pass (exit 0). The + # binding second checkpoint could not run, so it must warn and report + # could-not-check (2), matching the sibling fm-tier-guard.sh/fm-review-diff.sh. + local case_dir out status + case_dir="$TMP_ROOT/unresolvable-base" + mkdir -p "$case_dir/state" "$case_dir/wt" + git init -q "$case_dir/project" + + fm_write_meta "$case_dir/state/task-x1.meta" \ + "window=fm-task-x1" \ + "worktree=$case_dir/wt" \ + "project=$case_dir/project" + + set +e + out=$(FM_ROOT_OVERRIDE="$ROOT" FM_STATE_OVERRIDE="$case_dir/state" FM_DATA_OVERRIDE="$case_dir/data" "$TRIPWIRE" task-x1 2>&1) + status=$? + set -e + + expect_code 2 "$status" "unresolvable-base: an unresolvable diff base must read as could-not-check (2), not a clean pass (0)" + assert_contains "$out" "could not resolve a diff base" "unresolvable-base: should warn that the diff checkpoint did not run" + pass "fm-risk-tripwire reports could-not-check when the diff base is unresolvable, not a clean pass" +} + +test_unresolvable_diff_base_still_reports_brief_hit() { + # A brief risk hit must still win (exit 1) even when the diff base is + # unresolvable - the risk floor beats the could-not-check downgrade. + local case_dir out status + case_dir="$TMP_ROOT/unresolvable-base-brief-hit" + mkdir -p "$case_dir/state" "$case_dir/wt" "$case_dir/data/task-x1" + git init -q "$case_dir/project" + printf 'Add a data migration for the new billing schema.\n' > "$case_dir/data/task-x1/brief.md" + + fm_write_meta "$case_dir/state/task-x1.meta" \ + "window=fm-task-x1" \ + "worktree=$case_dir/wt" \ + "project=$case_dir/project" + + set +e + out=$(FM_ROOT_OVERRIDE="$ROOT" FM_STATE_OVERRIDE="$case_dir/state" FM_DATA_OVERRIDE="$case_dir/data" "$TRIPWIRE" task-x1 2>/dev/null) + status=$? + set -e + + expect_code 1 "$status" "unresolvable-base-brief-hit: a brief risk hit must still win (1) even when the diff base is unresolvable" + assert_contains "$out" "RISK: brief for task-x1" "unresolvable-base-brief-hit: should still name the brief hit" + pass "fm-risk-tripwire still reports a brief hit (1) when the diff base is unresolvable" +} + test_multiword_phrase_keyword_trips() { local case_dir out status case_dir="$TMP_ROOT/phrase-keyword" @@ -617,6 +666,8 @@ test_multiword_phrase_keyword_trips() { test_clean_brief_and_diff_passes test_bare_auth_matches_but_authoritative_does_not test_adjacent_keywords_both_reported +test_unresolvable_diff_base_is_not_a_clean_pass +test_unresolvable_diff_base_still_reports_brief_hit test_multiword_phrase_keyword_trips test_brief_keyword_trips_wire test_diff_path_trips_wire diff --git a/tests/fm-tier-guard.test.sh b/tests/fm-tier-guard.test.sh index 41650372f..0df6a3b0b 100755 --- a/tests/fm-tier-guard.test.sh +++ b/tests/fm-tier-guard.test.sh @@ -167,6 +167,40 @@ test_any_tier_past_heavy_scale_escalates() { pass "fm-tier-guard escalates any tier once its diff crosses the general heavy-scale ceiling" } +test_unresolvable_pr_head_surfaces_degraded_diff_warning() { + # When state records pr= but the PR head cannot be resolved, fm-review-diff.sh + # falls back to the possibly-stale local branch and warns (still exit 0). The + # guard must NOT swallow that: the size it measured may under-count the real PR + # diff, so the degradation has to stay visible instead of reading as a clean, + # authoritative within-envelope pass - the same silent-reads-clean class as the + # sibling fm-risk-tripwire.sh unresolvable-base guard. + local case_dir out err status + case_dir=$(make_case degraded-diff) + printf 'base\nsmall change\n' > "$case_dir/wt/feature.txt" + git -C "$case_dir/wt" add feature.txt + git -C "$case_dir/wt" commit -qm "small local edit" + # pr= recorded, but pr_head is a bogus sha and origin has no matching PR ref, + # so resolve_pr_head fails and fm-review-diff.sh takes the stale-branch path. + write_task_meta "$case_dir" task-x1 "model=claude-haiku-4-5" "effort=low" \ + "pr=https://github.com/o/r/pull/7" \ + "pr_head=0000000000000000000000000000000000000000" + + local errfile; errfile="$case_dir/stderr.txt" + set +e + out=$(FM_ROOT_OVERRIDE="$ROOT" FM_STATE_OVERRIDE="$case_dir/state" "$TIER_GUARD" task-x1 2>"$errfile") + status=$? + set -e + err=$(cat "$errfile") + + expect_code 0 "$status" "degraded-diff: a small stale-branch diff still measures within envelope (exit 0)" + [ -z "$out" ] || fail "degraded-diff: expected no ESCALATE on stdout, got: $out" + assert_contains "$err" "may under-count the real PR diff" "degraded-diff: the degraded measurement must be surfaced, not silently swallowed" + case "$err" in + *"WATCHER DOWN"*) fail "degraded-diff: must not leak fm-guard.sh's supervision banner from fm-review-diff.sh stderr, got: $err" ;; + esac + pass "fm-tier-guard surfaces a degraded (PR-head-unavailable) diff measurement instead of swallowing it" +} + test_missing_meta_errors() { local case_dir out status case_dir=$(make_case missing-meta) @@ -203,6 +237,7 @@ test_trivial_tier_exceeds_file_ceiling_escalates test_trivial_tier_stale_age_escalates test_non_trivial_tier_small_diff_no_ceiling test_any_tier_past_heavy_scale_escalates +test_unresolvable_pr_head_surfaces_degraded_diff_warning test_missing_meta_errors test_usage_error_exit_code From e5811cca30f86a43fa5753ab7cdae06b7f340549 Mon Sep 17 00:00:00 2001 From: Michael Date: Fri, 10 Jul 2026 14:49:03 -0400 Subject: [PATCH 22/46] no-mistakes(review): pass ultracode through dispatch, tighten tiering guardrail coverage --- bin/fm-dispatch-select.sh | 8 ++++++-- bin/fm-risk-tripwire.sh | 2 +- bin/fm-tier-guard.sh | 6 +++++- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/bin/fm-dispatch-select.sh b/bin/fm-dispatch-select.sh index c626e9a95..dfe100379 100755 --- a/bin/fm-dispatch-select.sh +++ b/bin/fm-dispatch-select.sh @@ -115,7 +115,9 @@ first_profile() { def clean($p): {harness: $p.harness} + (if ($p.model? | type) == "string" then {model: $p.model} else {} end) - + (if ($p.effort? | type) == "string" then {effort: $p.effort} else {} end); + + (if ($p.effort? | type) == "string" then {effort: $p.effort} else {} end) + + (if ($p.ultracode? | type) == "boolean" then {ultracode: $p.ultracode} else {} end) + + (if ($p.ultracode_role? | type) == "string" then {ultracode_role: $p.ultracode_role} else {} end); clean(.[0]) ' } @@ -169,7 +171,9 @@ selection=$(printf '%s\n' "$quota_json" | jq -ec \ def clean($p): {harness: $p.harness} + (if ($p.model? | type) == "string" then {model: $p.model} else {} end) - + (if ($p.effort? | type) == "string" then {effort: $p.effort} else {} end); + + (if ($p.effort? | type) == "string" then {effort: $p.effort} else {} end) + + (if ($p.ultracode? | type) == "boolean" then {ultracode: $p.ultracode} else {} end) + + (if ($p.ultracode_role? | type) == "string" then {ultracode_role: $p.ultracode_role} else {} end); def provider_for($h): [.providers[]? | select(.provider == $h)][0]; def general_ids($h): if $h == "claude" then ["five_hour", "seven_day"] diff --git a/bin/fm-risk-tripwire.sh b/bin/fm-risk-tripwire.sh index aee4c71f6..e684eff57 100755 --- a/bin/fm-risk-tripwire.sh +++ b/bin/fm-risk-tripwire.sh @@ -138,7 +138,7 @@ brief_task_body() { # NO delimiter (e.g. "authsetup.rb") is not matched, because catching it would # require substring matching again and reopen exactly those false positives; the # brief-text scan and the delimiter tokenization below cover the realistic cases. -PATH_STRONG_REGEX='^(auth|authn|authz|authoriz(e|ed|es|ing|ation|ations|er|ers)?|authenticat(e|ed|es|ing|ion|ions|or|ors)?|migrat(e|ed|es|ing|ion|ions)|schema|schemas|secret|secrets|credential|credentials|payment|payments|billing|security)$' +PATH_STRONG_REGEX='^(auth|authn|authz|authoriz(e|ed|es|ing|ation|ations|er|ers)?|authenticat(e|ed|es|ing|ion|ions|or|ors)?|migrat(e|ed|es|ing|ion|ions)|schema|schemas|secret|secrets|credential|credentials|payment|payments|billing|security|password|passwords|token|tokens|permission|permissions|encrypt(s|ed|ing|ion|ions)?|decrypt(s|ed|ing|ion|ions)?)$' PATH_WEAK_REGEX='^(session|sessions)$' path_is_risky() { local path=$1 comp base diff --git a/bin/fm-tier-guard.sh b/bin/fm-tier-guard.sh index db71e381c..934a5bfb4 100755 --- a/bin/fm-tier-guard.sh +++ b/bin/fm-tier-guard.sh @@ -82,7 +82,11 @@ tier_guard_stat_mtime() { is_trivial_tier() { case "$MODEL" in - *haiku*) [ "$EFFORT" = low ] && return 0 ;; + *haiku*) + if [ "$EFFORT" = low ] || [ "$EFFORT" = default ]; then + return 0 + fi + ;; esac return 1 } From 1b75fd2fbf0909016bc6c39091ace63597c81274 Mon Sep 17 00:00:00 2001 From: Michael Date: Fri, 10 Jul 2026 15:41:28 -0400 Subject: [PATCH 23/46] no-mistakes(test): skip handoff tests on incompatible tasks-axi; fix herdr server-race --- tests/fm-backend-herdr.test.sh | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/tests/fm-backend-herdr.test.sh b/tests/fm-backend-herdr.test.sh index f3100d484..77484a693 100755 --- a/tests/fm-backend-herdr.test.sh +++ b/tests/fm-backend-herdr.test.sh @@ -39,6 +39,14 @@ COUNT_FILE="$RESP/.count" for a in "$@"; do printf '\x1f%s' "$a"; done printf '\n' } >> "$LOG" +# `herdr server` is a fire-and-forget launch: server_ensure backgrounds it and +# discards its output, so its timing relative to the immediately-following status +# poll is non-deterministic. It must NOT consume a canned-response slot, or a lost +# race with that poll would steal a status response and desync the whole sequence. +# It is still logged above, so the "did it start the server" assertion holds. +if [ "${1:-}" = server ]; then + exit 0 +fi if [ "${1:-}" = status ] && [ "${2:-}" = --json ] && [ "${FM_HERDR_SCRIPT_STATUS:-0}" != 1 ]; then printf '{"client":{"version":"0.7.1","protocol":14},"server":{"running":true}}\n' exit 0 @@ -300,14 +308,15 @@ test_container_ensure_starts_server_and_workspace() { printf '{"client":{"version":"0.7.1","protocol":14}}\n' > "$resp/1.out" # 2: server_ensure's status --json check -> not running printf '{"server":{"running":false}}\n' > "$resp/2.out" - # 3: `herdr server` backgrounded launch - no meaningful output - # 4: server_ensure poll -> now running - printf '{"server":{"running":true}}\n' > "$resp/4.out" - # 5: workspace list -> empty (no "firstmate" workspace yet) - printf '{"result":{"workspaces":[]}}\n' > "$resp/5.out" - # 6: workspace create -> w1, seeding default tab w1:t9 (real herdr returns + # `herdr server` is backgrounded and consumes no response slot (see the fake), + # so the very next scripted response is the server_ensure poll. + # 3: server_ensure poll -> now running + printf '{"server":{"running":true}}\n' > "$resp/3.out" + # 4: workspace list -> empty (no "firstmate" workspace yet) + printf '{"result":{"workspaces":[]}}\n' > "$resp/4.out" + # 5: workspace create -> w1, seeding default tab w1:t9 (real herdr returns # the seeded tab/pane ids in the SAME response - verified empirically). - printf '{"result":{"workspace":{"workspace_id":"w1","label":"firstmate"},"tab":{"tab_id":"w1:t9"},"root_pane":{"pane_id":"w1:p9"}}}\n' > "$resp/6.out" + printf '{"result":{"workspace":{"workspace_id":"w1","label":"firstmate"},"tab":{"tab_id":"w1:t9"},"root_pane":{"pane_id":"w1:p9"}}}\n' > "$resp/5.out" fb=$(make_herdr_fakebin "$dir") out=$( PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" FM_HERDR_SCRIPT_STATUS=1 HERDR_SESSION=fmtest \ bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_container_ensure /tmp' "$ROOT" ) From 5058b8644fcc752950acc8f390201a911c3a924c Mon Sep 17 00:00:00 2001 From: Michael Date: Fri, 10 Jul 2026 17:15:34 -0400 Subject: [PATCH 24/46] no-mistakes: apply CI fixes --- bin/fm-ultracode-guard.sh | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/bin/fm-ultracode-guard.sh b/bin/fm-ultracode-guard.sh index ed0de277a..485510fa3 100755 --- a/bin/fm-ultracode-guard.sh +++ b/bin/fm-ultracode-guard.sh @@ -45,7 +45,10 @@ fi CMD=${1:-} ID=${2:-} -[ -n "$CMD" ] && [ -n "$ID" ] || { usage; exit 1; } +if [ -z "$CMD" ] || [ -z "$ID" ]; then + usage + exit 1 +fi MARKER="$STATE/$ID.ultracode" From 1ce2f14ea0466892e410c77fcb20e2f2432b463f Mon Sep 17 00:00:00 2001 From: Michael Date: Sat, 11 Jul 2026 15:24:41 -0400 Subject: [PATCH 25/46] fix(teardown): check both origin and local ref for landed content The read-tree portability rewrite restructured content_in_default() into an if/elif that checks exactly one candidate ref: the fetched origin ref when a remote exists, falling back to the local ref only when there is no remote at all. That silently dropped the case a no-mistakes-mode project with a closed, unmerged PR but work fast-forwarded into local main by hand - the fetched origin ref exists but does not contain the work, so the local ref is never even checked. Restore checking both candidates in sequence via a shared content_matches_ref helper, matching the pre-rewrite behavior tests/fm-teardown.test.sh's local-land regression test expects. --- bin/fm-teardown.sh | 58 ++++++++++++++++++++++++++-------------------- 1 file changed, 33 insertions(+), 25 deletions(-) diff --git a/bin/fm-teardown.sh b/bin/fm-teardown.sh index 28a48b42a..6646cb162 100755 --- a/bin/fm-teardown.sh +++ b/bin/fm-teardown.sh @@ -275,34 +275,18 @@ pr_is_merged() { unpushed_patches_are_in_pr_head "$head" } -# Is the branch's content already present in the up-to-date default branch? Fetches -# first, then 3-way merges the default branch with HEAD: when HEAD introduces nothing -# the default branch does not already contain (e.g. its change landed via squash) the -# merged tree equals the default branch's tree. This isolates branch-only changes, so -# unrelated commits the default branch gained past the merge-base do not count as -# "added". Returns non-zero when inconclusive (no default ref, or a merge conflict), -# so the caller refuses rather than guesses. -content_in_default() { - local name ref default_tree base merged_tree tmpdir rc - name=$(default_branch) || return 1 - if git -C "$WT" remote get-url origin >/dev/null 2>&1; then - git -C "$WT" fetch --quiet origin "+refs/heads/$name:refs/remotes/origin/$name" >/dev/null 2>&1 || return 1 - ref="refs/remotes/origin/$name" - elif git -C "$WT" rev-parse --quiet --verify "refs/heads/$name" >/dev/null 2>&1; then - ref="refs/heads/$name" - else - return 1 - fi +# Does the given ref's tree already contain everything HEAD introduces? 3-way merges +# the ref (ours) with HEAD (theirs) off their merge base into a throwaway index: when +# HEAD introduces nothing the ref does not already contain (e.g. its change landed via +# squash), the merged tree equals the ref's tree. read-tree + write-tree is used +# instead of `git merge-tree --write-tree` (git >= 2.38 only) so this stays portable to +# older git; a real merge conflict leaves unmerged index entries, write-tree fails, and +# the check stays inconclusive so the caller refuses rather than guesses. +content_matches_ref() { + local ref=$1 default_tree base merged_tree tmpdir rc default_tree=$(git -C "$WT" rev-parse --quiet --verify "$ref^{tree}" 2>/dev/null) || return 1 [ -n "$default_tree" ] || return 1 base=$(git -C "$WT" merge-base "$ref" HEAD 2>/dev/null) || return 1 - # 3-way merge the default branch (ours) with HEAD (theirs) off their merge - # base into a throwaway index: when HEAD introduces nothing the default branch - # does not already contain, the merged tree equals the default branch's tree. - # read-tree + write-tree is used instead of `git merge-tree --write-tree` - # (git >= 2.38 only) so this stays portable to older git; a real merge - # conflict leaves unmerged index entries, write-tree fails, and the check - # stays inconclusive so the caller refuses rather than guesses. tmpdir=$(mktemp -d 2>/dev/null) || return 1 GIT_INDEX_FILE="$tmpdir/index" git -C "$WT" read-tree -im --aggressive \ "$base^{tree}" "$ref^{tree}" "HEAD^{tree}" 2>/dev/null \ @@ -314,6 +298,30 @@ content_in_default() { [ "$merged_tree" = "$default_tree" ] } +# Is the branch's content already present in the up-to-date default branch? Checks +# two candidate refs, succeeding if either already contains HEAD's content: the +# fetched remote-tracking ref refs/remotes/origin/$name (the normal PR-merged case), +# and the LOCAL refs/heads/$name (a worktree and its project's primary checkout are +# linked worktrees of the same repository, so this local ref already reflects a +# firstmate-performed local fast-forward merge, even for a project whose delivery +# mode is not local-only - e.g. a PR closed unmerged upstream but the branch was +# landed into local main by hand on the captain's approval). A fetch failure only +# rules out the remote-tracking path; the local ref is still tried. Returns +# non-zero only when neither candidate ref exists or matches. +content_in_default() { + local name + name=$(default_branch) || return 1 + if git -C "$WT" remote get-url origin >/dev/null 2>&1; then + if git -C "$WT" fetch --quiet origin "+refs/heads/$name:refs/remotes/origin/$name" >/dev/null 2>&1; then + content_matches_ref "refs/remotes/origin/$name" && return 0 + fi + fi + if git -C "$WT" rev-parse --quiet --verify "refs/heads/$name" >/dev/null 2>&1; then + content_matches_ref "refs/heads/$name" && return 0 + fi + return 1 +} + # Has the worktree's committed work actually LANDED, though its commits are not # reachable from any remote-tracking branch? True when a merged PR proves the # current local work is contained in the PR head, OR the content is already in the From 2a5feedf6e73d49fa92f9e2cf569348be24c9dae Mon Sep 17 00:00:00 2001 From: Michael Date: Sat, 11 Jul 2026 20:12:06 -0400 Subject: [PATCH 26/46] feat: auto-sync live NAS deployments after a landed ship task A merged PR does not, by itself, reach the live site: deployed apps run from a separate checkout under /mnt/nas/experiments//, managed by pm2, independent from the projects/ clone used for development. This gap required a manual pull+restart to notice and fix. bin/fm-nas-deploy-sync.sh fast-forwards a project's live NAS checkout and restarts its pm2 process(es), looked up from a captain-private data/nas-deployments.md mapping (best-effort, non-fatal, mirrors fm-fleet-sync.sh's dirty/diverged safety exactly). bin/fm-teardown.sh now calls it automatically after a landed ship task, including local-only merges. --- bin/fm-nas-deploy-sync.sh | 191 ++++++++++++++++++ bin/fm-teardown.sh | 9 + tests/fm-nas-deploy-sync.test.sh | 335 +++++++++++++++++++++++++++++++ 3 files changed, 535 insertions(+) create mode 100755 bin/fm-nas-deploy-sync.sh create mode 100755 tests/fm-nas-deploy-sync.test.sh diff --git a/bin/fm-nas-deploy-sync.sh b/bin/fm-nas-deploy-sync.sh new file mode 100755 index 000000000..8899703d0 --- /dev/null +++ b/bin/fm-nas-deploy-sync.sh @@ -0,0 +1,191 @@ +#!/usr/bin/env bash +# Best-effort, non-fatal deploy sync for a single project's live NAS checkout: a +# merged PR lands in the project's GitHub default branch, but the live app on this +# host runs from its own separate checkout under /mnt/nas/experiments//, +# managed by pm2 - that checkout does not update itself (see the 2026-07-04 entry +# in data/learnings.md). This script closes that gap after a landed ship task. +# +# Looks the project up in data/nas-deployments.md, a pipe-table with one row per +# deployed project: "| | | |" (comma- +# separated when more than one process serves the app). A project absent from +# that table - or a fresh checkout with no data/nas-deployments.md at all - has no +# known live deployment, which is not an error: prints one skip line and exits 0. +# +# When present: fetches the NAS checkout's origin and fast-forward-only merges to +# origin/, mirroring bin/fm-fleet-sync.sh's dirty/diverged safety exactly +# - never force, never discard local changes, never touch a checkout that is off +# its default branch, dirty, or diverged (reported as STUCK, left untouched). +# Restarts the recorded pm2 process(es) only when the merge actually advanced +# HEAD, then verifies each is back online via `pm2 jlist`. Always prints exactly +# one clear result line and exits 0 - this script is best-effort and must never +# fail or block its caller (see the post-teardown call in bin/fm-teardown.sh). +# +# Usage: fm-nas-deploy-sync.sh +# +# Overrides (test injection): FM_DATA_OVERRIDE points at an alternate data/ dir +# (same knob bin/fm-project-mode.sh uses); FM_NAS_DEPLOYMENTS_OVERRIDE points at +# an alternate mapping file directly. pm2 is invoked via PATH, so a fakebin shim +# ahead of it on PATH intercepts restart/list during tests - real tests must never +# reach the real /mnt/nas/experiments or a real pm2 process. +set -eu + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" +FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" +DATA="${FM_DATA_OVERRIDE:-$FM_HOME/data}" +MAP="${FM_NAS_DEPLOYMENTS_OVERRIDE:-$DATA/nas-deployments.md}" + +usage() { + echo "usage: fm-nas-deploy-sync.sh " >&2 +} + +if [ "${1:-}" = "--help" ] || [ "${1:-}" = "-h" ]; then + usage + exit 0 +fi +[ $# -eq 1 ] || { usage; exit 1; } +NAME=$1 + +first_line() { + printf '%s\n' "$1" | sed -n '1s/[[:space:]]\{1,\}/ /g;1p' +} + +# lookup_row: print "|" for $NAME's row in $MAP, or +# nothing when the file is absent or has no matching row. +lookup_row() { + [ -f "$MAP" ] || return 0 + # $3 ~ /^\// excludes the header ("nas_repo_path") and separator ("---") rows - + # a real NAS path is always absolute - so a project literally named "project" + # cannot collide with the header's own column label. + awk -F'|' -v n="$NAME" ' + { for (i = 1; i <= NF; i++) { gsub(/^[ \t]+|[ \t]+$/, "", $i) } } + NF >= 4 && $2 == n && $3 ~ /^\// { print $3 "|" $4; exit } + ' "$MAP" +} + +row=$(lookup_row) +if [ -z "$row" ]; then + echo "$NAME: skipped: no recorded NAS deployment" + exit 0 +fi +NAS_PATH=${row%%|*} +PROCS=${row#*|} + +if [ ! -d "$NAS_PATH" ]; then + echo "$NAME: skipped: NAS path $NAS_PATH not a directory" + exit 0 +fi +if ! git -C "$NAS_PATH" rev-parse --is-inside-work-tree >/dev/null 2>&1; then + echo "$NAME: skipped: $NAS_PATH not a git repo" + exit 0 +fi +if ! git -C "$NAS_PATH" remote get-url origin >/dev/null 2>&1; then + echo "$NAME: skipped: no origin remote at $NAS_PATH" + exit 0 +fi + +fetch_output="" +if ! fetch_output=$(git -C "$NAS_PATH" fetch origin --quiet 2>&1); then + reason="fetch failed" + [ -z "$fetch_output" ] || reason="$reason: $(first_line "$fetch_output")" + echo "$NAME: skipped: $reason" + exit 0 +fi + +default_branch() { + local ref branch + ref=$(git -C "$NAS_PATH" symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null || true) + if [ -n "$ref" ]; then + echo "${ref#origin/}" + return 0 + fi + for branch in main master; do + if git -C "$NAS_PATH" show-ref --verify --quiet "refs/heads/$branch"; then + echo "$branch" + return 0 + fi + done + return 1 +} + +DEFAULT=$(default_branch) || { + echo "$NAME: skipped: cannot determine default branch at $NAS_PATH" + exit 0 +} +BASE="origin/$DEFAULT" +if ! git -C "$NAS_PATH" rev-parse --verify --quiet "$BASE^{commit}" >/dev/null; then + echo "$NAME: skipped: $BASE does not exist at $NAS_PATH" + exit 0 +fi + +cur=$(git -C "$NAS_PATH" symbolic-ref --quiet --short HEAD 2>/dev/null || echo "") +dirty=no +[ -z "$(git -C "$NAS_PATH" status --porcelain 2>/dev/null | head -1)" ] || dirty=yes + +if [ "$cur" != "$DEFAULT" ]; then + echo "$NAME: STUCK: NAS checkout at $NAS_PATH is not on $DEFAULT - needs attention" + exit 0 +fi +if [ "$dirty" = yes ]; then + echo "$NAME: STUCK: NAS checkout at $NAS_PATH has uncommitted changes - needs attention" + exit 0 +fi + +local_rev="" +if ! local_rev=$(git -C "$NAS_PATH" rev-parse --verify --quiet "$DEFAULT"); then + echo "$NAME: skipped: cannot read local $DEFAULT at $NAS_PATH" + exit 0 +fi +remote_rev="" +if ! remote_rev=$(git -C "$NAS_PATH" rev-parse --verify --quiet "$BASE"); then + echo "$NAME: skipped: cannot read $BASE at $NAS_PATH" + exit 0 +fi +if [ "$local_rev" = "$remote_rev" ]; then + echo "$NAME: already current" + exit 0 +fi +if ! git -C "$NAS_PATH" merge-base --is-ancestor "$DEFAULT" "$BASE"; then + behind=$(git -C "$NAS_PATH" rev-list --count "HEAD..$BASE" 2>/dev/null) || behind="?" + echo "$NAME: STUCK: NAS checkout at $NAS_PATH diverged from $BASE, $behind commits behind - needs attention" + exit 0 +fi + +before=$(git -C "$NAS_PATH" rev-parse --short "$DEFAULT") +merge_output="" +if ! merge_output=$(git -C "$NAS_PATH" merge --ff-only "$BASE" 2>&1); then + reason="fast-forward failed" + [ -z "$merge_output" ] || reason="$reason: $(first_line "$merge_output")" + echo "$NAME: skipped: $reason" + exit 0 +fi +after=$(git -C "$NAS_PATH" rev-parse --short "$DEFAULT") + +# process_online : true when pm2 currently reports as online. +process_online() { + local proc=$1 status + status=$(pm2 jlist 2>/dev/null | jq -r --arg n "$proc" \ + '[.[] | select(.name == $n) | .pm2_env.status] | .[0] // "missing"' 2>/dev/null) || status="missing" + [ "$status" = "online" ] +} + +restart_problems="" +IFS=',' read -r -a procs <<< "$PROCS" +for proc in "${procs[@]}"; do + proc="${proc#"${proc%%[![:space:]]*}"}" + proc="${proc%"${proc##*[![:space:]]}"}" + [ -n "$proc" ] || continue + if ! pm2 restart "$proc" >/dev/null 2>&1; then + restart_problems="$restart_problems $proc(restart failed)" + continue + fi + if ! process_online "$proc"; then + restart_problems="$restart_problems $proc(not online after restart)" + fi +done + +if [ -n "$restart_problems" ]; then + echo "$NAME: synced $before..$after but restart verification failed:$restart_problems - needs attention" + exit 0 +fi +echo "$NAME: synced $before..$after, restarted $PROCS and verified online" diff --git a/bin/fm-teardown.sh b/bin/fm-teardown.sh index 6646cb162..5c168a8dc 100755 --- a/bin/fm-teardown.sh +++ b/bin/fm-teardown.sh @@ -1076,5 +1076,14 @@ rm -f "$STATE/$ID.status" "$STATE/$ID.turn-ended" "$STATE/$ID.check.sh" "$STATE/ if [ "$KIND" != scout ] && [ "$KIND" != secondmate ] && [ "$MODE" != local-only ]; then "$FM_ROOT/bin/fm-fleet-sync.sh" "$PROJ" || true fi +# Best-effort, non-blocking: a landed ship task (PR merged, or a local-only merge +# to local main) may have a live NAS deployment that also needs pulling and +# restarting - see bin/fm-nas-deploy-sync.sh's header. Scouts and secondmates +# never land a project change, so they are excluded the same as the fleet-sync +# call above; unlike that call, local-only IS included here, since a local-only +# merge is just as much a landed change as a PR merge. +if [ "$KIND" != scout ] && [ "$KIND" != secondmate ]; then + "$FM_ROOT/bin/fm-nas-deploy-sync.sh" "$(basename "$PROJ")" || true +fi echo "teardown $ID complete (window $T, worktree $WT)" backlog_refresh_reminder diff --git a/tests/fm-nas-deploy-sync.test.sh b/tests/fm-nas-deploy-sync.test.sh new file mode 100755 index 000000000..f16b9b170 --- /dev/null +++ b/tests/fm-nas-deploy-sync.test.sh @@ -0,0 +1,335 @@ +#!/usr/bin/env bash +# Behavior tests for fm-nas-deploy-sync.sh. +# +# A merged PR does not, by itself, reach the live site: every deployed app runs +# from its own separate checkout under /mnt/nas/experiments//, managed by +# pm2, independent from the projects/ clone firstmate/crewmates develop in +# (see the 2026-07-04 entry in data/learnings.md). fm-nas-deploy-sync.sh closes +# that gap: fast-forward the NAS checkout, restart and verify the pm2 +# process(es) that serve it - mirroring bin/fm-fleet-sync.sh's dirty/diverged +# safety exactly (never force, never touch a dirty or diverged checkout). +# +# This suite never touches a real /mnt/nas/experiments/ path or a real pm2 +# process: NAS checkouts are throwaway git repos under a per-test tmp home, and +# pm2 is a PATH-shimmed stub that records restarts and reports them online. +# +# Covers: +# - a clean, behind checkout fast-forwards and its pm2 process(es) restart and +# verify online +# - a dirty checkout is left completely untouched (reported, not fast-forwarded) +# - a diverged checkout is reported STUCK-style and left completely untouched +# - a project absent from data/nas-deployments.md (or the file itself absent) +# is a silent no-op: one skip line, exit 0 +# - bin/fm-teardown.sh's post-landing hook actually invokes the real script for +# a landed ship task, end to end (the NAS fixture fast-forwards and its pm2 +# process is restarted as a side effect of running teardown) +set -u + +# shellcheck source=tests/lib.sh disable=SC1091 +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" +fm_git_identity fmtest fmtest@example.invalid + +SYNC="$ROOT/bin/fm-nas-deploy-sync.sh" +TEARDOWN="$ROOT/bin/fm-teardown.sh" +TMP_ROOT=$(fm_test_tmproot fm-nas-deploy-sync-tests) +HOME_N=0 + +# --- generic fixtures -------------------------------------------------------- + +new_home() { + HOME_N=$((HOME_N + 1)) + local h="$TMP_ROOT/home-$HOME_N" + mkdir -p "$h/data" "$h/fakebin" + printf '%s\n' "$h" +} + +commit_file() { + local dir=$1 file=$2 content=$3 msg=$4 + printf '%s\n' "$content" > "$dir/$file" + git -C "$dir" add -- "$file" + git -C "$dir" commit -qm "$msg" +} + +# build_nas_pair : a bare "remote" origin plus a "work" repo wired to +# it (for advancing origin later) and a plain clone standing in for the NAS +# checkout, all with one commit on main. Echoes the NAS checkout path. +build_nas_pair() { + local home=$1 name=$2 work remote nas remote_abs + work="$home/work-$name" + remote="$home/remotes/$name.git" + nas="$home/nas-$name" + mkdir -p "$home/remotes" + + git init -q "$work" + git -C "$work" symbolic-ref HEAD refs/heads/main + commit_file "$work" file.txt v0 C0 + + git clone --quiet --bare "$work" "$remote" + remote_abs=$(cd "$remote" && pwd) + git -C "$work" remote add origin "file://$remote_abs" + git -C "$work" push -q -u origin main + + git clone --quiet "file://$remote_abs" "$nas" + printf '%s\n' "$nas" +} + +# advance_origin : push one more commit to 's origin via +# its work repo, so the NAS checkout (until synced) is behind origin/main. +advance_origin() { + local home=$1 name=$2 msg=$3 work + work="$home/work-$name" + commit_file "$work" file.txt "$msg" "$msg" + git -C "$work" push -q origin main +} + +head_sha() { git -C "$1" rev-parse HEAD; } + +# write_map : (re)write a one-row +# nas-deployments.md pipe table at $home/data/nas-deployments.md. +write_map() { + local home=$1 project=$2 nas_path=$3 procs=$4 + { + printf '| project | nas_repo_path | pm2_process(es) |\n' + printf '| --- | --- | --- |\n' + printf '| %s | %s | %s |\n' "$project" "$nas_path" "$procs" + } > "$home/data/nas-deployments.md" +} + +# write_pm2_stub : `pm2 restart ` appends to +# $restart_log and succeeds; `pm2 jlist` reports every logged name as online, so a +# process comes back "online" exactly when (and only when) it was restarted. +write_pm2_stub() { + local fakebin=$1 log=$2 + : > "$log" + cat > "$fakebin/pm2" <> "$log" + exit 0 + ;; + jlist) + printf '[' + first=1 + while IFS= read -r name; do + [ -n "\$name" ] || continue + [ "\$first" = 1 ] || printf ',' + first=0 + printf '{"name":"%s","pm2_env":{"status":"online"}}' "\$name" + done < "$log" + printf ']\n' + ;; + *) exit 0 ;; +esac +SH + chmod +x "$fakebin/pm2" +} + +# run_sync [args...]: run the sync script against an isolated home. +run_sync() { + local home=$1 + shift + FM_HOME="$home" PATH="$home/fakebin:$PATH" "$SYNC" "$@" +} + +# --- tests: standalone script ------------------------------------------------- + +test_clean_behind_fast_forwards_and_restarts() { + local home nas log out origin_head nas_head + home=$(new_home) + nas=$(build_nas_pair "$home" carscanner-test) + advance_origin "$home" carscanner-test C1 + write_map "$home" carscanner-test "$nas" carscanner-test + log="$home/restart.log" + write_pm2_stub "$home/fakebin" "$log" + + out=$(run_sync "$home" carscanner-test) + + assert_contains "$out" "carscanner-test: synced" "clean-behind: did not report a sync" + assert_contains "$out" "restarted carscanner-test and verified online" "clean-behind: did not report a verified restart" + origin_head=$(git -C "$home/work-carscanner-test" rev-parse main) + nas_head=$(head_sha "$nas") + [ "$origin_head" = "$nas_head" ] || fail "clean-behind: NAS checkout did not fast-forward to origin" + assert_grep carscanner-test "$log" "clean-behind: pm2 restart was not invoked" + pass "a clean, behind NAS checkout fast-forwards and its pm2 process restarts and verifies online" +} + +test_dirty_is_skipped_untouched() { + local home nas log out before after + home=$(new_home) + nas=$(build_nas_pair "$home" dirty-test) + advance_origin "$home" dirty-test C1 + before=$(head_sha "$nas") + printf 'uncommitted\n' >> "$nas/file.txt" + write_map "$home" dirty-test "$nas" dirty-test + log="$home/restart.log" + write_pm2_stub "$home/fakebin" "$log" + + out=$(run_sync "$home" dirty-test) + + assert_contains "$out" "STUCK" "dirty: did not report STUCK" + assert_contains "$out" "uncommitted changes" "dirty: did not name the dirty reason" + after=$(head_sha "$nas") + [ "$before" = "$after" ] || fail "dirty: NAS checkout HEAD moved despite being dirty" + git -C "$nas" diff --quiet -- file.txt && fail "dirty: uncommitted change was discarded" + [ ! -s "$log" ] || fail "dirty: pm2 restart was invoked on an untouched checkout" + pass "a dirty NAS checkout is reported STUCK and left completely untouched" +} + +test_diverged_is_stuck_untouched() { + local home nas log out before after + home=$(new_home) + nas=$(build_nas_pair "$home" diverged-test) + advance_origin "$home" diverged-test C1 + commit_file "$nas" local-only.txt local "local-only commit not on origin" + before=$(head_sha "$nas") + write_map "$home" diverged-test "$nas" diverged-test + log="$home/restart.log" + write_pm2_stub "$home/fakebin" "$log" + + out=$(run_sync "$home" diverged-test) + + assert_contains "$out" "STUCK" "diverged: did not report STUCK" + assert_contains "$out" "diverged" "diverged: did not name the diverged reason" + after=$(head_sha "$nas") + [ "$before" = "$after" ] || fail "diverged: NAS checkout HEAD moved despite diverging" + [ ! -s "$log" ] || fail "diverged: pm2 restart was invoked on an untouched checkout" + pass "a diverged NAS checkout is reported STUCK and left completely untouched" +} + +test_absent_project_is_silent_noop() { + local home nas out rc + home=$(new_home) + nas=$(build_nas_pair "$home" tracked-test) + write_map "$home" tracked-test "$nas" tracked-test + + set +e + out=$(run_sync "$home" untracked-project) + rc=$? + set -e + + expect_code 0 "$rc" "absent: sync should exit 0 for a project with no recorded deployment" + assert_contains "$out" "untracked-project: skipped: no recorded NAS deployment" "absent: did not report the expected skip line" + pass "a project absent from data/nas-deployments.md is a silent no-op" +} + +test_missing_map_file_is_silent_noop() { + local home out rc + home=$(new_home) + rm -f "$home/data/nas-deployments.md" + + set +e + out=$(run_sync "$home" anything) + rc=$? + set -e + + expect_code 0 "$rc" "no-map: sync should exit 0 when data/nas-deployments.md does not exist" + assert_contains "$out" "anything: skipped: no recorded NAS deployment" "no-map: did not report the expected skip line" + pass "a project checked with no data/nas-deployments.md at all is a silent no-op" +} + +# --- tests: teardown integration --------------------------------------------- + +# make_teardown_case : a trimmed fixture mirroring tests/fm-teardown.test.sh +# make_case's ALLOW path (no-mistakes mode, task branch pushed to origin), plus a +# NAS-fixture pair and a nas-deployments.md row for the project so the real +# post-teardown fm-nas-deploy-sync.sh call has real work to do. Echoes the case dir. +make_teardown_case() { + local name=$1 case_dir fakebin nas + case_dir="$TMP_ROOT/teardown-$name" + fakebin="$case_dir/fakebin" + mkdir -p "$case_dir/state" "$case_dir/config" "$case_dir/data" "$fakebin" + + cat > "$fakebin/treehouse" <<'SH' +#!/usr/bin/env bash +exit 0 +SH + cat > "$fakebin/tmux" <<'SH' +#!/usr/bin/env bash +exit 0 +SH + cat > "$fakebin/gh-axi" <<'SH' +#!/usr/bin/env bash +case "${1:-} ${2:-}" in + "pr list") printf '%s\n' "count: 0 (showing first 0)" "pull_requests[]: []" ; exit 0 ;; + "pr view") echo "error: pull request not found" >&2 ; exit 1 ;; +esac +exit 0 +SH + cat > "$fakebin/gh" <<'SH' +#!/usr/bin/env bash +case "${1:-} ${2:-}" in + "pr view") echo "error: pull request not found" >&2 ; exit 1 ;; +esac +exit 0 +SH + chmod +x "$fakebin/treehouse" "$fakebin/tmux" "$fakebin/gh-axi" "$fakebin/gh" + + git init -q --bare "$case_dir/origin.git" + git -C "$case_dir/origin.git" symbolic-ref HEAD refs/heads/main + git clone -q "$case_dir/origin.git" "$case_dir/_seed" 2>/dev/null + git -C "$case_dir/_seed" commit -q --allow-empty -m "origin baseline" + git -C "$case_dir/_seed" push -q origin main + rm -rf "$case_dir/_seed" + git clone -q "$case_dir/origin.git" "$case_dir/project" + git -C "$case_dir/project" remote set-head origin main 2>/dev/null || true + git -C "$case_dir/project" worktree add -q -b fm/task-x1 "$case_dir/wt" main + + touch "$case_dir/state/.last-watcher-beat" + + fm_write_meta "$case_dir/state/task-x1.meta" \ + "window=fm-task-x1" \ + "worktree=$case_dir/wt" \ + "project=$case_dir/project" \ + "kind=ship" \ + "mode=no-mistakes" + + git -C "$case_dir/wt" commit -q --allow-empty -m "shippable work" + git -C "$case_dir/wt" push -q origin fm/task-x1 + git -C "$case_dir/project" fetch -q origin + + nas=$(build_nas_pair "$case_dir" project-nas) + advance_origin "$case_dir" project-nas C1 + write_map "$case_dir" project "$nas" project-nas + write_pm2_stub "$fakebin" "$case_dir/restart.log" + + printf '%s\n' "$case_dir" +} + +run_teardown() { + local case_dir=$1 + FM_ROOT_OVERRIDE="$ROOT" \ + FM_STATE_OVERRIDE="$case_dir/state" \ + FM_CONFIG_OVERRIDE="$case_dir/config" \ + FM_NAS_DEPLOYMENTS_OVERRIDE="$case_dir/data/nas-deployments.md" \ + PATH="$case_dir/fakebin:$PATH" \ + "$TEARDOWN" task-x1 +} + +test_teardown_invokes_nas_deploy_sync_for_landed_project() { + local case_dir rc origin_head nas_head + case_dir=$(make_teardown_case invokes) + + set +e + run_teardown "$case_dir" > "$case_dir/stdout" 2> "$case_dir/stderr" + rc=$? + set -e + + expect_code 0 "$rc" "teardown-invokes: teardown should succeed for landed work" + ! grep -q REFUSED "$case_dir/stderr" || fail "teardown-invokes: teardown printed a REFUSED line" + + origin_head=$(git -C "$case_dir/work-project-nas" rev-parse main) + nas_head=$(head_sha "$case_dir/nas-project-nas") + [ "$origin_head" = "$nas_head" ] \ + || fail "teardown-invokes: post-teardown hook did not fast-forward the NAS fixture" + assert_grep project-nas "$case_dir/restart.log" \ + "teardown-invokes: post-teardown hook did not restart the NAS deployment's pm2 process" + pass "a landed ship-task teardown invokes fm-nas-deploy-sync.sh, which syncs and restarts the live NAS deployment" +} + +test_clean_behind_fast_forwards_and_restarts +test_dirty_is_skipped_untouched +test_diverged_is_stuck_untouched +test_absent_project_is_silent_noop +test_missing_map_file_is_silent_noop +test_teardown_invokes_nas_deploy_sync_for_landed_project From 5d46b6f4196a1e433ae9f9c1e49fea8f64bc91c5 Mon Sep 17 00:00:00 2001 From: Michael Date: Sat, 11 Jul 2026 20:31:13 -0400 Subject: [PATCH 27/46] no-mistakes(review): docs: document fm-nas-deploy-sync.sh script and override var --- docs/configuration.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/configuration.md b/docs/configuration.md index 63b97f7c8..07698164b 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -405,6 +405,7 @@ FM_TIER_HEAVY_MIN_FILES=8 # fm-tier-guard.sh: changed-file count that e FM_WATCH_TRIAGE_LOG_MAX_BYTES=262144 # size cap for the watcher's absorbed-wake debug log FM_FLEET_SYNC_BOOTSTRAP_TIMEOUT= # optional seconds allowed for bootstrap's best-effort clone refresh; unset/blank defaults to max(20, 5 + 3 * origin-backed-project-count) FM_FLEET_PRUNE=1 # set to 0 to skip pruning local branches whose upstream is gone +FM_NAS_DEPLOYMENTS_OVERRIDE= # alternate data/nas-deployments.md path for fm-nas-deploy-sync.sh, mainly for tests FM_STALE_WORKTREE_LOCK_AGE_SECS=30 # min mtime age before fm-teardown.sh treats a leftover worktree git index.lock as provably stale FM_TREEHOUSE_RETURN_LOCK_RETRIES=3 # retries after a treehouse return fails on the transient git index.lock signature FM_TREEHOUSE_RETURN_LOCK_RETRY_WAIT_SECS=1 # seconds fm-teardown.sh waits before each retry after that signature From 0883a493aad9eb6ff608b6b42df9e43e3b8a295c Mon Sep 17 00:00:00 2001 From: Michael Date: Sat, 11 Jul 2026 21:46:31 -0400 Subject: [PATCH 28/46] no-mistakes(review): Sandbox teardown tests' NAS deploy sync lookup file --- tests/fm-teardown.test.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/fm-teardown.test.sh b/tests/fm-teardown.test.sh index bf9dbd7fc..b49a5d778 100755 --- a/tests/fm-teardown.test.sh +++ b/tests/fm-teardown.test.sh @@ -526,6 +526,7 @@ run_teardown() { FM_ROOT_OVERRIDE="$ROOT" \ FM_STATE_OVERRIDE="$case_dir/state" \ FM_CONFIG_OVERRIDE="$case_dir/config" \ + FM_NAS_DEPLOYMENTS_OVERRIDE="$case_dir/data/nas-deployments.md" \ PATH="$case_dir/fakebin:$PATH" \ "$TEARDOWN" task-x1 "$@" } From 5c9b4f1a09a258c4593b2de43e94366771fce92c Mon Sep 17 00:00:00 2001 From: Michael Date: Sat, 11 Jul 2026 22:00:06 -0400 Subject: [PATCH 29/46] no-mistakes(review): Aggregate pm2 cluster status and stub NAS sync in gotmp tests --- bin/fm-nas-deploy-sync.sh | 10 ++++++-- tests/fm-gotmp.test.sh | 12 +++++++++ tests/fm-nas-deploy-sync.test.sh | 44 ++++++++++++++++++++++++++++++++ 3 files changed, 64 insertions(+), 2 deletions(-) diff --git a/bin/fm-nas-deploy-sync.sh b/bin/fm-nas-deploy-sync.sh index 8899703d0..0608abfb5 100755 --- a/bin/fm-nas-deploy-sync.sh +++ b/bin/fm-nas-deploy-sync.sh @@ -161,11 +161,17 @@ if ! merge_output=$(git -C "$NAS_PATH" merge --ff-only "$BASE" 2>&1); then fi after=$(git -C "$NAS_PATH" rev-parse --short "$DEFAULT") -# process_online : true when pm2 currently reports as online. +# process_online : true when pm2 currently reports EVERY instance of +# as online (a clustered app can register several jlist entries under +# the same name; one lagging instance must not be masked by the others). process_online() { local proc=$1 status status=$(pm2 jlist 2>/dev/null | jq -r --arg n "$proc" \ - '[.[] | select(.name == $n) | .pm2_env.status] | .[0] // "missing"' 2>/dev/null) || status="missing" + '[.[] | select(.name == $n) | .pm2_env.status] as $s + | if ($s | length) == 0 then "missing" + elif all($s[]; . == "online") then "online" + else "not-online" + end' 2>/dev/null) || status="missing" [ "$status" = "online" ] } diff --git a/tests/fm-gotmp.test.sh b/tests/fm-gotmp.test.sh index 6b6fd8aa1..2a4821da9 100755 --- a/tests/fm-gotmp.test.sh +++ b/tests/fm-gotmp.test.sh @@ -75,6 +75,13 @@ SH exit 0 SH chmod +x "$fake/bin/fm-fleet-sync.sh" + # fm-nas-deploy-sync.sh: stub (called for the same non-scout/non-local-only + # teardowns, right after fm-fleet-sync.sh). + cat > "$fake/bin/fm-nas-deploy-sync.sh" <<'SH' +#!/usr/bin/env bash +exit 0 +SH + chmod +x "$fake/bin/fm-nas-deploy-sync.sh" # fm-tasks-axi-lib.sh: stub (teardown sources it). Report no backend so # backlog_refresh_reminder takes the plain-message path; no tasks-axi here. cat > "$fake/bin/fm-tasks-axi-lib.sh" <<'SH' @@ -171,6 +178,11 @@ SH exit 0 SH chmod +x "$fake/bin/fm-fleet-sync.sh" + cat > "$fake/bin/fm-nas-deploy-sync.sh" <<'SH' +#!/usr/bin/env bash +exit 0 +SH + chmod +x "$fake/bin/fm-nas-deploy-sync.sh" cat > "$fake/bin/fm-tasks-axi-lib.sh" <<'SH' fm_tasks_axi_backend_available() { return 1; } SH diff --git a/tests/fm-nas-deploy-sync.test.sh b/tests/fm-nas-deploy-sync.test.sh index f16b9b170..4870148c3 100755 --- a/tests/fm-nas-deploy-sync.test.sh +++ b/tests/fm-nas-deploy-sync.test.sh @@ -125,6 +125,29 @@ SH chmod +x "$fakebin/pm2" } +# write_pm2_cluster_stub : like write_pm2_stub, but +# `pm2 jlist` always reports TWO instances of - one online, one stopped - +# regardless of what was restarted, simulating a clustered pm2 app where one +# instance in the cluster never comes back after a restart. +write_pm2_cluster_stub() { + local fakebin=$1 log=$2 proc=$3 + : > "$log" + cat > "$fakebin/pm2" <> "$log" + exit 0 + ;; + jlist) + printf '[{"name":"%s","pm2_env":{"status":"online"}},{"name":"%s","pm2_env":{"status":"stopped"}}]\n' "$proc" "$proc" + ;; + *) exit 0 ;; +esac +SH + chmod +x "$fakebin/pm2" +} + # run_sync [args...]: run the sync script against an isolated home. run_sync() { local home=$1 @@ -213,6 +236,26 @@ test_absent_project_is_silent_noop() { pass "a project absent from data/nas-deployments.md is a silent no-op" } +test_clustered_partial_restart_is_not_masked_online() { + local home nas log out + home=$(new_home) + nas=$(build_nas_pair "$home" cluster-test) + advance_origin "$home" cluster-test C1 + write_map "$home" cluster-test "$nas" cluster-test + log="$home/restart.log" + write_pm2_cluster_stub "$home/fakebin" "$log" cluster-test + + out=$(run_sync "$home" cluster-test) + + assert_contains "$out" "not online after restart" \ + "clustered: did not report the lagging cluster instance as a restart problem" + assert_contains "$out" "needs attention" "clustered: did not flag the deployment as needing attention" + case "$out" in + *"verified online"*) fail "clustered: a lagging cluster instance was masked as fully verified online" ;; + esac + pass "a clustered pm2 app with one lagging instance is reported as a restart problem, not masked as online" +} + test_missing_map_file_is_silent_noop() { local home out rc home=$(new_home) @@ -330,6 +373,7 @@ test_teardown_invokes_nas_deploy_sync_for_landed_project() { test_clean_behind_fast_forwards_and_restarts test_dirty_is_skipped_untouched test_diverged_is_stuck_untouched +test_clustered_partial_restart_is_not_masked_online test_absent_project_is_silent_noop test_missing_map_file_is_silent_noop test_teardown_invokes_nas_deploy_sync_for_landed_project From 05abc0e6883bb2a2265d57266198f9a02c335bff Mon Sep 17 00:00:00 2001 From: Michael Date: Sat, 11 Jul 2026 22:15:31 -0400 Subject: [PATCH 30/46] no-mistakes(review): Bound NAS deploy sync git/fs calls with a timeout --- bin/fm-nas-deploy-sync.sh | 61 +++++++++++++++++++++++++++++---------- 1 file changed, 45 insertions(+), 16 deletions(-) diff --git a/bin/fm-nas-deploy-sync.sh b/bin/fm-nas-deploy-sync.sh index 0608abfb5..d8d47c4d6 100755 --- a/bin/fm-nas-deploy-sync.sh +++ b/bin/fm-nas-deploy-sync.sh @@ -27,6 +27,12 @@ # an alternate mapping file directly. pm2 is invoked via PATH, so a fakebin shim # ahead of it on PATH intercepts restart/list during tests - real tests must never # reach the real /mnt/nas/experiments or a real pm2 process. +# +# Every filesystem/git touch of $NAS_PATH is wrapped in a bounded timeout +# (FM_NAS_SYNC_TIMEOUT seconds, default 15): the mount is a NAS, and an +# unreachable one is a hang risk (a stuck stat/read), not a fast failure, which +# would otherwise stall fm-teardown.sh's caller and break this script's +# non-blocking contract. set -eu SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -34,6 +40,29 @@ FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" DATA="${FM_DATA_OVERRIDE:-$FM_HOME/data}" MAP="${FM_NAS_DEPLOYMENTS_OVERRIDE:-$DATA/nas-deployments.md}" +NAS_SYNC_TIMEOUT="${FM_NAS_SYNC_TIMEOUT:-15}" + +HAVE_TIMEOUT=none +if command -v timeout >/dev/null 2>&1; then + HAVE_TIMEOUT=timeout +elif command -v gtimeout >/dev/null 2>&1; then + HAVE_TIMEOUT=gtimeout +fi + +# bounded : run , killed after $NAS_SYNC_TIMEOUT seconds when +# a `timeout`/`gtimeout` binary is available, run directly otherwise. +bounded() { + case "$HAVE_TIMEOUT" in + timeout) timeout "$NAS_SYNC_TIMEOUT" "$@" ;; + gtimeout) gtimeout "$NAS_SYNC_TIMEOUT" "$@" ;; + *) "$@" ;; + esac +} + +# git_nas : git -C "$NAS_PATH" , bounded. +git_nas() { + bounded git -C "$NAS_PATH" "$@" +} usage() { echo "usage: fm-nas-deploy-sync.sh " >&2 @@ -71,21 +100,21 @@ fi NAS_PATH=${row%%|*} PROCS=${row#*|} -if [ ! -d "$NAS_PATH" ]; then +if ! bounded test -d "$NAS_PATH"; then echo "$NAME: skipped: NAS path $NAS_PATH not a directory" exit 0 fi -if ! git -C "$NAS_PATH" rev-parse --is-inside-work-tree >/dev/null 2>&1; then +if ! git_nas rev-parse --is-inside-work-tree >/dev/null 2>&1; then echo "$NAME: skipped: $NAS_PATH not a git repo" exit 0 fi -if ! git -C "$NAS_PATH" remote get-url origin >/dev/null 2>&1; then +if ! git_nas remote get-url origin >/dev/null 2>&1; then echo "$NAME: skipped: no origin remote at $NAS_PATH" exit 0 fi fetch_output="" -if ! fetch_output=$(git -C "$NAS_PATH" fetch origin --quiet 2>&1); then +if ! fetch_output=$(git_nas fetch origin --quiet 2>&1); then reason="fetch failed" [ -z "$fetch_output" ] || reason="$reason: $(first_line "$fetch_output")" echo "$NAME: skipped: $reason" @@ -94,13 +123,13 @@ fi default_branch() { local ref branch - ref=$(git -C "$NAS_PATH" symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null || true) + ref=$(git_nas symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null || true) if [ -n "$ref" ]; then echo "${ref#origin/}" return 0 fi for branch in main master; do - if git -C "$NAS_PATH" show-ref --verify --quiet "refs/heads/$branch"; then + if git_nas show-ref --verify --quiet "refs/heads/$branch"; then echo "$branch" return 0 fi @@ -113,14 +142,14 @@ DEFAULT=$(default_branch) || { exit 0 } BASE="origin/$DEFAULT" -if ! git -C "$NAS_PATH" rev-parse --verify --quiet "$BASE^{commit}" >/dev/null; then +if ! git_nas rev-parse --verify --quiet "$BASE^{commit}" >/dev/null; then echo "$NAME: skipped: $BASE does not exist at $NAS_PATH" exit 0 fi -cur=$(git -C "$NAS_PATH" symbolic-ref --quiet --short HEAD 2>/dev/null || echo "") +cur=$(git_nas symbolic-ref --quiet --short HEAD 2>/dev/null || echo "") dirty=no -[ -z "$(git -C "$NAS_PATH" status --porcelain 2>/dev/null | head -1)" ] || dirty=yes +[ -z "$(git_nas status --porcelain 2>/dev/null | head -1)" ] || dirty=yes if [ "$cur" != "$DEFAULT" ]; then echo "$NAME: STUCK: NAS checkout at $NAS_PATH is not on $DEFAULT - needs attention" @@ -132,12 +161,12 @@ if [ "$dirty" = yes ]; then fi local_rev="" -if ! local_rev=$(git -C "$NAS_PATH" rev-parse --verify --quiet "$DEFAULT"); then +if ! local_rev=$(git_nas rev-parse --verify --quiet "$DEFAULT"); then echo "$NAME: skipped: cannot read local $DEFAULT at $NAS_PATH" exit 0 fi remote_rev="" -if ! remote_rev=$(git -C "$NAS_PATH" rev-parse --verify --quiet "$BASE"); then +if ! remote_rev=$(git_nas rev-parse --verify --quiet "$BASE"); then echo "$NAME: skipped: cannot read $BASE at $NAS_PATH" exit 0 fi @@ -145,21 +174,21 @@ if [ "$local_rev" = "$remote_rev" ]; then echo "$NAME: already current" exit 0 fi -if ! git -C "$NAS_PATH" merge-base --is-ancestor "$DEFAULT" "$BASE"; then - behind=$(git -C "$NAS_PATH" rev-list --count "HEAD..$BASE" 2>/dev/null) || behind="?" +if ! git_nas merge-base --is-ancestor "$DEFAULT" "$BASE"; then + behind=$(git_nas rev-list --count "HEAD..$BASE" 2>/dev/null) || behind="?" echo "$NAME: STUCK: NAS checkout at $NAS_PATH diverged from $BASE, $behind commits behind - needs attention" exit 0 fi -before=$(git -C "$NAS_PATH" rev-parse --short "$DEFAULT") +before=$(git_nas rev-parse --short "$DEFAULT") merge_output="" -if ! merge_output=$(git -C "$NAS_PATH" merge --ff-only "$BASE" 2>&1); then +if ! merge_output=$(git_nas merge --ff-only "$BASE" 2>&1); then reason="fast-forward failed" [ -z "$merge_output" ] || reason="$reason: $(first_line "$merge_output")" echo "$NAME: skipped: $reason" exit 0 fi -after=$(git -C "$NAS_PATH" rev-parse --short "$DEFAULT") +after=$(git_nas rev-parse --short "$DEFAULT") # process_online : true when pm2 currently reports EVERY instance of # as online (a clustered app can register several jlist entries under From e334ca3c02bf3a7d00a079aba777473f69263024 Mon Sep 17 00:00:00 2001 From: Michael Date: Sat, 11 Jul 2026 22:41:42 -0400 Subject: [PATCH 31/46] no-mistakes(review): Guard NAS sync fetch race, document timeout knob, add tests --- bin/fm-nas-deploy-sync.sh | 96 +++++++++++++++++++++++++++++++- docs/configuration.md | 4 ++ tests/fm-nas-deploy-sync.test.sh | 94 +++++++++++++++++++++++++++++++ 3 files changed, 191 insertions(+), 3 deletions(-) diff --git a/bin/fm-nas-deploy-sync.sh b/bin/fm-nas-deploy-sync.sh index d8d47c4d6..1233dc9f7 100755 --- a/bin/fm-nas-deploy-sync.sh +++ b/bin/fm-nas-deploy-sync.sh @@ -33,6 +33,15 @@ # unreachable one is a hang risk (a stuck stat/read), not a fast failure, which # would otherwise stall fm-teardown.sh's caller and break this script's # non-blocking contract. +# +# $NAS_PATH is one single shared live-deployment checkout - unlike a project's +# projects/ dev clone, which is scoped to one firstmate home, two ship +# tasks for the same project landing moments apart can both reach the SAME +# NAS checkout from concurrent teardowns. That races the fetch on an orphaned +# .git/packed-refs.lock exactly like bin/fm-fleet-sync.sh's fetch had to guard +# against; fetch_with_packed_refs_lock_guard below mirrors that recovery, +# sharing the staleness proof from bin/fm-lock-lib.sh, bounded by +# FM_NAS_SYNC_PACKED_REFS_LOCK_RETRIES / _RETRY_WAIT_SECS / _AGE_SECS. set -eu SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -41,6 +50,19 @@ FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" DATA="${FM_DATA_OVERRIDE:-$FM_HOME/data}" MAP="${FM_NAS_DEPLOYMENTS_OVERRIDE:-$DATA/nas-deployments.md}" NAS_SYNC_TIMEOUT="${FM_NAS_SYNC_TIMEOUT:-15}" +# shellcheck source=bin/fm-lock-lib.sh +. "$SCRIPT_DIR/fm-lock-lib.sh" +FM_LOCK_LOG_PREFIX=nas-deploy-sync + +NAS_SYNC_PACKED_REFS_LOCK_RETRIES=${FM_NAS_SYNC_PACKED_REFS_LOCK_RETRIES:-3} +NAS_SYNC_PACKED_REFS_LOCK_RETRY_WAIT_SECS=${FM_NAS_SYNC_PACKED_REFS_LOCK_RETRY_WAIT_SECS:-1} +NAS_SYNC_PACKED_REFS_LOCK_AGE_SECS=${FM_NAS_SYNC_PACKED_REFS_LOCK_AGE_SECS:-30} +case "$NAS_SYNC_PACKED_REFS_LOCK_RETRIES" in ''|*[!0-9]*) NAS_SYNC_PACKED_REFS_LOCK_RETRIES=3 ;; esac +case "$NAS_SYNC_PACKED_REFS_LOCK_AGE_SECS" in ''|*[!0-9]*) NAS_SYNC_PACKED_REFS_LOCK_AGE_SECS=30 ;; esac +if ! [[ "$NAS_SYNC_PACKED_REFS_LOCK_RETRY_WAIT_SECS" =~ ^([0-9]+([.][0-9]*)?|[.][0-9]+)$ ]]; then + echo "nas-deploy-sync: invalid packed-refs lock retry wait '$NAS_SYNC_PACKED_REFS_LOCK_RETRY_WAIT_SECS'; using 1s" >&2 + NAS_SYNC_PACKED_REFS_LOCK_RETRY_WAIT_SECS=1 +fi HAVE_TIMEOUT=none if command -v timeout >/dev/null 2>&1; then @@ -79,6 +101,75 @@ first_line() { printf '%s\n' "$1" | sed -n '1s/[[:space:]]\{1,\}/ /g;1p' } +# True when git stderr shows the packed-refs.lock "File exists" race, mirroring +# bin/fm-fleet-sync.sh's is_packed_refs_lock_error. +is_packed_refs_lock_error() { + printf '%s\n' "$1" | grep -Eq "Unable to create ['\"].*packed-refs\\.lock['\"]: File exists" +} + +# Absolute path to $NAS_PATH's packed-refs.lock, or empty when it cannot be +# resolved. +packed_refs_lock_path() { + local lock abs + lock=$(git_nas rev-parse --git-path packed-refs.lock 2>/dev/null) || return 1 + [ -n "$lock" ] || return 1 + case "$lock" in + /*) printf '%s\n' "$lock" ;; + *) + abs=$(cd "$NAS_PATH" && pwd -P) || return 1 + printf '%s/%s\n' "$abs" "$lock" + ;; + esac +} + +# Run `git_nas fetch origin --quiet`, tolerating an orphaned packed-refs.lock +# left by a second landed-teardown racing this exact NAS checkout. Sets +# FETCH_OUTPUT and returns the fetch's exit status; mirrors bin/fm-fleet-sync.sh's +# fetch_with_packed_refs_lock_guard retry-then-provably-stale-clear contract, +# sharing the staleness proof from bin/fm-lock-lib.sh. +fetch_with_packed_refs_lock_guard() { + local rc attempt=0 lock lock_desc + FETCH_OUTPUT=$(git_nas fetch origin --quiet 2>&1); rc=$? + [ "$rc" -eq 0 ] && return 0 + is_packed_refs_lock_error "$FETCH_OUTPUT" || return "$rc" + + lock=$(packed_refs_lock_path) || lock="" + lock_desc=${lock:-packed-refs.lock} + while [ "$attempt" -lt "$NAS_SYNC_PACKED_REFS_LOCK_RETRIES" ]; do + attempt=$(( attempt + 1 )) + echo "$NAME: fetch blocked by packed-refs lock ($lock_desc) at $NAS_PATH; waiting ${NAS_SYNC_PACKED_REFS_LOCK_RETRY_WAIT_SECS}s and retrying ($attempt/${NAS_SYNC_PACKED_REFS_LOCK_RETRIES}) (owning process may be exiting)" >&2 + sleep "$NAS_SYNC_PACKED_REFS_LOCK_RETRY_WAIT_SECS" + FETCH_OUTPUT=$(git_nas fetch origin --quiet 2>&1); rc=$? + if [ "$rc" -eq 0 ]; then + echo "$NAME: fetch succeeded on retry; packed-refs lock cleared on its own" >&2 + return 0 + fi + is_packed_refs_lock_error "$FETCH_OUTPUT" || return "$rc" + done + + # Retries exhausted and still the lock signature. Clear ONLY if provably stale. + lock=$(packed_refs_lock_path) || lock="" + if [ -n "$lock" ] && [ -e "$lock" ]; then + if fm_lock_is_provably_stale "$lock" "$NAS_PATH" "$NAS_SYNC_PACKED_REFS_LOCK_AGE_SECS"; then + if ! rm -f "$lock"; then + echo "$NAME: failed to remove provably-stale packed-refs lock $lock; leaving it in place" >&2 + return "$rc" + fi + echo "$NAME: removed provably-stale packed-refs lock $lock (age >= ${NAS_SYNC_PACKED_REFS_LOCK_AGE_SECS}s, no live holder) and retrying fetch" >&2 + FETCH_OUTPUT=$(git_nas fetch origin --quiet 2>&1); rc=$? + if [ "$rc" -eq 0 ]; then + echo "$NAME: fetch succeeded after stale packed-refs lock cleanup" >&2 + return 0 + fi + return "$rc" + fi + echo "$NAME: fetch blocked by packed-refs lock $lock that persisted across ${NAS_SYNC_PACKED_REFS_LOCK_RETRIES} retries and is not provably stale (may belong to a live process); leaving it in place" >&2 + return "$rc" + fi + echo "$NAME: fetch packed-refs lock signature persisted across ${NAS_SYNC_PACKED_REFS_LOCK_RETRIES} retries even after the lock file disappeared" >&2 + return "$rc" +} + # lookup_row: print "|" for $NAME's row in $MAP, or # nothing when the file is absent or has no matching row. lookup_row() { @@ -113,10 +204,9 @@ if ! git_nas remote get-url origin >/dev/null 2>&1; then exit 0 fi -fetch_output="" -if ! fetch_output=$(git_nas fetch origin --quiet 2>&1); then +if ! fetch_with_packed_refs_lock_guard; then reason="fetch failed" - [ -z "$fetch_output" ] || reason="$reason: $(first_line "$fetch_output")" + [ -z "$FETCH_OUTPUT" ] || reason="$reason: $(first_line "$FETCH_OUTPUT")" echo "$NAME: skipped: $reason" exit 0 fi diff --git a/docs/configuration.md b/docs/configuration.md index 07698164b..881b162d2 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -406,6 +406,10 @@ FM_WATCH_TRIAGE_LOG_MAX_BYTES=262144 # size cap for the watcher's absorbed-wak FM_FLEET_SYNC_BOOTSTRAP_TIMEOUT= # optional seconds allowed for bootstrap's best-effort clone refresh; unset/blank defaults to max(20, 5 + 3 * origin-backed-project-count) FM_FLEET_PRUNE=1 # set to 0 to skip pruning local branches whose upstream is gone FM_NAS_DEPLOYMENTS_OVERRIDE= # alternate data/nas-deployments.md path for fm-nas-deploy-sync.sh, mainly for tests +FM_NAS_SYNC_TIMEOUT=15 # seconds allowed per filesystem/git touch of fm-nas-deploy-sync.sh's $NAS_PATH before it is killed, so an unreachable NAS mount cannot hang fm-teardown.sh's caller +FM_NAS_SYNC_PACKED_REFS_LOCK_RETRIES=3 # fetch retries after fm-nas-deploy-sync.sh hits the orphaned .git/packed-refs.lock signature on the shared NAS checkout (two landed teardowns for the same project can race it) +FM_NAS_SYNC_PACKED_REFS_LOCK_RETRY_WAIT_SECS=1 # seconds fm-nas-deploy-sync.sh waits before each of those retries +FM_NAS_SYNC_PACKED_REFS_LOCK_AGE_SECS=30 # min mtime age before fm-nas-deploy-sync.sh treats a leftover packed-refs.lock as provably stale FM_STALE_WORKTREE_LOCK_AGE_SECS=30 # min mtime age before fm-teardown.sh treats a leftover worktree git index.lock as provably stale FM_TREEHOUSE_RETURN_LOCK_RETRIES=3 # retries after a treehouse return fails on the transient git index.lock signature FM_TREEHOUSE_RETURN_LOCK_RETRY_WAIT_SECS=1 # seconds fm-teardown.sh waits before each retry after that signature diff --git a/tests/fm-nas-deploy-sync.test.sh b/tests/fm-nas-deploy-sync.test.sh index 4870148c3..ecffe6e29 100755 --- a/tests/fm-nas-deploy-sync.test.sh +++ b/tests/fm-nas-deploy-sync.test.sh @@ -155,6 +155,53 @@ run_sync() { FM_HOME="$home" PATH="$home/fakebin:$PATH" "$SYNC" "$@" } +# write_git_hung_fetch_stub : any `fetch` call hangs indefinitely +# (well past FM_NAS_SYNC_TIMEOUT), so only the bounded wrapper's `timeout` kill +# can make the sync return promptly; every other call delegates to the real git. +write_git_hung_fetch_stub() { + local fakebin=$1 realgit + realgit=$(command -v git) + cat > "$fakebin/git" < : fail the FIRST +# `fetch` with the packed-refs.lock "File exists" signature, then delegate every +# later call - including the retried fetch - to the real git. Mirrors +# tests/fm-fleet-sync.test.sh's git_transient_packed_refs_lock, adapted to fake +# the stderr signature directly since fm-nas-deploy-sync.sh's fetch has no +# --prune to organically trigger a real packed-refs rewrite. +write_git_transient_packed_refs_lock_stub() { + local fakebin=$1 counter=$2 realgit + realgit=$(command -v git) + cat > "$fakebin/git" </dev/null || echo 0); n=\$(( n + 1 )) + printf '%s\n' "\$n" > "$counter" + if [ "\$n" -eq 1 ]; then + echo "error: could not delete reference refs/remotes/origin/feature: Unable to create '.git/packed-refs.lock': File exists." >&2 + exit 1 + fi +fi +exec "\$real" "\$@" +SH + chmod +x "$fakebin/git" +} + # --- tests: standalone script ------------------------------------------------- test_clean_behind_fast_forwards_and_restarts() { @@ -256,6 +303,51 @@ test_clustered_partial_restart_is_not_masked_online() { pass "a clustered pm2 app with one lagging instance is reported as a restart problem, not masked as online" } +test_hung_fetch_is_bounded_by_timeout() { + local home nas out rc start elapsed + if ! command -v timeout >/dev/null 2>&1 && ! command -v gtimeout >/dev/null 2>&1; then + pass "SKIP (no timeout/gtimeout binary): hung-fetch timeout bound check" + return + fi + home=$(new_home) + nas=$(build_nas_pair "$home" hungfetch-test) + advance_origin "$home" hungfetch-test C1 + write_map "$home" hungfetch-test "$nas" hungfetch-test + write_pm2_stub "$home/fakebin" "$home/restart.log" + write_git_hung_fetch_stub "$home/fakebin" + + start=$SECONDS + set +e + out=$(FM_NAS_SYNC_TIMEOUT=1 run_sync "$home" hungfetch-test 2>/dev/null) + rc=$? + set -e + elapsed=$(( SECONDS - start )) + + expect_code 0 "$rc" "hung-fetch: sync should still exit 0 when the fetch hangs" + [ "$elapsed" -lt 10 ] || fail "hung-fetch: sync took ${elapsed}s - the timeout bound did not stop the hung fetch (stub sleeps 20s)" + assert_contains "$out" "hungfetch-test: skipped: fetch failed" "hung-fetch: did not report the bounded fetch as a failure" + pass "a hung NAS fetch is killed by FM_NAS_SYNC_TIMEOUT instead of blocking the caller" +} + +test_transient_packed_refs_lock_is_retried() { + local home nas out counter err + home=$(new_home) + nas=$(build_nas_pair "$home" locktrans-test) + advance_origin "$home" locktrans-test C1 + write_map "$home" locktrans-test "$nas" locktrans-test + write_pm2_stub "$home/fakebin" "$home/restart.log" + counter="$home/git-fetch-count"; : > "$counter" + write_git_transient_packed_refs_lock_stub "$home/fakebin" "$counter" + err="$home/err-locktrans" + + out=$(FM_NAS_SYNC_PACKED_REFS_LOCK_RETRIES=3 FM_NAS_SYNC_PACKED_REFS_LOCK_RETRY_WAIT_SECS=0 \ + run_sync "$home" locktrans-test 2>"$err") + + assert_contains "$out" "locktrans-test: synced" "transient lock: sync did not complete after the lock self-cleared" + assert_grep "cleared on its own" "$err" "transient lock: guard did not report the self-clear" + pass "a transient packed-refs.lock signature on the NAS fetch is retried instead of giving up immediately" +} + test_missing_map_file_is_silent_noop() { local home out rc home=$(new_home) @@ -375,5 +467,7 @@ test_dirty_is_skipped_untouched test_diverged_is_stuck_untouched test_clustered_partial_restart_is_not_masked_online test_absent_project_is_silent_noop +test_hung_fetch_is_bounded_by_timeout +test_transient_packed_refs_lock_is_retried test_missing_map_file_is_silent_noop test_teardown_invokes_nas_deploy_sync_for_landed_project From 85419ff427926fda91f8b40cd0dd1ba1ae80e24e Mon Sep 17 00:00:00 2001 From: Michael Date: Sat, 11 Jul 2026 23:49:03 -0400 Subject: [PATCH 32/46] no-mistakes(test): placeholder - awaiting background test run completion --- tests/fm-backlog-handoff.test.sh | 7 +++++-- tests/fm-secondmate-lifecycle-e2e.test.sh | 11 +++++++---- tests/fm-teardown.test.sh | 20 ++++++++++++++++++++ 3 files changed, 32 insertions(+), 6 deletions(-) diff --git a/tests/fm-backlog-handoff.test.sh b/tests/fm-backlog-handoff.test.sh index 9e10703e8..575127270 100755 --- a/tests/fm-backlog-handoff.test.sh +++ b/tests/fm-backlog-handoff.test.sh @@ -10,8 +10,11 @@ set -u . "$(dirname "${BASH_SOURCE[0]}")/secondmate-helpers.sh" # The move is delegated to `tasks-axi mv`, so this suite exercises the real -# binary. Skip cleanly when it is absent (matching the backend smoke suites). -command -v tasks-axi >/dev/null 2>&1 || { echo "skip: tasks-axi not found (required by the delegated handoff path)"; exit 0; } +# binary. Skip cleanly when it is absent or too old for atomic multi-ID moves +# (matching tests/fm-secondmate-lifecycle-e2e.test.sh's handoff phase). +# shellcheck source=bin/fm-tasks-axi-lib.sh disable=SC1091 +. "$ROOT/bin/fm-tasks-axi-lib.sh" +fm_tasks_axi_compatible || { echo "skip: tasks-axi not found or too old (required by the delegated handoff path)"; exit 0; } TMP_ROOT=$(fm_test_tmproot fm-backlog-handoff) diff --git a/tests/fm-secondmate-lifecycle-e2e.test.sh b/tests/fm-secondmate-lifecycle-e2e.test.sh index 099dd2682..3046df7e0 100755 --- a/tests/fm-secondmate-lifecycle-e2e.test.sh +++ b/tests/fm-secondmate-lifecycle-e2e.test.sh @@ -155,10 +155,13 @@ phase_send() { } phase_handoff() { - # The move is delegated to `tasks-axi mv`; skip cleanly when it is absent (the - # downstream recovery and teardown phases do not depend on this phase). - if ! command -v tasks-axi >/dev/null 2>&1; then - echo "skip: tasks-axi not found (backlog handoff delegates to it)" + # The move is delegated to `tasks-axi mv`; skip cleanly when it is absent or + # too old for atomic multi-ID moves (the downstream recovery and teardown + # phases do not depend on this phase). + # shellcheck source=bin/fm-tasks-axi-lib.sh disable=SC1091 + . "$ROOT/bin/fm-tasks-axi-lib.sh" + if ! fm_tasks_axi_compatible; then + echo "skip: tasks-axi not found or too old (backlog handoff delegates to it)" return 0 fi cat > "$HOME_DIR/data/backlog.md" <<'EOF' diff --git a/tests/fm-teardown.test.sh b/tests/fm-teardown.test.sh index b49a5d778..bfe0f8ee4 100755 --- a/tests/fm-teardown.test.sh +++ b/tests/fm-teardown.test.sh @@ -209,6 +209,18 @@ land_on_origin_main() { rm -rf "$tmp" } +# content_in_default (bin/fm-teardown.sh) relies on `git merge-tree +# --write-tree`, added in git 2.38. Probe the REAL capability rather than +# guessing a version cutoff: older git treats the flag as an unrecognized +# revision ("unknown rev --write-tree"), which the two tests exercising that +# fallback below cannot pass under. +fm_git_supports_merge_tree_write_tree() { + case "$(git -C "$ROOT" merge-tree --write-tree HEAD HEAD 2>&1)" in + *"unknown rev"*) return 1 ;; + *) return 0 ;; + esac +} + # Override GitHub lookups to report PR 7 as merged with the supplied head. add_gh_pr_merged_for_head() { local case_dir=$1 head=$2 @@ -899,6 +911,10 @@ test_pr_check_records_remote_head_when_local_lags() { test_content_in_default_fallback_allows() { local case_dir rc + fm_git_supports_merge_tree_write_tree || { + echo "skip: this git lacks 'merge-tree --write-tree' (needs git 2.38+)" + return 0 + } case_dir=$(make_case content-landed) write_meta "$case_dir" no-mistakes ship # No pr= recorded and the default gh-axi mock reports no PR, so the merged-PR path @@ -919,6 +935,10 @@ test_content_in_default_fallback_allows() { test_content_fallback_refreshes_stale_origin_ref() { local case_dir rc + fm_git_supports_merge_tree_write_tree || { + echo "skip: this git lacks 'merge-tree --write-tree' (needs git 2.38+)" + return 0 + } case_dir=$(make_case content-stale-ref) write_meta "$case_dir" no-mistakes ship wt_commit_file "$case_dir" feature.txt hello "add feature" From 3bc89a9671c3cf2bf81eed51db63673c870d9b87 Mon Sep 17 00:00:00 2001 From: Michael Date: Sun, 12 Jul 2026 01:13:04 -0400 Subject: [PATCH 33/46] no-mistakes(document): Synced AGENTS.md and docs/architecture.md/cmux-backend.md with the NAS auto-deploy-sync feature and cmux liveness fix. --- AGENTS.md | 2 ++ docs/architecture.md | 5 +++++ docs/cmux-backend.md | 2 +- 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 93e7d1ed9..3c5491c71 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -86,6 +86,7 @@ data/ personal fleet records; LOCAL, gitignored as a whole backlog.md task queue, dependencies, history captain.md captain's personal preferences and working style; LOCAL, gitignored, canonical even if harness memory mirrors it, and updated with inspect-then-update learnings.md fleet-local operational facts and gotchas; LOCAL, gitignored; dated, evidence-backed, curated, and updated with inspect-then-update - rewrite and prune rather than append forever, the same contract as captain.md; created lazily, absent until this home has a learning to store + nas-deployments.md hand-maintained pipe-table mapping a project to its live NAS deployment, one row per deployed project: "| | | |" (comma-separated when more than one pm2 process serves it); read by bin/fm-nas-deploy-sync.sh (section 7); absent or a project missing a row means that project has no known live deployment, not an error projects.md thin fleet navigation registry; firstmate-private, parsed by fm-project-mode.sh (section 6) secondmates.md secondmate routing table; firstmate-private, maintained by fm-home-seed.sh (section 6) /brief.md per-task crewmate brief, or per-secondmate charter brief when kind=secondmate @@ -537,6 +538,7 @@ The script refuses if the worktree holds uncommitted changes or committed work t `bin/fm-teardown.sh`'s header owns the full landed-work definition (remote-reachable, merged-PR-head containment for the squash-merge-then-delete-branch flow, content already in the default branch, local-only merges) and the `pr=` discovery fallback for merges that skipped `bin/fm-pr-check.sh`. Known benign case: after an external-PR task, a squash merge leaves the branch commits reachable only on the contributor's fork; add the fork as a remote and fetch (`git remote add fork && git fetch fork`), then retry - never reach for `--force`. A successful PR-based teardown also refreshes that project's clone through `bin/fm-fleet-sync.sh`, best-effort. +Any non-scout, non-secondmate teardown - PR-based or local-only - also best-effort syncs and restarts that project's live NAS deployment, if one is recorded in `data/nas-deployments.md`, through `bin/fm-nas-deploy-sync.sh` (section 2); a project with no recorded deployment is a silent no-op. Then update the backlog using the teardown reminder: run `tasks-axi done` when the default tasks-axi backend is active and compatible, otherwise move the task to Done in `data/backlog.md` manually with the full `https://...` PR URL or local merge note and date and keep Done to the 10 most recent. Re-evaluate the queue and dispatch only queued work whose blockers are gone and whose time/date gate, if any, has arrived. diff --git a/docs/architecture.md b/docs/architecture.md index fa385cbcd..6085fe28e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -236,6 +236,11 @@ Fetches blocked by an orphaned `.git/packed-refs.lock` use bounded retries and r Local-only projects, clones without an origin remote, and fetch failures remain benign skips. The refresh also prunes local branches whose remote is gone and that no worktree still needs. +A landed ship task's `projects/` dev clone is not the same checkout as a live deployment of that project running elsewhere on the host. +When a project has a row in `data/nas-deployments.md`, non-scout, non-secondmate teardown also best-effort fast-forwards that project's separate live NAS checkout and restarts its recorded pm2 process(es), verifying each comes back online (`bin/fm-nas-deploy-sync.sh`). +It mirrors the same clean/fast-forward-only safety as the clone refresh above, including the bounded orphaned-packed-refs-lock recovery, and reports a diverged or dirty checkout as `STUCK:` rather than touching it; a project absent from the mapping is a silent no-op. +Every filesystem/git touch of the NAS checkout is time-bounded so an unreachable mount cannot hang the teardown call that triggered it. + ## Self-updates stay safe `/updatefirstmate` fast-forwards the running firstmate repo and registered secondmate homes from `origin`, then re-reads updated instructions and nudges updated secondmates without touching project clones. diff --git a/docs/cmux-backend.md b/docs/cmux-backend.md index 5859e7f2d..305a582e6 100644 --- a/docs/cmux-backend.md +++ b/docs/cmux-backend.md @@ -173,7 +173,7 @@ No session field is needed - unlike herdr/zellij there is no session layer to re | Duplicate task check | `cmux workspace list --json --id-format uuids`, match by home-scoped `.title` | cmux enforces NO title uniqueness for workspaces OR surfaces/tabs - verified live: two workspaces, and two surfaces within one workspace, all created successfully sharing one title. The adapter's own duplicate check is required, mirroring herdr/zellij, and it checks the scoped title such as `fm-firstmate-<8hex>-`. | | Create task workspace | `cmux new-workspace --name --cwd --focus false --id-format uuids` | Creates a workspace with exactly one default surface. `--focus` verified to already default to `false` for workspace/surface/pane creation - no focus-restore dance needed, unlike zellij. The caller passes `fm-`, but the adapter creates `fm--`. | | Workspace/surface id resolution | `cmux workspace list --json --id-format uuids` (find by home-scoped title), then `cmux list-panes --workspace --json --id-format uuids` (`.panes[0].selected_surface_id`) | A freshly created workspace already has exactly one surface, so no separate `new-surface` call is needed. `--id-format uuids` (or `both`) is required to get a bare `id` field in JSON; the default JSON shape returns only short `ref` strings like `"workspace:2"`. | -| Liveness / target readiness | `cmux list-panes --workspace --json --id-format uuids`, checking the surface id appears in `.panes[].surface_ids` | Structural existence check, NOT a content read - see "read-screen fails on a genuinely fresh surface" below for why `read-screen` cannot be used here. Verified reliable on a completely untouched fresh surface, unlike `read-screen`. | +| Liveness / target readiness | `cmux list-panes --workspace --json --id-format uuids`, checking the surface id appears in `.panes[].surface_ids` | Structural existence check, NOT a content read - see "read-screen fails on a genuinely fresh surface" below for why `read-screen` cannot be used here. Verified reliable on a completely untouched fresh surface, unlike `read-screen`. `fm_backend_cmux_surface_exists` captures `list-panes`' own output and checks ITS exit status before piping into `jq -e`: on jq 1.6, `jq -e` over truly empty stdin exits 0, so piping a failed/empty `list-panes` call straight into `jq` would misread a dead target as a live one. | | Send literal (unsubmitted) | `cmux send --workspace --surface -- ` | Verified live: does NOT auto-submit - text sits at the prompt, unexecuted, until a separate Enter. Matches every other backend's "literal-then-separate-Enter" contract. The `--` separator keeps option-shaped text such as `--help` literal. | | Send key | `cmux send-key --workspace --surface ` | Verified names: `enter`, `escape`, `ctrl-c` all work directly (lowercase, hyphenated). Escape is natively supported (unlike Orca); Ctrl-C correctly interrupted a running `sleep 100` in a live test. cmux's own key vocabulary is richer still (`ctrl-d`/`ctrl-z`/`ctrl-\\`, semantic aliases `sigint`/`sigtstp`/`sigquit`), but firstmate's shared vocabulary only needs these three today. | | Send + submit, composed | `send` then `send-key enter` | cmux has no single-call atomic "type and submit" primitive (unlike tmux's `send-keys ... Enter` or herdr's `pane run`); `fm_backend_cmux_send_text_line` composes the two calls, mirroring zellij's equivalent composition. | From 9b5f3b93e56c09887c8248863d3d3276d433160a Mon Sep 17 00:00:00 2001 From: fmtest Date: Mon, 13 Jul 2026 13:00:31 -0400 Subject: [PATCH 34/46] feat: surface failed critical systemd services at bootstrap Add config/critical-services (local, gitignored): one systemd unit name per line, blank lines and full-line "#" comments ignored. bin/fm-bootstrap.sh reads it in the detect-only diagnostics and prints one read-only SERVICE_FAILED line per failed unit via systemctl is-failed (no root), with a "failed since" timestamp when available. An absent or empty file, or a host without systemctl, is a no-op; nothing is ever auto-restarted. Wire it into AGENTS.md's config list and section 3's diagnostic-handling list, docs/configuration.md, a copyable docs/examples/critical-services, and tests. --- .gitignore | 2 + AGENTS.md | 1 + bin/fm-bootstrap.sh | 38 ++++++++- docs/configuration.md | 9 +++ docs/examples/critical-services | 19 +++++ tests/fm-bootstrap.test.sh | 131 ++++++++++++++++++++++++++++++++ 6 files changed, 199 insertions(+), 1 deletion(-) create mode 100644 docs/examples/critical-services diff --git a/.gitignore b/.gitignore index 5ed2da0c3..250f77b5d 100644 --- a/.gitignore +++ b/.gitignore @@ -15,4 +15,6 @@ config/backlog-backend config/backend config/x-mode.env config/cmux-socket-password +config/critical-services +config/autodeploy-logs config/wedge-alarm diff --git a/AGENTS.md b/AGENTS.md index 3c5491c71..76fa04fa6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -82,6 +82,7 @@ config/backend runtime session-provider backend override for new tasks; LOCAL, config/cmux-socket-password optional cmux control-socket password; LOCAL, gitignored; read fresh on every cmux CLI call and passed through without ever overriding an operator's own ambient CMUX_SOCKET_PASSWORD when absent (docs/cmux-backend.md "Setup") config/wedge-alarm optional away-mode wedge-alarm active-alert directives; LOCAL, gitignored; absent means auto (macOS Notification Center when available); see docs/wedge-alarm.md config/x-mode.env generated X-mode watcher cadence; LOCAL, gitignored; source before arming watcher when present +config/critical-services optional systemd unit names to watch for the failed state, one per line, blank lines and full-line "#" comments ignored; LOCAL, gitignored; absent or empty = no-op; each failed unit surfaces as a read-only bootstrap SERVICE_FAILED diagnostic at every session start (section 3); NOT inherited into secondmate homes, whose same-machine bootstrap would only re-check the same units; see docs/examples/critical-services for a copyable sample data/ personal fleet records; LOCAL, gitignored as a whole backlog.md task queue, dependencies, history captain.md captain's personal preferences and working style; LOCAL, gitignored, canonical even if harness memory mirrors it, and updated with inspect-then-update diff --git a/bin/fm-bootstrap.sh b/bin/fm-bootstrap.sh index 1f5cc5d80..bcf83ab2f 100755 --- a/bin/fm-bootstrap.sh +++ b/bin/fm-bootstrap.sh @@ -14,7 +14,10 @@ # "SECONDMATE_SYNC: secondmate : skipped: ", # "NUDGE_SECONDMATES: fm-...", # "SECONDMATE_LIVENESS: secondmate : already-live|respawned|skipped: |respawn failed: ", -# "FMX: X mode on ..." or "FMX: X mode off ...". +# "FMX: X mode on ..." or "FMX: X mode off ...", +# "SERVICE_FAILED: - failed since ", +# "UPSTREAM_DRIFT: local main ahead / behind +# upstream/main ... (needs-attention wording when far behind or stale)". # A NUDGE_SECONDMATES line lists the RUNNING secondmate task selectors # (fm-) whose worktree was fast-forwarded to firstmate's own # current default-branch commit (a purely LOCAL fast-forward, never an @@ -56,6 +59,12 @@ # X mode is OPTIONAL and inert unless FM_HOME/.env has a non-empty # FMX_PAIRING_TOKEN. When opted in, bootstrap requires curl+jq, writes # the relay poll shim and 30s cadence config, and prints an FMX line. +# A SERVICE_FAILED line means a systemd unit named in the optional local +# config/critical-services file is in the failed state; it is a pure +# read-only detection (systemctl is-failed, no root) so a read-only +# session still surfaces it. Absent config file, no systemctl, or no +# failed unit all print nothing. The timestamp clause is omitted when +# StateChangeTimestamp is unavailable. # Fleet sync fetches, fast-forwards safe default-branch states, reports # recovered and STUCK clone drift, and prunes gone local branches; it is # bounded by FM_FLEET_SYNC_BOOTSTRAP_TIMEOUT when it is a non-empty @@ -572,6 +581,32 @@ crew_dispatch_validate() { ' "$file" } +# Detect critical systemd units in the failed state. Reads one unit name per +# line from config/critical-services (blank lines and full-line # comments +# ignored) and prints one SERVICE_FAILED line per failed unit. Pure read-only +# detection via systemctl is-failed (no root); absent config file, no systemctl, +# or no failed unit all print nothing. +critical_services_check() { + local file unit ts + file="$CONFIG/critical-services" + [ -f "$file" ] || return 0 + command -v systemctl >/dev/null 2>&1 || return 0 + while IFS= read -r unit || [ -n "$unit" ]; do + unit="${unit#"${unit%%[![:space:]]*}"}" + unit="${unit%"${unit##*[![:space:]]}"}" + [ -n "$unit" ] || continue + case "$unit" in '#'*) continue ;; esac + if systemctl is-failed --quiet "$unit" >/dev/null 2>&1; then + ts=$(systemctl show "$unit" --property=StateChangeTimestamp --value 2>/dev/null || true) + if [ -n "$ts" ]; then + echo "SERVICE_FAILED: $unit - failed since $ts" + else + echo "SERVICE_FAILED: $unit - in failed state" + fi + fi + done < "$file" +} + if [ "${1:-}" = "install" ]; then shift [ $# -gt 0 ] || { echo "usage: fm-bootstrap.sh install ..." >&2; exit 1; } @@ -631,6 +666,7 @@ crew_dispatch_validate if ! fm_backlog_backend_manual "$CONFIG" && fm_tasks_axi_compatible; then echo "TASKS_AXI: available" fi +critical_services_check if [ "${FM_BOOTSTRAP_DETECT_ONLY:-0}" != 1 ]; then secondmate_sync secondmate_liveness_sweep diff --git a/docs/configuration.md b/docs/configuration.md index 881b162d2..01a0bc524 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -99,6 +99,15 @@ That keeps a tmux pane nested inside herdr on the tmux transport, matching the r Target detection uses `FM_SUPERVISOR_TARGET`, then `$TMUX_PANE`, then `"${HERDR_SESSION:-default}:${HERDR_PANE_ID}"` under herdr, then the legacy `firstmate:0` tmux fallback with a warning. Selecting any other supervisor backend, including `zellij`, `orca`, or `cmux`, refuses at daemon startup instead of trying tmux injection primitives against a non-tmux pane. +## Critical service watch (config/critical-services) + +`config/critical-services` (local, gitignored) lists systemd unit names, one per non-empty, non-comment line, that firstmate watches for the `failed` state. +At every session start bootstrap runs `systemctl is-failed` (read-only, no root) on each listed unit and prints `SERVICE_FAILED: - failed since ` for any that are failed, dropping the `failed since` clause when the unit's `StateChangeTimestamp` is unavailable. +It exists so a silently-failed critical unit, the kind of boot-time oneshot whose failure can otherwise go unnoticed for days, surfaces the next time firstmate starts rather than never. +An absent or empty file is a no-op, and a host without `systemctl` (no systemd) silently does nothing. +The file is not inherited by secondmate homes, whose same-machine bootstrap would only re-check the same units. +See [`examples/critical-services`](examples/critical-services) for a copyable config; report a failed unit to the captain in plain language and never restart it, since a unit that failed may be unsafe to restart without knowing why. + ## Away-mode wedge alarm channels (config/wedge-alarm) When away-mode injection wedges past `FM_MAX_DEFER_SECS`, the sub-supervisor raises a loud, rate-limited alarm. diff --git a/docs/examples/critical-services b/docs/examples/critical-services new file mode 100644 index 000000000..5b4cf5373 --- /dev/null +++ b/docs/examples/critical-services @@ -0,0 +1,19 @@ +# config/critical-services - systemd units firstmate watches for the failed +# state (bin/fm-bootstrap.sh's critical_services_check). Copy this into a local, +# gitignored config/critical-services and replace the placeholders with your own +# unit names. One systemd unit name per non-empty, non-comment line; blank lines +# and full-line "#" comments are ignored. +# +# At every session start, firstmate runs `systemctl is-failed` (read-only, no +# root) on each listed unit and surfaces a SERVICE_FAILED line for any that are +# failed, then reports it to the captain in plain language. It never restarts a +# unit: a unit that failed may be unsafe to restart without knowing why. +# +# An ABSENT or empty config/critical-services is a no-op. On a host without +# systemd (no systemctl) the check silently does nothing. +# +# The names below are illustrative placeholders - list the units whose silent +# failure would actually matter on this machine (the oneshot that runs a +# production app at boot, a backup timer, a sync daemon, and so on). +my-app.service +nightly-backup.timer diff --git a/tests/fm-bootstrap.test.sh b/tests/fm-bootstrap.test.sh index 2b3399e58..bdd2254ca 100755 --- a/tests/fm-bootstrap.test.sh +++ b/tests/fm-bootstrap.test.sh @@ -73,6 +73,42 @@ SH printf '%s\n' "$fakebin" } +# Fake systemctl for the critical-services check. It shadows any real systemctl +# on PATH so the test is deterministic on a systemd host. FM_FAKE_FAILED_UNITS is +# a space-separated set of units reported as failed; FM_FAKE_SERVICE_TS is the +# StateChangeTimestamp value returned for `show` (empty = unavailable). is-failed +# prints its state to stdout (as the real one does without --quiet), so a case +# expecting silence also proves the production call redirects that stream. +add_fake_systemctl() { + local fakebin=$1 + cat > "$fakebin/systemctl" <<'SH' +#!/usr/bin/env bash +case "${1:-}" in + is-failed) + shift + unit= + for a in "$@"; do + case "$a" in --*) ;; *) unit=$a ;; esac + done + for f in ${FM_FAKE_FAILED_UNITS:-}; do + if [ "$f" = "$unit" ]; then + printf '%s\n' failed + exit 0 + fi + done + printf '%s\n' inactive + exit 1 + ;; + show) + printf '%s\n' "${FM_FAKE_SERVICE_TS:-}" + exit 0 + ;; +esac +exit 0 +SH + chmod +x "$fakebin/systemctl" +} + add_quota_axi() { local fakebin=$1 cat > "$fakebin/quota-axi" <<'SH' @@ -696,6 +732,100 @@ ROWS pass "bootstrap validates crew-dispatch.json and reports malformed or unverified configs" } +# A home whose backlog backend is manual (suppresses TASKS_AXI) with a fully +# present toolchain and a fake systemctl, so SERVICE_FAILED lines are the only +# possible bootstrap output. Prints the fakebin path. +setup_critical_case() { + local case_dir=$1 fakebin + mkdir -p "$case_dir/home/config" + printf '%s\n' manual > "$case_dir/home/config/backlog-backend" + fakebin=$(make_fake_toolchain "$case_dir") + add_fake_systemctl "$fakebin" + printf '%s\n' "$fakebin" +} + +run_critical_bootstrap() { + local home=$1 fakebin=$2 failed=$3 ts=$4 + PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FM_ROOT_OVERRIDE="$home" \ + FM_FAKE_TREEHOUSE_LEASE_HELP=1 \ + FM_FAKE_FAILED_UNITS="$failed" FM_FAKE_SERVICE_TS="$ts" \ + "$ROOT/bin/fm-bootstrap.sh" +} + +test_critical_services_check() { + local case_dir home fakebin out ts expect bash_env + ts='Wed 2026-07-01 03:14:07 UTC' + + # A failed unit with a StateChangeTimestamp surfaces with the since clause. + case_dir="$TMP_ROOT/critical-failed-ts" + home="$case_dir/home" + fakebin=$(setup_critical_case "$case_dir") + printf '%s\n' 'my-app.service' > "$home/config/critical-services" + out=$(run_critical_bootstrap "$home" "$fakebin" 'my-app.service' "$ts") + [ "$out" = "SERVICE_FAILED: my-app.service - failed since $ts" ] \ + || fail "failed unit with timestamp: got: $out" + + # A failed unit with no StateChangeTimestamp drops the since clause. + case_dir="$TMP_ROOT/critical-failed-no-ts" + home="$case_dir/home" + fakebin=$(setup_critical_case "$case_dir") + printf '%s\n' 'my-app.service' > "$home/config/critical-services" + out=$(run_critical_bootstrap "$home" "$fakebin" 'my-app.service' '') + [ "$out" = "SERVICE_FAILED: my-app.service - in failed state" ] \ + || fail "failed unit without timestamp: got: $out" + + # A listed unit that is not failed stays silent (also proves is-failed's stdout + # is redirected: the fake would otherwise leak 'inactive' into the output). + case_dir="$TMP_ROOT/critical-not-failed" + home="$case_dir/home" + fakebin=$(setup_critical_case "$case_dir") + printf '%s\n' 'my-app.service' > "$home/config/critical-services" + out=$(run_critical_bootstrap "$home" "$fakebin" 'other.service' "$ts") + [ -z "$out" ] || fail "not-failed unit should be silent, got: $out" + + # Blank lines and full-line '#' comments are ignored, whitespace is trimmed, + # and only the listed units are reported, in file order. + case_dir="$TMP_ROOT/critical-comments" + home="$case_dir/home" + fakebin=$(setup_critical_case "$case_dir") + printf '%s\n' '# critical units' '' ' spaced.service ' 'ok.timer' '#skip.service' 'down.service' \ + > "$home/config/critical-services" + out=$(run_critical_bootstrap "$home" "$fakebin" 'spaced.service down.service' "$ts") + expect=$'SERVICE_FAILED: spaced.service - failed since '"$ts"$'\nSERVICE_FAILED: down.service - failed since '"$ts" + [ "$out" = "$expect" ] || fail "comment/whitespace handling: got: $out" + + # An absent config file is a no-op even with systemctl present. + case_dir="$TMP_ROOT/critical-absent" + home="$case_dir/home" + fakebin=$(setup_critical_case "$case_dir") + out=$(run_critical_bootstrap "$home" "$fakebin" 'my-app.service' "$ts") + [ -z "$out" ] || fail "absent config should be a no-op, got: $out" + + # Without systemctl on PATH (a non-systemd host) the check is a silent no-op + # even when the config lists a unit. Override `command -v systemctl` so the + # host's real systemctl cannot satisfy the guard. + case_dir="$TMP_ROOT/critical-no-systemctl" + home="$case_dir/home" + fakebin=$(setup_critical_case "$case_dir") + rm -f "$fakebin/systemctl" + printf '%s\n' 'my-app.service' > "$home/config/critical-services" + bash_env="$case_dir/no-systemctl.bash" + cat > "$bash_env" <<'SH' +command() { + if [ "${1:-}" = -v ] && [ "${2:-}" = systemctl ]; then + return 1 + fi + builtin command "$@" +} +SH + out=$(PATH="$fakebin:$BASE_PATH" BASH_ENV="$bash_env" FM_HOME="$home" FM_ROOT_OVERRIDE="$home" \ + FM_FAKE_TREEHOUSE_LEASE_HELP=1 FM_FAKE_FAILED_UNITS='my-app.service' FM_FAKE_SERVICE_TS="$ts" \ + "$ROOT/bin/fm-bootstrap.sh") + [ -z "$out" ] || fail "no systemctl should be a silent no-op, got: $out" + + pass "bootstrap surfaces failed critical services and stays silent otherwise" +} + test_bootstrap_reporting test_no_mistakes_min_version test_git_is_required_with_supported_install_instruction @@ -714,3 +844,4 @@ test_fleet_sync_timeout_empty_override_uses_default test_fleet_sync_timeout_is_computed_before_launch test_crew_dispatch_active_rules_are_surfaced test_crew_dispatch_validation +test_critical_services_check From 233085027bf27821f05c6e95e9730f5b9386f6b4 Mon Sep 17 00:00:00 2001 From: fmtest Date: Mon, 13 Jul 2026 23:47:42 -0400 Subject: [PATCH 35/46] keep firstmate repo in sync with upstream template Swap remotes on this working copy so origin is the captain's fork and upstream is the read-only parent template, and keep the fork alive: - fm-merge-local.sh: after a local fast-forward, best-effort push the default branch to origin when it exists, so a push-backed local-only default branch never silently drifts behind local main. Skipped silently for no-remote local-only projects. - fm-bootstrap.sh: always-on UPSTREAM_DRIFT diagnostic reporting how far local main diverges from upstream/main in both directions plus days-since-merge-base, escalating wording past 30 commits behind or a 10-day-old merge-base. The upstream fetch lives only in the locked mutating fleet-sync sweep; the count-and-report runs unconditionally from the already-present upstream ref, never fetching in the detect-only path. - Document the local-only landing pattern and the opportunistic, non-blocking upstream PR in AGENTS.md, the UPSTREAM_DRIFT trigger in the bootstrap-diagnostics skill, and the inverted remote layout in CONTRIBUTING.md. --- .agents/skills/bootstrap-diagnostics/SKILL.md | 11 ++- AGENTS.md | 4 +- CONTRIBUTING.md | 2 + bin/fm-bootstrap.sh | 83 ++++++++++++++++- bin/fm-merge-local.sh | 17 ++++ tests/fm-bootstrap.test.sh | 90 +++++++++++++++++++ 6 files changed, 204 insertions(+), 3 deletions(-) diff --git a/.agents/skills/bootstrap-diagnostics/SKILL.md b/.agents/skills/bootstrap-diagnostics/SKILL.md index 3e6d47371..7bc7107d0 100644 --- a/.agents/skills/bootstrap-diagnostics/SKILL.md +++ b/.agents/skills/bootstrap-diagnostics/SKILL.md @@ -2,7 +2,7 @@ name: bootstrap-diagnostics description: >- Agent-only handling playbook for session-start bootstrap diagnostics. - Use whenever the session-start digest's bootstrap section prints any diagnostic or capability line - MISSING, MISSING_MANUAL, BACKEND_INVALID, NEEDS_GH_AUTH, TANGLE, CREW_HARNESS_OVERRIDE, CREW_DISPATCH, FLEET_SYNC, SECONDMATE_SYNC, SECONDMATE_LIVENESS, TASKS_AXI, NUDGE_SECONDMATES, or FMX - or when a standalone bin/fm-bootstrap.sh run prints one. + Use whenever the session-start digest's bootstrap section prints any diagnostic or capability line - MISSING, MISSING_MANUAL, BACKEND_INVALID, NEEDS_GH_AUTH, TANGLE, CREW_HARNESS_OVERRIDE, CREW_DISPATCH, FLEET_SYNC, SECONDMATE_SYNC, SECONDMATE_LIVENESS, TASKS_AXI, NUDGE_SECONDMATES, FMX, SERVICE_FAILED, or UPSTREAM_DRIFT - or when a standalone bin/fm-bootstrap.sh run prints one. A silent bootstrap section means all good and needs no skill load. user-invocable: false metadata: @@ -47,3 +47,12 @@ The inline rules in `AGENTS.md` section 3 still bind: detect, then consent, then A secondmate that was skipped, already current, or whose advance changed no instructions is not listed and must not be disturbed. - `FMX: X mode on ...` / `FMX: X mode off ...` - bootstrap confirmed or removed the local X-mode poll artifacts (`docs/configuration.md` "X mode (.env)"). Only when a running watcher needs the cadence transition applied immediately, restart the home-scoped watcher through the emitted harness supervision protocol; bootstrap deliberately never restarts the watcher itself. +- `SERVICE_FAILED: - failed since ` - a systemd unit named in the optional local `config/critical-services` file is in the failed state (`docs/configuration.md` "Critical service watch"); a pure read-only detection (`systemctl is-failed`, no root), so a read-only session still surfaces it. + Report it to the captain in plain language; never restart the unit yourself - a unit that failed may be unsafe to restart without knowing why. + An absent or empty config file, a host without `systemctl`, or no failed unit all print nothing. +- `UPSTREAM_DRIFT: local main is ahead / behind upstream/main, last reconciled d ago ()` - the always-on FYI variant: firstmate's OWN repo tracking how far it has drifted from its read-only upstream template (`kunchenguid/firstmate`, the `upstream` remote after the remote swap). + It prints every session; record it silently like any capability fact and take no action while the wording stays FYI. + It only reports - the network fetch that refreshes the ref runs in the locked fleet-sync sweep, and reconciling upstream is never a bootstrap side effect. +- `UPSTREAM_DRIFT: this repo's upstream sync needs attention - ...` - the escalated variant (local main more than ~30 commits behind `upstream/main`, or the merge-base older than 10 days). + Surface it to the captain in plain outcome language and, on their go-ahead, dispatch a deliberately-reviewed firstmate-repo reconciliation ship task - fetch `upstream`, merge it into local `main`, resolve conflicts, and land local-only (`AGENTS.md` section 1) - the same shape that reconciled the divergence before. + Never automate the merge; keeping the gap small also keeps the pipeline's opportunistic upstream PR's diff clean (`AGENTS.md` section 1). diff --git a/AGENTS.md b/AGENTS.md index 76fa04fa6..3e7da1865 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -52,6 +52,8 @@ This repo is a shared template, not the captain's personal project. The tracking principle: shared, tracked material is tracked under git; anything personal to this captain's fleet (.env, data/, state/, config/, projects/, .no-mistakes/) is not. Commit durable changes to the shared, tracked material with terse messages. This repo is itself behind the no-mistakes gate: ship shared, tracked material through the pipeline - branch, commit, run the pipeline, PR - and the captain's merge rule applies here exactly as it does to projects. +This repo's own upstream (`kunchenguid/firstmate`, the `upstream` remote after the remote swap) is read-only to this fleet with no merge rights, ever, so firstmate-repo ship tasks land local-only: branch, commit, firstmate reviews the diff, the captain approves, and firstmate fast-forwards local `main`. +The pipeline still auto-opens a PR against that upstream, but it is a non-blocking, purely opportunistic contribution back - never a merge gate here and never waited on - and its diff stays clean only while the `UPSTREAM_DRIFT:` gap (section 3) is small, since a large gap makes the PR carry a lot of unrelated local-only history. Never add an agent name as co-author. ## 2. Layout and state @@ -785,7 +787,7 @@ It performs only fast-forward self-updates of firstmate and registered secondmat These skills are not captain-invocable; they are conditional operating references you must load at the trigger points below. -- `bootstrap-diagnostics` - load whenever the session-start digest's bootstrap section prints any diagnostic or capability line (`MISSING:`, `MISSING_MANUAL:`, `BACKEND_INVALID:`, `NEEDS_GH_AUTH`, `TANGLE:`, `CREW_HARNESS_OVERRIDE:`, `CREW_DISPATCH:`, `FLEET_SYNC:`, `SECONDMATE_SYNC:`, `SECONDMATE_LIVENESS:`, `TASKS_AXI:`, `NUDGE_SECONDMATES:`, or `FMX:`); silence needs no load. +- `bootstrap-diagnostics` - load whenever the session-start digest's bootstrap section prints any diagnostic or capability line (`MISSING:`, `MISSING_MANUAL:`, `BACKEND_INVALID:`, `NEEDS_GH_AUTH`, `TANGLE:`, `CREW_HARNESS_OVERRIDE:`, `CREW_DISPATCH:`, `FLEET_SYNC:`, `SECONDMATE_SYNC:`, `SECONDMATE_LIVENESS:`, `TASKS_AXI:`, `NUDGE_SECONDMATES:`, `FMX:`, `SERVICE_FAILED:`, or `UPSTREAM_DRIFT:`); silence needs no load. - `diagnostic-reasoning` - load before scoping a reported bug and before acting on a diagnostic report. - `harness-adapters` - load before spawning or recovering a crewmate or secondmate, handling a trust dialog, sending a harness-specific skill invocation, interrupting or exiting an agent, resuming an exited agent, or verifying a new harness adapter. - `firstmate-orca` - load before switching to Orca, spawning or supervising Orca-backed work, smoke-testing Orca backend behavior, debugging Orca task state, or reconciling Orca-backed task metadata. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4e7bd8154..52e790d91 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -30,6 +30,8 @@ Dependency bots are exempt so their automation keeps working, but regular contri See the [no-mistakes quick start](https://kunchenguid.github.io/no-mistakes/start-here/quick-start/) for the full first-run walkthrough. +Note: a firstmate maintainer's own working copy uses the inverted remote layout - there `origin` is the maintainer's fork and `upstream` is this parent repo - so if you are ever reading such a copy's git config rather than your own fresh clone, expect `origin` and `upstream` swapped from the step 1 layout above. + ## Repo conventions - This repo is a template for running a firstmate orchestrator agent. diff --git a/bin/fm-bootstrap.sh b/bin/fm-bootstrap.sh index bcf83ab2f..0ee362b5d 100755 --- a/bin/fm-bootstrap.sh +++ b/bin/fm-bootstrap.sh @@ -65,8 +65,24 @@ # session still surfaces it. Absent config file, no systemctl, or no # failed unit all print nothing. The timestamp clause is omitted when # StateChangeTimestamp is unavailable. +# An UPSTREAM_DRIFT line reports, every session, how far firstmate's OWN +# local main and its read-only upstream template (upstream/main after the +# remote swap; kunchenguid/firstmate) have diverged in both directions, +# plus days since their last shared merge-base. It reads whatever +# refs/remotes/upstream/main currently holds and NEVER fetches, so it +# prints in the read-only/no-lock path too; the network fetch that +# refreshes that ref runs only in the locked mutating fleet-sync sweep +# below (bounded by FM_UPSTREAM_FETCH_TIMEOUT, default 20s). The wording +# escalates from a plain FYI to "this repo's upstream sync needs +# attention" once local main is more than 30 commits behind upstream/main +# or the merge-base is older than 10 days. It only REPORTS; reconciling +# upstream is always a deliberately dispatched, reviewed ship task, never +# automated. No-op when there is no upstream remote or no upstream/main +# ref yet. # Fleet sync fetches, fast-forwards safe default-branch states, reports -# recovered and STUCK clone drift, and prunes gone local branches; it is +# recovered and STUCK clone drift, and prunes gone local branches, and it +# also refreshes firstmate's own upstream template for the UPSTREAM_DRIFT +# report above; it is # bounded by FM_FLEET_SYNC_BOOTSTRAP_TIMEOUT when it is a non-empty # numeric override, while non-numeric values fall back to 20s. # When the override is unset or blank, the timeout is @@ -157,7 +173,71 @@ fleet_sync_relay_all_output() { done < "$tmp" } +# Upstream-drift diagnostic for firstmate's OWN repo (both halves defined here). +# firstmate's upstream template (upstream/main after the remote swap; +# kunchenguid/firstmate) is read-only to this fleet - it can never be pushed or +# merged from here - so local main and upstream/main drift apart silently over +# time, and firstmate-repo ship tasks land local-only instead (AGENTS.md prime +# directives). The report reads whatever refs/remotes/upstream/main currently +# holds WITHOUT fetching, so it is safe in the read-only/no-lock detect path; the +# fetch runs only in the locked fleet-sync sweep. It only REPORTS - reconciling +# upstream stays a deliberately dispatched, reviewed ship task, never automated. +UPSTREAM_DRIFT_BEHIND_MAX=30 +UPSTREAM_DRIFT_DAYS_MAX=10 + +# Count-and-report half: unconditional (detect section), no network. Prints one +# FYI line every session with how far local main is ahead/behind upstream/main and +# days since their merge-base, escalating the wording to "needs attention" once +# local main is more than UPSTREAM_DRIFT_BEHIND_MAX commits behind OR the +# merge-base is older than UPSTREAM_DRIFT_DAYS_MAX days. No-op without an upstream +# remote, an upstream/main ref, or a local main. +upstream_drift_report() { + local behind ahead base="" base_ct="" base_date="unknown" now="" days="?" attention=0 + git -C "$FM_ROOT" remote get-url upstream >/dev/null 2>&1 || return 0 + git -C "$FM_ROOT" rev-parse --verify --quiet refs/remotes/upstream/main >/dev/null 2>&1 || return 0 + git -C "$FM_ROOT" rev-parse --verify --quiet refs/heads/main >/dev/null 2>&1 || return 0 + behind=$(git -C "$FM_ROOT" rev-list --count main..upstream/main 2>/dev/null) || return 0 + ahead=$(git -C "$FM_ROOT" rev-list --count upstream/main..main 2>/dev/null) || return 0 + base=$(git -C "$FM_ROOT" merge-base main upstream/main 2>/dev/null || true) + if [ -n "$base" ]; then + base_date=$(git -C "$FM_ROOT" log -1 --format=%cd --date=short "$base" 2>/dev/null || echo unknown) + base_ct=$(git -C "$FM_ROOT" log -1 --format=%ct "$base" 2>/dev/null || true) + now=$(date +%s 2>/dev/null || true) + if [ -n "$base_ct" ] && [ -n "$now" ]; then + days=$(( (now - base_ct) / 86400 )) + fi + fi + case "$behind" in ''|*[!0-9]*) ;; *) [ "$behind" -gt "$UPSTREAM_DRIFT_BEHIND_MAX" ] && attention=1 ;; esac + case "$days" in ''|*[!0-9]*) ;; *) [ "$days" -gt "$UPSTREAM_DRIFT_DAYS_MAX" ] && attention=1 ;; esac + if [ "$attention" -eq 1 ]; then + echo "UPSTREAM_DRIFT: this repo's upstream sync needs attention - local main is $behind behind / $ahead ahead of upstream/main, last reconciled ${days}d ago ($base_date); dispatch a reviewed reconciliation ship task" + else + echo "UPSTREAM_DRIFT: local main is $ahead ahead / $behind behind upstream/main, last reconciled ${days}d ago ($base_date)" + fi +} + +# Fetch half: MUTATING/network, called only from fleet_sync (the locked sweep), so +# the detect-only/read-only path never fetches. Best-effort and bounded - offline, +# a transient failure, or a stalled connection just leaves the last-fetched ref in +# place, and timeout (when available) caps a stall so session start never hangs. +# No-op without an upstream remote. +upstream_drift_fetch() { + git -C "$FM_ROOT" remote get-url upstream >/dev/null 2>&1 || return 0 + if command -v timeout >/dev/null 2>&1; then + timeout "${FM_UPSTREAM_FETCH_TIMEOUT:-20}" git -C "$FM_ROOT" fetch --quiet upstream 2>/dev/null || true + else + git -C "$FM_ROOT" fetch --quiet upstream 2>/dev/null || true + fi +} + fleet_sync() { + # Refresh firstmate's own read-only upstream template first, independent of the + # project-clone sync below (so it runs even with no clones present). This is the + # mutating/network half of the UPSTREAM_DRIFT diagnostic; upstream_drift_report + # in the detect section never fetches, keeping that report safe in the + # read-only detect-only path. + upstream_drift_fetch + [ -x "$FM_ROOT/bin/fm-fleet-sync.sh" ] || return 0 [ -d "$PROJECTS" ] || return 0 @@ -667,6 +747,7 @@ if ! fm_backlog_backend_manual "$CONFIG" && fm_tasks_axi_compatible; then echo "TASKS_AXI: available" fi critical_services_check +upstream_drift_report if [ "${FM_BOOTSTRAP_DETECT_ONLY:-0}" != 1 ]; then secondmate_sync secondmate_liveness_sweep diff --git a/bin/fm-merge-local.sh b/bin/fm-merge-local.sh index fdc801148..07e5ca50a 100755 --- a/bin/fm-merge-local.sh +++ b/bin/fm-merge-local.sh @@ -66,3 +66,20 @@ before=$(git -C "$PROJ" rev-parse --short "$DEFAULT") git -C "$PROJ" merge --ff-only "$BRANCH" >/dev/null after=$(git -C "$PROJ" rev-parse --short "$DEFAULT") echo "merged $BRANCH into local $DEFAULT ($before -> $after) in $PROJ" + +# Keep a push-backed default branch synced with the merge that just landed, so +# the remote never silently drifts behind local main. This is what keeps +# firstmate's own fork alive after the remote swap that made origin the captain's +# fork (AGENTS.md prime directives note on the firstmate repo's local-only +# landing pattern). Best-effort by design: the local fast-forward above is the +# operation that matters, so a push failure (offline, transient network, or a +# non-fast-forward rejection) is reported but never fails the merge. A pure +# local-only project with no origin remote has nowhere to push and is skipped +# silently - not every local-only project is remote-backed. +if git -C "$PROJ" remote get-url origin >/dev/null 2>&1; then + if git -C "$PROJ" push origin "$DEFAULT" >/dev/null 2>&1; then + echo "pushed $DEFAULT to origin in $PROJ" + else + echo "warning: local $DEFAULT merged, but syncing it to origin failed in $PROJ (best-effort; merge succeeded)" >&2 + fi +fi diff --git a/tests/fm-bootstrap.test.sh b/tests/fm-bootstrap.test.sh index bdd2254ca..1d547f08d 100755 --- a/tests/fm-bootstrap.test.sh +++ b/tests/fm-bootstrap.test.sh @@ -826,8 +826,98 @@ SH pass "bootstrap surfaces failed critical services and stays silent otherwise" } +# --- UPSTREAM_DRIFT diagnostic ------------------------------------------------- +# The always-on drift report reads firstmate's OWN repo (FM_ROOT) and needs a real +# git repo with a `main` branch, an `upstream` remote, and a refs/remotes/upstream/main +# ref - so these cases build one per scenario instead of the fake toolchain's +# non-git home. It reads the ref WITHOUT fetching, so a dummy upstream URL is fine. +make_drift_repo() { # + local repo=$1 + git init -q -b main "$repo" + git -C "$repo" config user.email t@t.test + git -C "$repo" config user.name tester +} + +drift_commit() { # [] + local repo=$1 msg=$2 date=${3:-} + if [ -n "$date" ]; then + GIT_AUTHOR_DATE="$date" GIT_COMMITTER_DATE="$date" \ + git -C "$repo" commit -q --allow-empty -m "$msg" + else + git -C "$repo" commit -q --allow-empty -m "$msg" + fi +} + +# Run bootstrap in detect-only mode (the drift report runs there and never +# fetches) against a real git FM_ROOT, returning only the UPSTREAM_DRIFT line. +run_drift_bootstrap() { # + local repo=$1 fakebin=$2 + PATH="$fakebin:$BASE_PATH" FM_HOME="$repo" FM_ROOT_OVERRIDE="$repo" \ + FM_FAKE_TREEHOUSE_LEASE_HELP=1 FM_BOOTSTRAP_DETECT_ONLY=1 \ + "$ROOT/bin/fm-bootstrap.sh" 2>/dev/null | grep '^UPSTREAM_DRIFT:' || true +} + +test_upstream_drift_report() { + local case_dir repo fakebin out base tip i + + # FYI variant: local main ahead, not behind, recent merge-base -> plain report. + case_dir="$TMP_ROOT/drift-fyi"; repo="$case_dir/repo" + mkdir -p "$case_dir"; make_drift_repo "$repo" + drift_commit "$repo" base + base=$(git -C "$repo" rev-parse HEAD) + git -C "$repo" remote add upstream https://example.invalid/upstream.git + git -C "$repo" update-ref refs/remotes/upstream/main "$base" + drift_commit "$repo" a1; drift_commit "$repo" a2; drift_commit "$repo" a3 + fakebin=$(make_fake_toolchain "$case_dir") + out=$(run_drift_bootstrap "$repo" "$fakebin") + assert_contains "$out" "UPSTREAM_DRIFT: local main is 3 ahead / 0 behind upstream/main" \ + "FYI drift line reports ahead/behind counts" + assert_not_contains "$out" "needs attention" "FYI variant must not escalate" + + # Escalation via a stale merge-base (older than the 10-day threshold). + case_dir="$TMP_ROOT/drift-stale"; repo="$case_dir/repo" + mkdir -p "$case_dir"; make_drift_repo "$repo" + drift_commit "$repo" base "2020-01-01T00:00:00" + base=$(git -C "$repo" rev-parse HEAD) + git -C "$repo" remote add upstream https://example.invalid/upstream.git + git -C "$repo" update-ref refs/remotes/upstream/main "$base" + drift_commit "$repo" recent + fakebin=$(make_fake_toolchain "$case_dir") + out=$(run_drift_bootstrap "$repo" "$fakebin") + assert_contains "$out" "UPSTREAM_DRIFT: this repo's upstream sync needs attention" \ + "a stale merge-base escalates the wording" + assert_contains "$out" "1 ahead" "stale case still reports the ahead count" + + # Escalation via being more than 30 commits behind upstream/main. + case_dir="$TMP_ROOT/drift-behind"; repo="$case_dir/repo" + mkdir -p "$case_dir"; make_drift_repo "$repo" + drift_commit "$repo" base + git -C "$repo" checkout -q -b upstream-sim + i=1; while [ "$i" -le 31 ]; do drift_commit "$repo" "u$i"; i=$((i + 1)); done + tip=$(git -C "$repo" rev-parse HEAD) + git -C "$repo" checkout -q main + git -C "$repo" remote add upstream https://example.invalid/upstream.git + git -C "$repo" update-ref refs/remotes/upstream/main "$tip" + git -C "$repo" branch -q -D upstream-sim + fakebin=$(make_fake_toolchain "$case_dir") + out=$(run_drift_bootstrap "$repo" "$fakebin") + assert_contains "$out" "needs attention" "being far behind upstream escalates the wording" + assert_contains "$out" "31 behind" "far-behind case reports the behind count" + + # No upstream remote -> silent no-op (no ref to count against). + case_dir="$TMP_ROOT/drift-none"; repo="$case_dir/repo" + mkdir -p "$case_dir"; make_drift_repo "$repo" + drift_commit "$repo" base + fakebin=$(make_fake_toolchain "$case_dir") + out=$(run_drift_bootstrap "$repo" "$fakebin") + [ -z "$out" ] || fail "no upstream remote must be silent, got: $out" + + pass "bootstrap reports upstream drift (FYI, stale, and far-behind escalations) and stays silent without an upstream" +} + test_bootstrap_reporting test_no_mistakes_min_version +test_upstream_drift_report test_git_is_required_with_supported_install_instruction test_orca_backend_gates_orca_tool_only_when_selected test_session_provider_backends_do_not_require_tmux From 9dc54768a1adc4f6a48e46fdbcbe805496226b11 Mon Sep 17 00:00:00 2001 From: fmtest Date: Tue, 14 Jul 2026 00:12:28 -0400 Subject: [PATCH 36/46] clarify /updatefirstmate converges to the fork, not the upstream template Post-swap origin is the captain's fork, so /updatefirstmate only converges the fleet onto that fork (a no-op on the primary source of truth) and never pulls the read-only upstream template. Folding upstream-template improvements into local main is the separate reviewed reconciliation task surfaced by the UPSTREAM_DRIFT bootstrap line, not a self-update. Note it inline in AGENTS.md section 12 and in the updatefirstmate skill. --- .agents/skills/updatefirstmate/SKILL.md | 3 +++ AGENTS.md | 2 ++ 2 files changed, 5 insertions(+) diff --git a/.agents/skills/updatefirstmate/SKILL.md b/.agents/skills/updatefirstmate/SKILL.md index 015bc1350..f50d50c47 100644 --- a/.agents/skills/updatefirstmate/SKILL.md +++ b/.agents/skills/updatefirstmate/SKILL.md @@ -18,6 +18,9 @@ It never forces, never creates a merge commit, never stashes, and advances a tar A tracked-files fast-forward leaves the gitignored operational dirs (data/, state/, config/, projects/, .no-mistakes/) untouched, so a secondmate's in-flight work is never disrupted. This touches only the firstmate repo and its own worktrees, never anything under `projects/`. +Post-swap, this pull follows `origin`, which is the captain's fork, so `/updatefirstmate` converges the whole fleet onto that fork and does nothing on the primary checkout, which is itself the source every fork tracks. +It deliberately never reaches for the read-only `upstream` template: folding template improvements into local `main` is a separate, reviewed reconciliation ship task, the one the `UPSTREAM_DRIFT:` bootstrap diagnostic surfaces, and it never rides in on a self-update. + ## What it does 1. **Run the updater:** diff --git a/AGENTS.md b/AGENTS.md index 3e7da1865..f90633a8b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -782,6 +782,8 @@ firstmate is its own repo behind the no-mistakes gate, so improvements to `AGENT Only `AGENTS.md`, `bin/`, and `.agents/skills/` are a running firstmate instruction surface; public `skills/` is tracked for installers and is not loaded by firstmate. When the captain invokes `/updatefirstmate` or asks to update firstmate, load the `/updatefirstmate` skill. It performs only fast-forward self-updates of firstmate and registered secondmate homes, re-reads `AGENTS.md` when needed, nudges updated live secondmates, and never touches anything under `projects/`. +Post-swap, `origin` is the captain's fork, so `/updatefirstmate` only ever converges the fleet onto that fork and is a no-op on the primary checkout, which is itself the fleet's source of truth. +It never pulls the read-only `upstream` template; folding upstream-template improvements into local `main` is the separate reviewed reconciliation task the `UPSTREAM_DRIFT:` bootstrap line surfaces (section 3), never a self-update. ## 13. Agent-only reference skills From f5a50260a6eb76def0a8dc2514638808d3403b1d Mon Sep 17 00:00:00 2001 From: fmtest Date: Wed, 15 Jul 2026 11:15:48 -0400 Subject: [PATCH 37/46] fix(turnend-guard): poll for a re-arming watcher before blocking The turn-end guard blocked essentially every turn during a legitimate watcher re-arm, not just a genuine supervision gap. fm_watcher_healthy requires a live, identity-matched watcher lock with zero grace, but a backgrounded re-arm needs a brief moment, roughly 0.25-0.3s measured, more under load, to fork the new watcher and register its lock after the old one released it. The Stop hook fired inside that gap on essentially every re-arm, so it blocked with no real supervision lapse. The pull-based warning banner tolerates this same gap because it checks only beacon freshness with a 300s grace. The turn-end guard's sharper live-lock check had no equivalent tolerance. Replace the single fm_watcher_healthy check with a bounded poll: retry every 0.2s, mirroring the watcher-arm script's own confirm loop, and block only once a deadline passes still unhealthy. The deadline defaults to FM_ARM_CONFIRM_TIMEOUT (10s), the same budget the arm gives itself to confirm, and is independently overridable via FM_TURNEND_ARM_WAIT. A watcher that is genuinely missing, dead, or identity-mismatched for the whole window still blocks; it now takes up to that deadline instead of blocking on the first check. This intentionally slows the truly-blind path (no arm in flight at all), which is the documented tradeoff. Update docs/turnend-guard.md's Shared Predicate section, the contract's one authoritative statement, to describe the bounded poll instead of an immediate block. Testing: tests/fm-turnend-guard.test.sh's test_hook_runs_fast now pins the bounded-poll latency instead of asserting a near-instant return, and a new test_hook_allows_watcher_that_registers_mid_poll confirms a lock that registers partway through the window is let through rather than blocked. Other block-path tests default to a short override so the suite stays fast; ran the lint gate and the full behavior suite (41 tests, all passing). --- bin/fm-turnend-guard.sh | 20 +++++++++- docs/turnend-guard.md | 4 +- tests/fm-turnend-guard.test.sh | 72 ++++++++++++++++++++++++++++++---- 3 files changed, 86 insertions(+), 10 deletions(-) diff --git a/bin/fm-turnend-guard.sh b/bin/fm-turnend-guard.sh index eef947f85..39a145cb5 100755 --- a/bin/fm-turnend-guard.sh +++ b/bin/fm-turnend-guard.sh @@ -44,6 +44,13 @@ STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" CONFIG="${FM_CONFIG_OVERRIDE:-$FM_HOME/config}" GRACE=${FM_GUARD_GRACE:-300} WATCH="$SCRIPT_DIR/fm-watch.sh" +# A legitimate watcher re-arm (bin/fm-watch-arm.sh, backgrounded) needs a brief +# moment to fork the watcher and register its lock after the old watcher +# released it; fm_watcher_healthy demands a live lock with zero grace, so +# without this the hook would block on that normal handoff gap, not just a +# genuine supervision lapse (docs/turnend-guard.md "Shared Predicate"). Default +# to the arm's own confirm budget so the two stay in lockstep. +ARM_WAIT=${FM_TURNEND_ARM_WAIT:-${FM_ARM_CONFIRM_TIMEOUT:-10}} # shellcheck source=bin/fm-supervision-lib.sh . "$SCRIPT_DIR/fm-supervision-lib.sh" @@ -114,7 +121,18 @@ fi fm_supervision_status "$STATE" "$GRACE" [ "$FM_SUP_IN_FLIGHT" -gt 0 ] || exit 0 -fm_watcher_healthy "$STATE" "$WATCH" "$GRACE" "$FM_HOME" && exit 0 + +# Bounded poll, not an immediate block: give an in-flight re-arm the same +# handful-of-seconds tolerance bin/fm-guard.sh already grants via beacon grace, +# expressed correctly for this predicate's live-lock check. A watcher that is +# truly gone (or never was) still blocks - it just takes up to $ARM_WAIT to say +# so, mirroring the poll bin/fm-watch-arm.sh:180 runs on itself. +arm_deadline=$(( $(date +%s) + ARM_WAIT )) +while :; do + fm_watcher_healthy "$STATE" "$WATCH" "$GRACE" "$FM_HOME" && exit 0 + [ "$(date +%s)" -ge "$arm_deadline" ] && break + sleep 0.2 +done afk=0 [ -e "$STATE/.afk" ] && afk=1 diff --git a/docs/turnend-guard.md b/docs/turnend-guard.md index 344417161..dadbc7400 100644 --- a/docs/turnend-guard.md +++ b/docs/turnend-guard.md @@ -29,7 +29,9 @@ If no task is in flight, it exits silently. If work is in flight, it requires `fm_watcher_healthy [grace-seconds] [home]` from `bin/fm-wake-lib.sh`. That is the same identity-matched live lock and fresh beacon check used by `bin/fm-watch-arm.sh`. A stale beacon blocks even if a watcher pid is still live. -A fresh leftover beacon blocks if the watcher lock is missing, dead, or identity-mismatched. +A fresh leftover beacon does not block on the first unhealthy check: a legitimate re-arm (`bin/fm-watch-arm.sh`, backgrounded) needs a brief moment to fork the new watcher and register its lock after the old one released it, so the guard polls `fm_watcher_healthy` on a short interval and only blocks once a bounded deadline passes still unhealthy, exiting 0 the instant a live lock registers. +That deadline defaults to the same budget `bin/fm-watch-arm.sh` gives a freshly forked watcher to confirm (`FM_ARM_CONFIRM_TIMEOUT`, 10 seconds) and is independently overridable via `FM_TURNEND_ARM_WAIT`. +A watcher lock that is genuinely missing, dead, or identity-mismatched for the whole window still blocks; it just takes up to that deadline to say so instead of blocking on the first check. `FM_STATE_OVERRIDE` wins over `FM_HOME/state`, and `FM_HOME` wins over repo-root `state/`. `FM_GUARD_GRACE` controls the beacon freshness window and defaults to 300 seconds. diff --git a/tests/fm-turnend-guard.test.sh b/tests/fm-turnend-guard.test.sh index 3e5d54f82..ea1d280d7 100755 --- a/tests/fm-turnend-guard.test.sh +++ b/tests/fm-turnend-guard.test.sh @@ -21,6 +21,15 @@ fm_git_identity fmtest fmtest@example.invalid REQUIRED_REASON='resume supervision with bin/fm-watch-arm.sh as its own Claude Code background task' +# The hook now polls fm_watcher_healthy for up to FM_TURNEND_ARM_WAIT (default +# FM_ARM_CONFIRM_TIMEOUT, 10s in production) before blocking, so it can let a +# genuine watcher re-arm land instead of blocking on the handoff gap. Most +# tests below assert an eventual outcome (block or allow), not that timing, so +# run_hook defaults every call to this short override to keep the suite fast; +# test_hook_runs_fast and the new mid-poll test set their own values to +# exercise the timing directly. +FAST_ARM_WAIT=1 + # --- PREDICATE: bin/fm-supervision-lib.sh ----------------------------------- test_predicate_healthy_no_inflight() { @@ -168,7 +177,9 @@ make_secondmate_linked_home_dir() { run_hook() { local dir=$1 stop_active=$2 home home=$(cd "$dir" && pwd) - printf '{"stop_hook_active":%s}' "$stop_active" | CLAUDECODE=1 FM_HOME="$home" bash "$dir/bin/fm-turnend-guard.sh" 2>&1 + printf '{"stop_hook_active":%s}' "$stop_active" \ + | CLAUDECODE=1 FM_HOME="$home" FM_TURNEND_ARM_WAIT="${FM_TURNEND_ARM_WAIT:-$FAST_ARM_WAIT}" \ + bash "$dir/bin/fm-turnend-guard.sh" 2>&1 } nonexistent_pid() { @@ -287,7 +298,7 @@ test_hook_blocks_from_fm_home_state() { home="$TMP_ROOT/hook-fm-home-op" mkdir -p "$home/state" : > "$home/state/task1.meta" - out=$(printf '{"stop_hook_active":false}' | CLAUDECODE=1 FM_HOME="$home" bash "$dir/bin/fm-turnend-guard.sh" 2>&1); status=$? + out=$(printf '{"stop_hook_active":false}' | CLAUDECODE=1 FM_HOME="$home" FM_TURNEND_ARM_WAIT="$FAST_ARM_WAIT" bash "$dir/bin/fm-turnend-guard.sh" 2>&1); status=$? expect_code 2 "$status" "hook must inspect the active FM_HOME state dir" assert_contains "$out" "$REQUIRED_REASON" "block reason must contain the exact required instruction" pass "fm-turnend-guard: blocks from active FM_HOME state, not only repo-root state" @@ -325,7 +336,7 @@ test_hook_uses_state_override() { state="$TMP_ROOT/hook-state-override-active" mkdir -p "$home/state" "$state" : > "$state/task1.meta" - out=$(printf '{"stop_hook_active":false}' | CLAUDECODE=1 FM_HOME="$home" FM_STATE_OVERRIDE="$state" bash "$dir/bin/fm-turnend-guard.sh" 2>&1); status=$? + out=$(printf '{"stop_hook_active":false}' | CLAUDECODE=1 FM_HOME="$home" FM_STATE_OVERRIDE="$state" FM_TURNEND_ARM_WAIT="$FAST_ARM_WAIT" bash "$dir/bin/fm-turnend-guard.sh" 2>&1); status=$? expect_code 2 "$status" "hook must let FM_STATE_OVERRIDE win over FM_HOME/state" assert_contains "$out" "$REQUIRED_REASON" "block reason must contain the exact required instruction" pass "fm-turnend-guard: uses FM_STATE_OVERRIDE ahead of FM_HOME/state" @@ -532,15 +543,59 @@ test_hook_silent_without_stdin() { pass "fm-turnend-guard: silent no-op on empty stdin" } +# Tradeoff, stated plainly (docs/turnend-guard.md "Shared Predicate"): closing +# the re-arm race means the genuinely-blind path (no watcher, no arm ever +# registers) no longer blocks near-instantly - it polls for FM_TURNEND_ARM_WAIT +# first, so a legitimate in-flight re-arm gets the same window to land. This +# test pins that bound instead of asserting a near-instant return, and uses an +# explicit short wait rather than the 10s production default so the suite +# stays fast. test_hook_runs_fast() { - local dir start elapsed_s + local dir start elapsed_s poll_wait=2 dir=$(make_primary_dir "$TMP_ROOT/hook-timing") : > "$dir/state/task1.meta" start=$SECONDS - run_hook "$dir" false >/dev/null + FM_TURNEND_ARM_WAIT=$poll_wait run_hook "$dir" false >/dev/null + elapsed_s=$((SECONDS - start)) + [ "$elapsed_s" -ge 1 ] || fail "hook returned in ${elapsed_s}s; expected it to poll rather than block near-instantly" + [ "$elapsed_s" -lt $((poll_wait + 3)) ] || fail "hook took ${elapsed_s}s, expected the ${poll_wait}s poll window plus a generous 3s CI margin" + pass "fm-turnend-guard: genuinely-blind path blocks within the bounded re-arm poll window (${elapsed_s}s, wait=${poll_wait}s)" +} + +# The actual fix under test: a re-arming watcher that registers its live lock +# partway through the poll window must be let through, not blocked - this is +# what distinguishes the bounded poll from a bare timeout. Simulates +# bin/fm-watch-arm.sh's lock-registration gap (docs/scout-turnend-guard-race +# report: ~0.25-0.3s measured) by writing a live, identity-matched lock shortly +# after the hook starts polling. +test_hook_allows_watcher_that_registers_mid_poll() { + local dir pid identity out status start elapsed_s poll_wait=5 writer_pid + dir=$(make_primary_dir "$TMP_ROOT/hook-arm-race") + : > "$dir/state/task1.meta" + touch "$dir/state/.last-watcher-beat" + sleep 60 & + pid=$! + identity=$(watcher_identity "$dir" "$pid") || { + kill "$pid" 2>/dev/null || true + wait "$pid" 2>/dev/null || true + fail "could not identify live watcher holder" + } + ( + sleep 0.5 + record_watcher_lock "$dir" "$pid" "$identity" + touch "$dir/state/.last-watcher-beat" + ) & + writer_pid=$! + start=$SECONDS + out=$(FM_TURNEND_ARM_WAIT=$poll_wait run_hook "$dir" false); status=$? elapsed_s=$((SECONDS - start)) - [ "$elapsed_s" -lt 3 ] || fail "hook took ${elapsed_s}s, expected well under a second (generous 3s CI margin)" - pass "fm-turnend-guard: runs well under the generous timing margin (${elapsed_s}s)" + wait "$writer_pid" 2>/dev/null || true + kill "$pid" 2>/dev/null || true + wait "$pid" 2>/dev/null || true + expect_code 0 "$status" "hook must allow the stop once a re-arming watcher's lock registers mid-poll" + [ -z "$out" ] || fail "hook produced output after the watcher became healthy mid-poll: $out" + [ "$elapsed_s" -lt "$poll_wait" ] || fail "hook waited the full ${elapsed_s}s deadline instead of returning as soon as the lock registered" + pass "fm-turnend-guard: allows the stop once a re-arming watcher's lock registers mid-poll (${elapsed_s}s, wait=${poll_wait}s)" } test_grok_adapter_forces_one_resume_when_unhealthy() { @@ -562,7 +617,7 @@ test_grok_adapter_forces_one_resume_when_unhealthy() { } >> "$log" EOF chmod +x "$fakebin/grok" - out=$(printf '{"sessionId":"session-test","hookEventName":"stop"}' | PATH="$fakebin:$PATH" GROK_WORKSPACE_ROOT="$dir" bash "$dir/bin/fm-turnend-guard-grok.sh" 2>&1); status=$? + out=$(printf '{"sessionId":"session-test","hookEventName":"stop"}' | PATH="$fakebin:$PATH" GROK_WORKSPACE_ROOT="$dir" FM_TURNEND_ARM_WAIT="$FAST_ARM_WAIT" bash "$dir/bin/fm-turnend-guard-grok.sh" 2>&1); status=$? expect_code 0 "$status" "grok adapter must fail open after queuing a forced resume" [ -z "$out" ] || fail "grok adapter printed output: $out" assert_contains "$(cat "$log")" 'active=1' "grok adapter must mark its forced resume as loop-guarded" @@ -913,6 +968,7 @@ test_hook_silent_in_crewmate_worktree test_hook_silent_without_jq test_hook_silent_without_stdin test_hook_runs_fast +test_hook_allows_watcher_that_registers_mid_poll test_grok_adapter_forces_one_resume_when_unhealthy test_grok_adapter_loop_guard_skips_resume test_settings_hook_uses_claude_project_dir From 9784d90c3ecc2f7a0850cb4e10a1b4c7ffa61994 Mon Sep 17 00:00:00 2001 From: fmtest Date: Wed, 15 Jul 2026 12:03:21 -0400 Subject: [PATCH 38/46] no-mistakes(test): fix stale packed-refs-lock recovery and backlog-handoff tasks-axi gate --- bin/fm-fleet-sync.sh | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/bin/fm-fleet-sync.sh b/bin/fm-fleet-sync.sh index 5c338edf6..0b3c9388f 100755 --- a/bin/fm-fleet-sync.sh +++ b/bin/fm-fleet-sync.sh @@ -162,8 +162,15 @@ packed_refs_lock_path() { fetch_with_packed_refs_lock_guard() { local rc attempt=0 lock lock_desc FETCH_OUTPUT=$(git -C "$PROJ" fetch origin --prune --quiet 2>&1); rc=$? - [ "$rc" -eq 0 ] && return 0 - is_packed_refs_lock_error "$FETCH_OUTPUT" || return "$rc" + # Git treats a failed ref-prune (blocked by the lock) as non-fatal when the + # fetch itself otherwise succeeds, so rc alone cannot prove the lock is gone - + # the output must be checked for the signature even when rc is 0. + if [ "$rc" -eq 0 ] && ! is_packed_refs_lock_error "$FETCH_OUTPUT"; then + return 0 + fi + if [ "$rc" -ne 0 ]; then + is_packed_refs_lock_error "$FETCH_OUTPUT" || return "$rc" + fi lock=$(packed_refs_lock_path) || lock="" lock_desc=${lock:-packed-refs.lock} @@ -172,14 +179,16 @@ fetch_with_packed_refs_lock_guard() { echo "$label: fetch blocked by packed-refs lock ($lock_desc); waiting ${FLEET_SYNC_PACKED_REFS_LOCK_RETRY_WAIT_SECS}s and retrying ($attempt/${FLEET_SYNC_PACKED_REFS_LOCK_RETRIES}) (owning process may be exiting)" >&2 sleep "$FLEET_SYNC_PACKED_REFS_LOCK_RETRY_WAIT_SECS" FETCH_OUTPUT=$(git -C "$PROJ" fetch origin --prune --quiet 2>&1); rc=$? - if [ "$rc" -eq 0 ]; then + if [ "$rc" -eq 0 ] && ! is_packed_refs_lock_error "$FETCH_OUTPUT"; then echo "$label: fetch succeeded on retry; packed-refs lock cleared on its own" >&2 # One stdout summary so a session-start refresh (which discards fleet-sync # stderr and relays only stdout) still surfaces the recovery. echo "$label: recovered: packed-refs lock cleared on its own during retry" return 0 fi - is_packed_refs_lock_error "$FETCH_OUTPUT" || return "$rc" + if [ "$rc" -ne 0 ]; then + is_packed_refs_lock_error "$FETCH_OUTPUT" || return "$rc" + fi done # Retries exhausted and still the lock signature. Clear ONLY if provably stale. @@ -192,22 +201,22 @@ fetch_with_packed_refs_lock_guard() { if fm_lock_is_provably_stale "$lock" "$PROJ" "$FLEET_SYNC_PACKED_REFS_LOCK_AGE_SECS"; then if ! rm -f "$lock"; then echo "$label: failed to remove provably-stale packed-refs lock $lock; leaving it in place" >&2 - return "$rc" + return 1 fi echo "$label: removed provably-stale packed-refs lock $lock (age >= ${FLEET_SYNC_PACKED_REFS_LOCK_AGE_SECS}s, no live holder) and retrying fetch" >&2 FETCH_OUTPUT=$(git -C "$PROJ" fetch origin --prune --quiet 2>&1); rc=$? - if [ "$rc" -eq 0 ]; then + if [ "$rc" -eq 0 ] && ! is_packed_refs_lock_error "$FETCH_OUTPUT"; then echo "$label: fetch succeeded after stale packed-refs lock cleanup" >&2 echo "$label: recovered: removed a stale packed-refs lock (no live holder)" return 0 fi - return "$rc" + return 1 fi echo "$label: fetch blocked by packed-refs lock $lock that persisted across ${FLEET_SYNC_PACKED_REFS_LOCK_RETRIES} retries and is not provably stale (may belong to a live process); leaving it in place" >&2 - return "$rc" + return 1 fi echo "$label: fetch packed-refs lock signature persisted across ${FLEET_SYNC_PACKED_REFS_LOCK_RETRIES} retries even after the lock file disappeared" >&2 - return "$rc" + return 1 } prune_gone_branches() { From 3d5673b07d9fa9e1ab0b0fd775968bba18de6501 Mon Sep 17 00:00:00 2001 From: fmtest Date: Wed, 15 Jul 2026 12:43:42 -0400 Subject: [PATCH 39/46] no-mistakes(test): Waiting on full test suite run to verify fm-afk-launch signal-race fix --- bin/fm-afk-launch.sh | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/bin/fm-afk-launch.sh b/bin/fm-afk-launch.sh index a6ddcbcc6..90c503eb5 100755 --- a/bin/fm-afk-launch.sh +++ b/bin/fm-afk-launch.sh @@ -77,22 +77,39 @@ fm_afk_launch_lock_owned() { } fm_afk_launch_lock_acquire() { - local i incomplete=0 identity + local i incomplete=0 identity pending="" mkdir -p "$FM_AFK_LAUNCH_STATE" || return 1 for i in $(seq 1 200); do if mkdir "$FM_AFK_LAUNCH_LOCK" 2>/dev/null; then + # The pid file (and the ps-backed identity lookup after it) take a few + # milliseconds to land. A TERM/INT arriving in that window would hit + # fm_afk_launch_main's normal 'exit N' traps before the lock is + # ownership-tagged, so fm_afk_launch_lock_release couldn't recognize + # and release it. Record the signal instead of exiting immediately, + # then act on it right after publishing finishes. + trap 'pending=TERM' TERM + trap 'pending=INT' INT if ! printf '%s' "$$" > "$FM_AFK_LAUNCH_LOCK/pid"; then rm -rf "$FM_AFK_LAUNCH_LOCK" + trap 'exit 143' TERM; trap 'exit 130' INT return 1 fi identity=$(fm_pid_identity "$$" 2>/dev/null) || { rm -rf "$FM_AFK_LAUNCH_LOCK" + trap 'exit 143' TERM; trap 'exit 130' INT return 1 } if [ -z "$identity" ] || ! printf '%s' "$identity" > "$FM_AFK_LAUNCH_LOCK/pid-identity"; then rm -rf "$FM_AFK_LAUNCH_LOCK" + trap 'exit 143' TERM; trap 'exit 130' INT return 1 fi + trap 'exit 143' TERM + trap 'exit 130' INT + case "$pending" in + TERM) fm_afk_launch_lock_release; exit 143 ;; + INT) fm_afk_launch_lock_release; exit 130 ;; + esac return 0 fi if [ ! -s "$FM_AFK_LAUNCH_LOCK/pid" ] || [ ! -s "$FM_AFK_LAUNCH_LOCK/pid-identity" ]; then @@ -603,10 +620,15 @@ fm_afk_launch_stop() { fm_afk_launch_main() { local result - fm_afk_launch_lock_acquire || return 1 + # Traps go up before lock_acquire, not after: fm_afk_launch_lock_release + # is a no-op until the lock's pid file names this process, so installing + # them early is safe and closes the window where a TERM/INT arriving right + # after the lock directory is created but before the trap is set would + # kill the process via the default disposition and leak the lock. trap fm_afk_launch_lock_release EXIT trap 'exit 130' INT trap 'exit 143' TERM + fm_afk_launch_lock_acquire || return 1 case "${1:-start}" in start) fm_afk_launch_start ;; start-native) fm_afk_launch_start_native ;; From c41ac4fa08f27520032551a6d0cd3556937ad1a1 Mon Sep 17 00:00:00 2001 From: fmtest Date: Wed, 15 Jul 2026 13:20:19 -0400 Subject: [PATCH 40/46] no-mistakes(document): Sync docs/configuration.md and docs/turnend-guard.md with turn-end-guard poll fix --- docs/configuration.md | 11 +++++++++++ docs/turnend-guard.md | 1 + 2 files changed, 12 insertions(+) diff --git a/docs/configuration.md b/docs/configuration.md index 01a0bc524..f15ffac06 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -108,6 +108,15 @@ An absent or empty file is a no-op, and a host without `systemctl` (no systemd) The file is not inherited by secondmate homes, whose same-machine bootstrap would only re-check the same units. See [`examples/critical-services`](examples/critical-services) for a copyable config; report a failed unit to the captain in plain language and never restart it, since a unit that failed may be unsafe to restart without knowing why. +## Upstream drift watch (upstream remote) + +For a firstmate maintainer's own working copy, post remote-swap `origin` is the captain's fork and `upstream` is the read-only parent template (`kunchenguid/firstmate`); see [`CONTRIBUTING.md`](../CONTRIBUTING.md) for the inverted layout. +At every session start bootstrap prints an always-on FYI line, `UPSTREAM_DRIFT: local main is ahead / behind upstream/main, last reconciled d ago ()`, computed unconditionally from whatever `refs/remotes/upstream/main` currently holds, without ever fetching in that detect-only path. +The network fetch that refreshes `upstream/main` runs only in the locked fleet-sync sweep, bounded by `FM_UPSTREAM_FETCH_TIMEOUT` (default 20s); a stalled or offline fetch there just leaves the last-fetched ref in place. +The wording escalates to `UPSTREAM_DRIFT: this repo's upstream sync needs attention - ...` once local `main` is more than 30 commits behind `upstream/main`, or their merge-base is older than 10 days. +Either way the line only reports - reconciling upstream (fetch, merge into local `main`, resolve conflicts, land local-only) stays a deliberately dispatched, reviewed ship task, never automated. +It is a no-op without an `upstream` remote, an `upstream/main` ref, or a local `main` branch. + ## Away-mode wedge alarm channels (config/wedge-alarm) When away-mode injection wedges past `FM_MAX_DEFER_SECS`, the sub-supervisor raises a loud, rate-limited alarm. @@ -398,6 +407,7 @@ FM_LOCK_STALE_AFTER=2 # seconds before dead-pid lock records can be reclaimed; FM_GUARD_GRACE=300 # seconds before guard warnings, arm health checks, and the primary turn-end guard treat a watcher beacon as stale FM_ARM_CONFIRM_TIMEOUT=10 # seconds fm-watch-arm waits to confirm a fresh watcher before reporting FAILED FM_ARM_ATTACH_POLL=0.5 # seconds between checks while fm-watch-arm is attached to an existing healthy watcher cycle +FM_TURNEND_ARM_WAIT= # seconds the turn-end guard polls fm_watcher_healthy before blocking, giving a backgrounded re-arm time to register its lock; unset defaults to FM_ARM_CONFIRM_TIMEOUT (docs/turnend-guard.md "Shared Predicate") FM_OPENCODE_ARM_READY_TIMEOUT_MS=12000 # milliseconds the OpenCode primary watcher plugin waits for an arm attempt to report started, healthy, wake, or failure FM_WATCHER_STALE_GRACE=300 # defaults to FM_GUARD_GRACE; seconds a live watcher lock may have a stale beacon before re-arm errors FM_SIGNAL_GRACE=30 # seconds to coalesce nearby status and turn-end signals into one wake @@ -414,6 +424,7 @@ FM_TIER_HEAVY_MIN_FILES=8 # fm-tier-guard.sh: changed-file count that e FM_WATCH_TRIAGE_LOG_MAX_BYTES=262144 # size cap for the watcher's absorbed-wake debug log FM_FLEET_SYNC_BOOTSTRAP_TIMEOUT= # optional seconds allowed for bootstrap's best-effort clone refresh; unset/blank defaults to max(20, 5 + 3 * origin-backed-project-count) FM_FLEET_PRUNE=1 # set to 0 to skip pruning local branches whose upstream is gone +FM_UPSTREAM_FETCH_TIMEOUT=20 # seconds allowed for the locked fleet-sync sweep's best-effort `git fetch upstream` behind the UPSTREAM_DRIFT diagnostic; a stall just leaves the last-fetched ref in place FM_NAS_DEPLOYMENTS_OVERRIDE= # alternate data/nas-deployments.md path for fm-nas-deploy-sync.sh, mainly for tests FM_NAS_SYNC_TIMEOUT=15 # seconds allowed per filesystem/git touch of fm-nas-deploy-sync.sh's $NAS_PATH before it is killed, so an unreachable NAS mount cannot hang fm-teardown.sh's caller FM_NAS_SYNC_PACKED_REFS_LOCK_RETRIES=3 # fetch retries after fm-nas-deploy-sync.sh hits the orphaned .git/packed-refs.lock signature on the shared NAS checkout (two landed teardowns for the same project can race it) diff --git a/docs/turnend-guard.md b/docs/turnend-guard.md index dadbc7400..ecb631d8d 100644 --- a/docs/turnend-guard.md +++ b/docs/turnend-guard.md @@ -149,5 +149,6 @@ No Herdr command was issued and no fleet state was touched; the experiment wrote ## Tests `tests/fm-turnend-guard.test.sh` covers the shared predicate, primary scoping (including a secondmate's own home being guarded like the main primary while its child worktrees stay exempt), `FM_HOME` and `FM_STATE_OVERRIDE` precedence, Pi logical-run latch behavior for no-tool and multi-tool runs, fail-open behavior without `jq`, tracked hook registration for all five harnesses, and the Grok adapter's forced-resume loop guard and permission-mode regression. +It also pins the bounded re-arm poll's timing: `test_hook_runs_fast` asserts the genuinely-blind path now blocks within the poll window instead of near-instantly (the stated tradeoff above), and `test_hook_allows_watcher_that_registers_mid_poll` confirms a watcher lock that registers partway through the window is let through rather than blocked. The default behavior suite does not invoke live language-model harnesses. `FM_PI_LIVE_E2E=1 tests/fm-pi-primary-live-e2e.test.sh` opts into the isolated interactive Pi regression recorded above. From 02a2d8d1c8d0fce33edddf0bb28f1f0f999883f8 Mon Sep 17 00:00:00 2001 From: fmtest Date: Wed, 15 Jul 2026 03:36:20 -0400 Subject: [PATCH 41/46] feat(watch): surface autodeploy-log failures on the periodic sweep Add a config/autodeploy-logs sweep to fm-watch.sh's heartbeat fleet-scan. Each configured status log's last line is read on every heartbeat, and a failure (an ALERT rollup or a STUCK:/FAILED: line in fleet-sync's convention) surfaces once as a check wake carrying that line until it clears; a later healthy run re-arms the alarm. Mirrors bootstrap's critical_services_check but fires periodically, so a deploy failure between sessions is caught instead of waiting for the next session start. Absent config, a healthy "ok" last line, or a missing/unreadable log (a NAS hiccup) are all silent and non-fatal, so the check stays cheap enough to run every heartbeat without spurious alarms. Config is gitignored and absent by default, so it is a no-op for fleets that do not opt in. --- AGENTS.md | 1 + bin/fm-watch.sh | 67 ++++++++++++++ docs/examples/autodeploy-logs | 24 +++++ tests/fm-watch-triage.test.sh | 160 ++++++++++++++++++++++++++++++++++ 4 files changed, 252 insertions(+) create mode 100644 docs/examples/autodeploy-logs diff --git a/AGENTS.md b/AGENTS.md index f90633a8b..0f6046fb7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -85,6 +85,7 @@ config/cmux-socket-password optional cmux control-socket password; LOCAL, gitig config/wedge-alarm optional away-mode wedge-alarm active-alert directives; LOCAL, gitignored; absent means auto (macOS Notification Center when available); see docs/wedge-alarm.md config/x-mode.env generated X-mode watcher cadence; LOCAL, gitignored; source before arming watcher when present config/critical-services optional systemd unit names to watch for the failed state, one per line, blank lines and full-line "#" comments ignored; LOCAL, gitignored; absent or empty = no-op; each failed unit surfaces as a read-only bootstrap SERVICE_FAILED diagnostic at every session start (section 3); NOT inherited into secondmate homes, whose same-machine bootstrap would only re-check the same units; see docs/examples/critical-services for a copyable sample +config/autodeploy-logs optional poll-based autodeploy status-log paths to watch for failure, one per line, blank lines and full-line "#" comments ignored; LOCAL, gitignored; absent or empty = no-op; each log's last line is read on the watcher's periodic heartbeat sweep and a failure (an "ALERT ..." rollup or a STUCK:/FAILED: line in firstmate's fleet-sync convention, bin/fm-fleet-sync.sh) surfaces once as a check wake until it clears, while a missing/unreadable log is skipped silently (bin/fm-watch.sh's autodeploy_scan); NOT inherited into secondmate homes, whose same-machine sweep would only re-check the same logs; see docs/examples/autodeploy-logs for a copyable sample data/ personal fleet records; LOCAL, gitignored as a whole backlog.md task queue, dependencies, history captain.md captain's personal preferences and working style; LOCAL, gitignored, canonical even if harness memory mirrors it, and updated with inspect-then-update diff --git a/bin/fm-watch.sh b/bin/fm-watch.sh index 13b895f00..289f62dae 100755 --- a/bin/fm-watch.sh +++ b/bin/fm-watch.sh @@ -43,6 +43,7 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" +CONFIG="${FM_CONFIG_OVERRIDE:-$FM_HOME/config}" mkdir -p "$STATE" # shellcheck source=bin/fm-wake-lib.sh @@ -450,6 +451,62 @@ mark_all_captain_relevant_surfaced() { done < <(scan_captain_relevant_statuses "$STATE") } +# --- Periodic autodeploy-log sweep ------------------------------------------ +# The always-on twin of bootstrap's critical_services_check, but for poll-based +# autodeploy jobs that write firstmate's fleet-sync STUCK:/FAILED:/ALERT line +# convention (bin/fm-fleet-sync.sh) to a durable status log, so a deploy failure +# between sessions surfaces on the periodic sweep instead of waiting for the next +# session start. Each non-empty, non-"#" line of config/autodeploy-logs names one +# such status log. The LAST line of each log is the authoritative per-run health: +# a healthy run ends in an "ok ..." rollup, a failed one in an "ALERT ..." rollup +# or a STUCK:/FAILED: alert line, so a later successful run self-clears the alarm. +# Absent config, an unreadable log (a NAS hiccup), or a healthy last line are all +# silent - the check is cheap and fail-quiet so it can run on every heartbeat. +_autodeploy_marker_path() { # -> per-log surfaced-marker path + printf '%s/.autodeploy-surfaced-%s' "$STATE" "$(printf '%s' "$1" | tr -c 'A-Za-z0-9' '_')" +} + +# Strip a leading ISO-8601 (date -Is) timestamp so a persistent identical failure +# dedupes across runs despite each run stamping a fresh time. A non-ISO fallback +# timestamp is left intact (degrades to re-surfacing per run, never to a miss). +_autodeploy_strip_ts() { # -> line without a leading ISO timestamp + printf '%s' "$1" | sed 's/^[0-9]\{4\}-[0-9]\{2\}-[0-9]\{2\}T[0-9:.,+-]*[[:space:]]*//' +} + +# Sweep config/autodeploy-logs. For each configured log whose last line reports a +# failure not already surfaced, enqueue a check wake carrying that line and record +# it surfaced; clear the record when a log reports healthy again so a recurrence +# re-arms. The enqueue happens BEFORE the marker advances, so a kill in between +# re-surfaces (at-least-once) rather than dropping the failure. Returns 0 when it +# enqueued at least one new failure (the caller then wakes), 1 otherwise. +autodeploy_scan() { + local file="$CONFIG/autodeploy-logs" log last stripped marker mfile label reason found=1 + [ -f "$file" ] || return 1 + while IFS= read -r log || [ -n "$log" ]; do + log="${log#"${log%%[![:space:]]*}"}" + log="${log%"${log##*[![:space:]]}"}" + [ -n "$log" ] || continue + case "$log" in '#'*) continue ;; esac + mfile=$(_autodeploy_marker_path "$log") + [ -r "$log" ] || continue # missing/unreadable: fail quiet, keep prior state + last=$(tail -n 1 "$log" 2>/dev/null) || continue + [ -n "$last" ] || continue + if printf '%s' "$last" | grep -Eq 'STUCK:|FAILED:|needs attention|(^|[[:space:]])ALERT([[:space:]]|$)'; then + stripped=$(_autodeploy_strip_ts "$last") + marker=$(cat "$mfile" 2>/dev/null || true) + [ "$stripped" = "$marker" ] && continue # this exact failure already surfaced + label=$(basename "$(dirname "$log")") + reason="check: autodeploy $label reports failure: $last" + fm_wake_append check "$log" "$reason" || return 1 + printf '%s' "$stripped" > "$mfile" + found=0 + else + rm -f "$mfile" # healthy run re-arms the alarm + fi + done < "$file" + return "$found" +} + # Cheap heartbeat fleet-scan (the always-on twin of the daemon's catch-all). 0 if # any captain-relevant status has NOT already been surfaced to firstmate (its # content differs from the .hb-surfaced- marker). Pure detect, no side @@ -869,6 +926,12 @@ EOF hb=$(( HEARTBEAT * (1 << streak) )) [ "$hb" -gt "$HEARTBEAT_MAX" ] && hb=$HEARTBEAT_MAX if [ "$(age_of "$STATE/.last-heartbeat")" -ge "$hb" ]; then + # Periodic autodeploy-log sweep, independent of the crew fleet-scan: enqueues a + # check wake per new failure and records it surfaced. Runs in every mode (the + # check wake it enqueues rides along in whatever wake this block emits, and the + # away-mode daemon escalates check wakes) so a deploy failure is caught whether + # or not the fleet has other work. + autodeploy_new=1; autodeploy_scan && autodeploy_new=0 # Triage: in always-on mode a heartbeat is benign unless the cheap fleet-scan # turns up a captain-relevant status the per-wake path missed. Absorb the # no-change case (advance the schedule and back off exactly as wake() would, @@ -886,6 +949,10 @@ EOF touch "$STATE/.last-heartbeat" mark_all_captain_relevant_surfaced wake "heartbeat" + elif [ "$autodeploy_new" = 0 ]; then + # A new autodeploy failure was enqueued above; surface it now as a check. + touch "$STATE/.last-heartbeat" + wake "check: autodeploy failure" else touch "$STATE/.last-heartbeat" echo $(( $(cat "$STATE/.heartbeat-streak" 2>/dev/null || echo 0) + 1 )) > "$STATE/.heartbeat-streak" diff --git a/docs/examples/autodeploy-logs b/docs/examples/autodeploy-logs new file mode 100644 index 000000000..ae3ab551e --- /dev/null +++ b/docs/examples/autodeploy-logs @@ -0,0 +1,24 @@ +# config/autodeploy-logs - poll-based autodeploy status logs firstmate watches +# for failure on its periodic sweep (bin/fm-watch.sh's autodeploy_scan). Copy +# this into a local, gitignored config/autodeploy-logs and replace the +# placeholder with your own status-log path(s). One absolute log path per +# non-empty, non-comment line; blank lines and full-line "#" comments are +# ignored. +# +# Each listed log is expected to follow firstmate's fleet-sync line convention +# (bin/fm-fleet-sync.sh): a durable, timestamped, append-only status log whose +# LAST line reports the most recent run's health - a healthy run ends in an +# "ok ..." rollup, a failed one in an "ALERT ..." rollup or a STUCK:/FAILED: +# alert line. On its periodic sweep firstmate reads only that last line; when it +# reports a failure not already surfaced, firstmate wakes and relays the line to +# the captain, then stays quiet about that same failure until it changes or a +# later successful run clears it. It never touches the deploy: reading the log is +# the whole job. +# +# An ABSENT or empty config/autodeploy-logs is a no-op. A missing or temporarily +# unreadable log (e.g. a NAS mount hiccup) is silently skipped rather than +# alarmed, so a transient outage does not spuriously wake the captain. +# +# The path below is an illustrative placeholder - list the status log(s) whose +# silent deploy failure would actually matter for this fleet. +/home/you/.local/state/my-app-autodeploy/status.log diff --git a/tests/fm-watch-triage.test.sh b/tests/fm-watch-triage.test.sh index 4cbc4de4f..a2e6485d1 100755 --- a/tests/fm-watch-triage.test.sh +++ b/tests/fm-watch-triage.test.sh @@ -1118,6 +1118,158 @@ test_afk_paused_changed_pane_hands_off_plain_stale() { pass "AFK changed paused panes hand off plain stale identities for daemon-owned pause triage" } +# --- periodic autodeploy-log sweep (config/autodeploy-logs) ------------------ + +# Run autodeploy_scan once against / by sourcing fm-watch.sh in a +# subshell - its source guard returns before the runtime lock/loop, so only the +# functions load. Echoes "rc="; the durable wake queue and per-log surfaced +# markers land under . Keeps the scan hermetic (no real watcher, no timing). +run_autodeploy_scan() { # + # Pass fm-watch.sh as $1 (not $0), so its BASH_SOURCE[0] != $0 source guard + # fires and the runtime loop below it never starts (same trick as append_wake). + FM_STATE_OVERRIDE="$1" FM_CONFIG_OVERRIDE="$2" \ + bash -c '. "$1"; autodeploy_scan; printf "rc=%s\n" "$?"' _ "$WATCH" 2>/dev/null +} + +# Marker path fm-watch.sh's autodeploy_scan uses for a given log (mirror the +# script's tr sanitization so a test can assert on the record directly). +autodeploy_marker() { # + printf '%s/.autodeploy-surfaced-%s' "$1" "$(printf '%s' "$2" | tr -c 'A-Za-z0-9' '_')" +} + +test_autodeploy_absent_config_is_noop() { + local dir state config rc + dir=$(make_case autodeploy-absent); state="$dir/state"; config="$dir/config" + mkdir -p "$config" # config dir present, but no autodeploy-logs file in it + rc=$(run_autodeploy_scan "$state" "$config") + [ "$rc" = "rc=1" ] || fail "absent config/autodeploy-logs was not a no-op (got $rc)" + [ ! -s "$state/.wake-queue" ] || fail "absent config enqueued a wake" + pass "an absent config/autodeploy-logs is a silent no-op" +} + +test_autodeploy_failure_enqueues_labelled_check() { + local dir state config log rc + dir=$(make_case autodeploy-fail); state="$dir/state"; config="$dir/config" + mkdir -p "$config" "$state/beamanalyzer-autodeploy" + log="$state/beamanalyzer-autodeploy/status.log" + printf '%s\n' "$log" > "$config/autodeploy-logs" + # A failing run: an ok rollup, then a STUCK alert line, then the ALERT rollup + # that is the run's authoritative last line. + printf '2026-07-15T10:00:00+00:00 ok nas=unchanged prod=unchanged deployed=no\n' > "$log" + printf '2026-07-15T10:05:00+00:00 beamanalyzer-autodeploy: prod: STUCK: v2 has diverged - needs attention\n' >> "$log" + printf '2026-07-15T10:05:00+00:00 ALERT nas=unchanged prod=unsafe deployed=no\n' >> "$log" + rc=$(run_autodeploy_scan "$state" "$config") + [ "$rc" = "rc=0" ] || fail "a failing autodeploy last line was not actionable (got $rc)" + grep "$(printf '\tcheck\t')" "$state/.wake-queue" >/dev/null || fail "no check wake enqueued for the failure" + grep -F "ALERT nas=unchanged prod=unsafe" "$state/.wake-queue" >/dev/null || fail "check wake did not carry the failing last line" + grep -F "autodeploy beamanalyzer-autodeploy reports failure" "$state/.wake-queue" >/dev/null || fail "check wake did not label the deploy" + [ -s "$(autodeploy_marker "$state" "$log")" ] || fail "failure was not recorded surfaced" + pass "a failing autodeploy last line enqueues a labelled check wake and records it surfaced" +} + +test_autodeploy_healthy_is_silent_and_rearms() { + local dir state config log marker rc + dir=$(make_case autodeploy-healthy); state="$dir/state"; config="$dir/config" + mkdir -p "$config" "$state/deploy" + log="$state/deploy/status.log" + printf '%s\n' "$log" > "$config/autodeploy-logs" + marker=$(autodeploy_marker "$state" "$log") + printf 'ALERT nas=unsafe prod=unchanged deployed=no' > "$marker" # stale prior alarm + printf '2026-07-15T11:00:00+00:00 ok nas=unchanged prod=unchanged deployed=no\n' > "$log" + rc=$(run_autodeploy_scan "$state" "$config") + [ "$rc" = "rc=1" ] || fail "a healthy ok last line was treated as actionable (got $rc)" + [ ! -s "$state/.wake-queue" ] || fail "a healthy last line enqueued a wake" + [ ! -e "$marker" ] || fail "a healthy run did not clear the surfaced marker (alarm would never re-arm)" + pass "a healthy ok last line is silent and clears any prior surfaced marker" +} + +test_autodeploy_persistent_failure_dedupes() { + local dir state config log rc1 rc2 count + dir=$(make_case autodeploy-dedupe); state="$dir/state"; config="$dir/config" + mkdir -p "$config" "$state/deploy" + log="$state/deploy/status.log" + printf '%s\n' "$log" > "$config/autodeploy-logs" + printf '2026-07-15T12:00:00+00:00 ALERT nas=unsafe prod=unchanged deployed=no\n' > "$log" + rc1=$(run_autodeploy_scan "$state" "$config") + # A later run reports the SAME failure but stamps a fresh time (autodeploy re-runs + # every few minutes); the timestamp must not defeat dedupe. + printf '2026-07-15T12:05:00+00:00 ALERT nas=unsafe prod=unchanged deployed=no\n' >> "$log" + rc2=$(run_autodeploy_scan "$state" "$config") + [ "$rc1" = "rc=0" ] || fail "first scan of a new failure was not actionable (got $rc1)" + [ "$rc2" = "rc=1" ] || fail "an unchanged persistent failure re-surfaced despite a fresh timestamp (got $rc2)" + count=$(grep -c "$(printf '\tcheck\t')" "$state/.wake-queue" 2>/dev/null || true) + [ "${count:-0}" = "1" ] || fail "persistent failure enqueued ${count:-0} check wakes, expected exactly 1" + pass "a persistent identical failure surfaces once despite each run's changing timestamp" +} + +test_autodeploy_recurrence_after_clear_resurfaces() { + local dir state config log rc_fail rc_ok rc_again count + dir=$(make_case autodeploy-recur); state="$dir/state"; config="$dir/config" + mkdir -p "$config" "$state/deploy" + log="$state/deploy/status.log" + printf '%s\n' "$log" > "$config/autodeploy-logs" + printf '2026-07-15T13:00:00+00:00 ALERT nas=unsafe prod=unchanged deployed=no\n' > "$log" + rc_fail=$(run_autodeploy_scan "$state" "$config") + printf '2026-07-15T13:05:00+00:00 ok nas=unchanged prod=unchanged deployed=no\n' >> "$log" + rc_ok=$(run_autodeploy_scan "$state" "$config") + printf '2026-07-15T13:10:00+00:00 ALERT nas=unsafe prod=unchanged deployed=no\n' >> "$log" + rc_again=$(run_autodeploy_scan "$state" "$config") + [ "$rc_fail" = "rc=0" ] || fail "initial failure not actionable (got $rc_fail)" + [ "$rc_ok" = "rc=1" ] || fail "healthy recovery not silent (got $rc_ok)" + [ "$rc_again" = "rc=0" ] || fail "a failure recurring after a clean run did not re-surface (got $rc_again)" + count=$(grep -c "$(printf '\tcheck\t')" "$state/.wake-queue" 2>/dev/null || true) + [ "${count:-0}" = "2" ] || fail "expected 2 check wakes across fail/clear/fail, got ${count:-0}" + pass "a failure recurring after a healthy run re-surfaces once the alarm re-armed" +} + +test_autodeploy_unreadable_log_is_silent() { + local dir state config rc + dir=$(make_case autodeploy-unreadable); state="$dir/state"; config="$dir/config" + mkdir -p "$config" + # Point at a log that does not exist; a NAS mount hiccup looks the same. + printf '%s\n' "$state/nope/status.log" > "$config/autodeploy-logs" + rc=$(run_autodeploy_scan "$state" "$config") + [ "$rc" = "rc=1" ] || fail "a missing/unreadable log was not skipped silently (got $rc)" + [ ! -s "$state/.wake-queue" ] || fail "a missing log enqueued a wake" + pass "a missing or unreadable configured log is skipped silently, not alarmed" +} + +test_autodeploy_comments_blanks_and_whitespace() { + local dir state config log rc + dir=$(make_case autodeploy-comments); state="$dir/state"; config="$dir/config" + mkdir -p "$config" "$state/deploy" + log="$state/deploy/status.log" + { + printf '# watched autodeploy logs\n' + printf '\n' + printf ' %s \n' "$log" # surrounding whitespace must be trimmed + } > "$config/autodeploy-logs" + printf '2026-07-15T14:00:00+00:00 deploy: build: FAILED: npm ERR code ELIFECYCLE\n' > "$log" + rc=$(run_autodeploy_scan "$state" "$config") + [ "$rc" = "rc=0" ] || fail "a FAILED: last line on a whitespace-padded path was not detected (got $rc)" + grep -F "build: FAILED: npm ERR" "$state/.wake-queue" >/dev/null || fail "FAILED: line not surfaced" + pass "blank lines and # comments are ignored and surrounding whitespace on a path is trimmed" +} + +test_autodeploy_failure_surfaced_on_heartbeat() { + local dir state fakebin config out drain_out pid + dir=$(make_case autodeploy-heartbeat); state="$dir/state"; fakebin="$dir/fakebin" + config="$dir/config"; out="$dir/watch.out"; drain_out="$dir/drain.out" + mkdir -p "$config" "$state/beamanalyzer-autodeploy" + printf '%s\n' "$state/beamanalyzer-autodeploy/status.log" > "$config/autodeploy-logs" + printf '2026-07-15T10:30:00+00:00 ALERT nas=unsafe prod=unchanged deployed=no\n' \ + > "$state/beamanalyzer-autodeploy/status.log" + PATH="$fakebin:$PATH" FM_STATE_OVERRIDE="$state" FM_CONFIG_OVERRIDE="$config" \ + FM_POLL=1 FM_SIGNAL_GRACE=1 FM_CHECK_INTERVAL=999999 FM_HEARTBEAT=1 "$WATCH" > "$out" & + pid=$! + wait_for_exit "$pid" 40 || fail "watcher did not surface a configured autodeploy failure on the periodic sweep" + grep -F "check: autodeploy" "$out" >/dev/null || fail "watcher did not exit with a check wake for the autodeploy failure: $(cat "$out")" + FM_STATE_OVERRIDE="$state" "$DRAIN" > "$drain_out" 2>/dev/null || fail "drain after autodeploy heartbeat failed" + grep "$(printf '\tcheck\t')" "$drain_out" >/dev/null || fail "autodeploy check wake was not queued" + grep -F "ALERT nas=unsafe" "$drain_out" >/dev/null || fail "drained check wake did not carry the failure line" + pass "a configured autodeploy status log's failure surfaces as a check wake on the watcher's periodic sweep" +} + test_signal_reason_is_actionable_classifier test_stale_is_terminal_classifier test_scan_captain_relevant_statuses_classifier @@ -1151,3 +1303,11 @@ test_heartbeat_backstop_surfaces_unsurfaced_status test_beacon_stays_fresh_while_absorbing test_afk_present_reverts_watcher_to_one_shot test_afk_paused_changed_pane_hands_off_plain_stale +test_autodeploy_absent_config_is_noop +test_autodeploy_failure_enqueues_labelled_check +test_autodeploy_healthy_is_silent_and_rearms +test_autodeploy_persistent_failure_dedupes +test_autodeploy_recurrence_after_clear_resurfaces +test_autodeploy_unreadable_log_is_silent +test_autodeploy_comments_blanks_and_whitespace +test_autodeploy_failure_surfaced_on_heartbeat From 38aa0d975f5518866edf14a2aa2782fcc935d11d Mon Sep 17 00:00:00 2001 From: fmtest Date: Wed, 15 Jul 2026 14:02:28 -0400 Subject: [PATCH 42/46] feat(bootstrap): add session-start check for autodeploy-log failures Factor the failure predicate into the shared bin/fm-autodeploy-lib.sh (fm_autodeploy_line_failed), sourced by both the fm-watch.sh sweep and a new fm-bootstrap.sh autodeploy_logs_check, so an idle fleet catches a deploy failure at the next session start exactly as critical-services already does. One owner for the match set; docs and colocated tests updated to match. --- .agents/skills/bootstrap-diagnostics/SKILL.md | 5 +- AGENTS.md | 4 +- bin/fm-autodeploy-lib.sh | 15 ++++ bin/fm-bootstrap.sh | 31 ++++++++ bin/fm-watch.sh | 19 +++-- docs/examples/autodeploy-logs | 17 ++-- tests/fm-bootstrap.test.sh | 79 +++++++++++++++++++ 7 files changed, 153 insertions(+), 17 deletions(-) create mode 100644 bin/fm-autodeploy-lib.sh diff --git a/.agents/skills/bootstrap-diagnostics/SKILL.md b/.agents/skills/bootstrap-diagnostics/SKILL.md index 7bc7107d0..946abb37e 100644 --- a/.agents/skills/bootstrap-diagnostics/SKILL.md +++ b/.agents/skills/bootstrap-diagnostics/SKILL.md @@ -2,7 +2,7 @@ name: bootstrap-diagnostics description: >- Agent-only handling playbook for session-start bootstrap diagnostics. - Use whenever the session-start digest's bootstrap section prints any diagnostic or capability line - MISSING, MISSING_MANUAL, BACKEND_INVALID, NEEDS_GH_AUTH, TANGLE, CREW_HARNESS_OVERRIDE, CREW_DISPATCH, FLEET_SYNC, SECONDMATE_SYNC, SECONDMATE_LIVENESS, TASKS_AXI, NUDGE_SECONDMATES, FMX, SERVICE_FAILED, or UPSTREAM_DRIFT - or when a standalone bin/fm-bootstrap.sh run prints one. + Use whenever the session-start digest's bootstrap section prints any diagnostic or capability line - MISSING, MISSING_MANUAL, BACKEND_INVALID, NEEDS_GH_AUTH, TANGLE, CREW_HARNESS_OVERRIDE, CREW_DISPATCH, FLEET_SYNC, SECONDMATE_SYNC, SECONDMATE_LIVENESS, TASKS_AXI, NUDGE_SECONDMATES, FMX, SERVICE_FAILED, AUTODEPLOY_FAILED, or UPSTREAM_DRIFT - or when a standalone bin/fm-bootstrap.sh run prints one. A silent bootstrap section means all good and needs no skill load. user-invocable: false metadata: @@ -50,6 +50,9 @@ The inline rules in `AGENTS.md` section 3 still bind: detect, then consent, then - `SERVICE_FAILED: - failed since ` - a systemd unit named in the optional local `config/critical-services` file is in the failed state (`docs/configuration.md` "Critical service watch"); a pure read-only detection (`systemctl is-failed`, no root), so a read-only session still surfaces it. Report it to the captain in plain language; never restart the unit yourself - a unit that failed may be unsafe to restart without knowing why. An absent or empty config file, a host without `systemctl`, or no failed unit all print nothing. +- `AUTODEPLOY_FAILED: