From 514f0ab89e3f1964fddb05d8f206db18febe705c Mon Sep 17 00:00:00 2001 From: Christopher McKay <101884182+karotkriss@users.noreply.github.com> Date: Tue, 28 Jul 2026 14:34:33 -0400 Subject: [PATCH 01/70] fix: restore stock macOS Bash 3.2 brief scaffolding (#1093) * fix(brief): make DOD scaffolding parse-safe on stock macOS Bash 3.2 fm-brief.sh built each Definition-of-done block and the not-enabled Herdr declaration with `VAR=$(cat </dev/null || { echo "::error::jq is required"; exit 1; } - /bin/bash -n bin/fm-fleet-snapshot.sh + + shell_inventory="$RUNNER_TEMP/fm-shell-inventory" + bin/fm-lint.sh --list-files > "$shell_inventory" + parse_fail=0 + while IFS= read -r f; do + /bin/bash -n "$f" || { echo "::error::stock macOS Bash 3.2 failed to parse $f"; parse_fail=1; } + done < "$shell_inventory" + [ "$parse_fail" -eq 0 ] || { echo "::error::stock macOS Bash 3.2 parse sweep failed"; exit 1; } snapshot_output=$(/bin/bash tests/fm-fleet-snapshot-view.test.sh) printf '%s\n' "$snapshot_output" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7f90f00202..7c3a1cab18 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -71,7 +71,7 @@ That is firstmate-specific; do not commit `.no-mistakes/evidence/` here even whe Check and test the toolbelt before pushing: ```sh -for script in bin/*.sh bin/backends/*.sh; do bash -n "$script"; done # syntax-check the toolbelt +while IFS= read -r script; do /bin/bash -n "$script" || exit; done < <(bin/fm-lint.sh --list-files) # syntax-check the canonical shell surface bin/fm-lint.sh # lint the toolbelt and behavior tests; the single owner CI and the no-mistakes gate both run bin/fm-test-run.sh tests/.test.sh # one script (primary local focus path, timed) bin/fm-test-run.sh --family pure-contract-unit # ordinary family-scoped local path (serial, timed) @@ -93,7 +93,7 @@ Its header and `--help` own the flags, family labels, lanes, and changed-file ma Portable shard balance evidence lives in `docs/fm-test-portable-shards.md`. Local no-mistakes Test stays intent-targeted and must not wire `commands.test` to `--all` or a `tests/*.test.sh` walk. Family selection is the ordinary local path; `--all` is deliberate full regression only. -CI owns broad regression across required portable parallel shards, the portable serial lane, the Herdr lane, lint, invariants, the coverage guard, and macOS snapshot compatibility in [`.github/workflows/ci.yml`](.github/workflows/ci.yml). +CI owns broad regression across required portable parallel shards, the portable serial lane, the Herdr lane, lint, invariants, the coverage guard, and stock macOS Bash compatibility in [`.github/workflows/ci.yml`](.github/workflows/ci.yml). Use `bin/fm-test-run.sh --help` for lane names, `--jobs` rules, and required gate-skip flags when reproducing a lane locally. Discover tests by listing `tests/*.test.sh`: each is a self-contained bash script named `.test.sh`, and its header comment describes what it covers, so pass one to `bin/fm-test-run.sh` to focus on a subject with canonical timing output. Tests that need a real optional backend or an explicit opt-in (real herdr/zellij/cmux smoke tests, the live Pi regression) skip themselves and print the tool or environment gate needed to enable them, so the portable suite remains safe on machines without those tools. diff --git a/bin/fm-brief.sh b/bin/fm-brief.sh index 00ea34ddab..8125aa2e9e 100755 --- a/bin/fm-brief.sh +++ b/bin/fm-brief.sh @@ -217,13 +217,13 @@ HERDR_SECTION=$(printf '%s\n' \ 'Never bypass the helper, even for a read-only lifecycle probe or cleanup after failure.' \ 'The captain fleet uses the running `default` session.') else -HERDR_SECTION=$(cat <<'EOF' +IFS= read -r -d '' HERDR_SECTION <<'EOF' || true # Herdr lifecycle declaration - NOT ENABLED **HARD SAFETY GATE:** this scaffold cannot inspect the task text that replaces `{TASK}` later. If the task will start, stop, delete, restart, profile, or otherwise drive Herdr lifecycle behavior, stop and regenerate the brief with `--herdr-lab` before dispatch. Do not add Herdr lifecycle commands to this unguarded brief by hand. EOF -) +HERDR_SECTION=${HERDR_SECTION%$'\n'} fi if [ "$KIND" = scout ]; then @@ -285,19 +285,18 @@ case "$MODE" in direct-PR) SETUP2="" RULE1='1. Never push to the default branch (push only your `fm/'"$ID"'` branch). Never merge a PR.' - DOD=$(cat < "$BRIEF" < [path]... override bounded worker count # fm-lint.sh --telemetry ... write a quiet metrics snapshot # fm-lint.sh --required-version print the ShellCheck pin +# fm-lint.sh --list-files print the canonical file set # fm-lint.sh --help print this usage set -u @@ -83,11 +84,12 @@ if [ "${1:-}" = "--required-version" ]; then fi fm_lint_usage() { - sed -n '2,25{s/^# \{0,1\}//;p;}' "$SELF" + sed -n '2,26{s/^# \{0,1\}//;p;}' "$SELF" } JOBS=${FM_LINT_JOBS:-2} TELEMETRY=${FM_LINT_TELEMETRY:-} +LIST_FILES=0 while [ "$#" -gt 0 ]; do case "$1" in --jobs) @@ -108,6 +110,10 @@ while [ "$#" -gt 0 ]; do TELEMETRY=${1#*=} shift ;; + --list-files) + LIST_FILES=1 + shift + ;; --help|-h) fm_lint_usage exit 0 @@ -125,6 +131,22 @@ case "$JOBS" in *) printf 'fm-lint.sh: jobs must be 1 or 2, got %s.\n' "$JOBS" >&2; exit 2 ;; esac +if [ "$#" -gt 0 ]; then + ROOTS=("$@") +else + ROOTS=(bin/*.sh bin/backends/*.sh tests/*.sh) +fi +ROOT_COUNT=${#ROOTS[@]} + +if [ "$LIST_FILES" -eq 1 ]; then + [ "$#" -eq 0 ] || { + printf 'fm-lint.sh: --list-files does not accept explicit paths.\n' >&2 + exit 2 + } + printf '%s\n' "${ROOTS[@]}" + exit 0 +fi + if ! command -v shellcheck >/dev/null 2>&1; then printf 'fm-lint.sh: ShellCheck not found; install ShellCheck %s for CI parity.\n' \ "$REQUIRED_SHELLCHECK" >&2 @@ -144,15 +166,6 @@ if [ "$resolved" != "$REQUIRED_SHELLCHECK" ]; then exit 1 fi -if [ "$#" -gt 0 ]; then - ROOTS=("$@") -else - # Canonical file set: the one authoritative definition. Callers never repeat - # these globs, and every adapter and test shell remains an independent root. - ROOTS=(bin/*.sh bin/backends/*.sh tests/*.sh) -fi -ROOT_COUNT=${#ROOTS[@]} - if [ -n "$TELEMETRY" ]; then telemetry_parent=$(dirname "$TELEMETRY") [ -d "$telemetry_parent" ] || { diff --git a/tests/fm-brief.test.sh b/tests/fm-brief.test.sh index 74690eab42..bed170b741 100755 --- a/tests/fm-brief.test.sh +++ b/tests/fm-brief.test.sh @@ -1,14 +1,18 @@ #!/usr/bin/env bash # Behavior tests for bin/fm-brief.sh. # -# Regression coverage for the heredoc-in-command-substitution parse bug (issue -# #166): each ship-mode branch builds its Definition-of-done text with -# `VAR=$(cat < file <&1); rc=$? @@ -29,6 +34,142 @@ test_script_parses() { pass "fm-brief.sh: bash -n succeeds" } +# Structural class guard (issues #166, #958, #1069): never build a variable by +# wrapping a heredoc in a command substitution (`VAR=$(cat < "$unsafe" + # shellcheck disable=SC2016 # Literal shell fixtures must remain unexpanded. + printf '%s\n' 'cat < "$safe" + if no_heredoc_in_command_substitution "$unsafe"; then + fail "structural guard accepted a multiline heredoc nested in a command substitution" + fi + no_heredoc_in_command_substitution "$safe" \ + || fail "structural guard treated heredoc body prose as shell structure" + no_heredoc_in_command_substitution "$ROOT/bin/fm-brief.sh" \ + || fail "fm-brief.sh wraps a heredoc in a command substitution (breaks Bash 3.2 parsing)" + pass "fm-brief.sh: no heredoc is nested inside a command substitution (Bash 3.2 parse-safe)" +} + +no_heredoc_in_command_substitution() { + perl - "$1" <<'PERL' +use strict; +use warnings; + +my $path = shift; +open my $source, '<', $path or die "$path: $!\n"; +my @frames; +my @heredocs; +my $quote = ''; +my $line_number = 0; + +while (my $line = <$source>) { + $line_number++; + if (@heredocs) { + my $candidate = $line; + $candidate =~ s/\r?\n\z//; + $candidate =~ s/^\t+// if $heredocs[0]{strip_tabs}; + shift @heredocs if $candidate eq $heredocs[0]{delimiter}; + next; + } + + my $length = length $line; + for (my $i = 0; $i < $length; $i++) { + my $char = substr($line, $i, 1); + if ($quote eq "'") { + $quote = '' if $char eq "'"; + next; + } + if ($char eq '\\') { + $i++; + next; + } + if ($quote eq '"' && $char eq '"') { + $quote = ''; + next; + } + if ($char eq "'" && $quote eq '') { + $quote = "'"; + next; + } + if ($char eq '"' && $quote eq '') { + $quote = '"'; + next; + } + if ($char eq '#' && $quote eq '' && ($i == 0 || substr($line, $i - 1, 1) =~ /[\s;|&()]/)) { + last; + } + if ($char eq '$' && substr($line, $i + 1, 1) eq '(') { + push @frames, { depth => 1, quote => $quote }; + $quote = ''; + $i++; + next; + } + if (@frames && $quote eq '' && $char eq '(') { + $frames[-1]{depth}++; + next; + } + if (@frames && $quote eq '' && $char eq ')') { + $frames[-1]{depth}--; + if ($frames[-1]{depth} == 0) { + my $frame = pop @frames; + $quote = $frame->{quote}; + } + next; + } + next unless $quote eq '' && $char eq '<' && substr($line, $i + 1, 1) eq '<'; + if (@frames) { + print STDERR "$path:$line_number\n"; + exit 1; + } + + my $j = $i + 2; + my $strip_tabs = substr($line, $j, 1) eq '-'; + $j++ if $strip_tabs; + $j++ while substr($line, $j, 1) =~ /[ \t]/; + my $delimiter = ''; + my $delimiter_quote = ''; + for (; $j < $length; $j++) { + my $token = substr($line, $j, 1); + if ($delimiter_quote) { + if ($token eq $delimiter_quote) { + $delimiter_quote = ''; + } elsif ($token eq '\\' && $delimiter_quote eq '"') { + $j++; + $delimiter .= substr($line, $j, 1); + } else { + $delimiter .= $token; + } + next; + } + if ($token eq "'" || $token eq '"') { + $delimiter_quote = $token; + next; + } + if ($token eq '\\') { + $j++; + $delimiter .= substr($line, $j, 1); + next; + } + last if $token =~ /[\s;|&()<>]/; + $delimiter .= $token; + } + push @heredocs, { delimiter => $delimiter, strip_tabs => $strip_tabs }; + $i = $j - 1; + } +} + +exit 0; +PERL +} + test_help_includes_entire_header() { local help help=$("$ROOT/bin/fm-brief.sh" --help) @@ -114,9 +255,13 @@ test_no_mistakes_dod_wording() { # shellcheck disable=SC2016 # single quotes are deliberate: the backticks must stay literal assert_grep '`help`' "$brief" \ "no-mistakes DOD must render literal backticks around help" - assert_no_grep "no-mistakes' own guidance" "$brief" \ - "no-mistakes DOD regressed to the apostrophe form that breaks bash -n" - pass "fm-brief.sh: no-mistakes DOD wording avoids the apostrophe regression" + # The apostrophe in "firstmate's authority check" is now structurally safe + # (no `$(...)` wrapper around the heredoc), so it renders verbatim instead of + # being reworded or escaped away. test_no_heredoc_in_command_substitution + # guards the structure that makes it safe. + assert_grep "firstmate's authority check" "$brief" \ + "no-mistakes DOD lost the apostrophe prose that the structural fix makes parse-safe" + pass "fm-brief.sh: no-mistakes DOD keeps its apostrophe prose, now parse-safe" } test_ship_project_memory_wording() { @@ -383,6 +528,7 @@ test_scout_and_secondmate_scaffold() { } test_script_parses +test_no_heredoc_in_command_substitution test_help_includes_entire_header test_ship_modes_generate_clean_briefs test_faster_paths_use_configured_authority_without_stacked_review diff --git a/tests/fm-lint.test.sh b/tests/fm-lint.test.sh index 4a1b18d7dc..a2b3c8fb29 100755 --- a/tests/fm-lint.test.sh +++ b/tests/fm-lint.test.sh @@ -58,6 +58,20 @@ test_ci_invokes_the_owner() { pass "CI lint job calls the one-owner script, not an inline command" } +test_stock_bash_parse_uses_owner_inventory() { + local listed expected + listed=$("$LINT" --list-files) + expected=$(find bin bin/backends tests -maxdepth 1 -type f -name '*.sh' -print | LC_ALL=C sort) + [ "$(printf '%s\n' "$listed" | LC_ALL=C sort)" = "$expected" ] \ + || fail "fm-lint.sh --list-files did not return the complete canonical shell inventory" + # shellcheck disable=SC2016 # Literal assertion must remain unexpanded. + assert_grep 'bin/fm-lint.sh --list-files > "$shell_inventory"' "$CI" \ + "stock macOS Bash parse sweep must consume fm-lint.sh's canonical inventory" + assert_no_grep 'for f in bin/*.sh bin/backends/*.sh tests/*.sh' "$CI" \ + "stock macOS Bash parse sweep must not duplicate the canonical inventory" + pass "stock macOS Bash parse sweep consumes the canonical lint inventory" +} + test_nomistakes_invokes_the_owner() { grep -Fqx " lint: 'bin/fm-lint.sh'" "$NM" || fail "no-mistakes commands.lint must map exactly to the one-owner script" pass "no-mistakes pre-push lint calls the one-owner script" @@ -485,6 +499,7 @@ SH test_owner_exists_and_executable test_owner_defines_canonical_set test_ci_invokes_the_owner +test_stock_bash_parse_uses_owner_inventory test_nomistakes_invokes_the_owner test_pins_an_explicit_version test_ci_installs_and_logs_the_pinned_version From 2459f77f295640f931d457e8304c48c53abba93a Mon Sep 17 00:00:00 2001 From: Kun Chen <3233006+kunchenguid@users.noreply.github.com> Date: Tue, 28 Jul 2026 12:05:56 -0700 Subject: [PATCH 02/70] test: stabilize tmux teardown conformance baseline (#1209) * fix(test): pin teardown tmux baseline to historical kill selectors merge-base HEAD main collapses to HEAD after the exact-selector change lands on the default branch, so the old teardown fixture was accidentally exercising current exact targets. Resolve a content-historical permissive tmux adapter from first-parent history and force that post-squash topology inside the conformance case so main and feature branches keep the same old-vs-new contract. * no-mistakes(lint): Suppress intentional literal-pattern ShellCheck warnings --- tests/fm-backend.test.sh | 77 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 75 insertions(+), 2 deletions(-) diff --git a/tests/fm-backend.test.sh b/tests/fm-backend.test.sh index 323cd4f5e5..922b227cec 100755 --- a/tests/fm-backend.test.sh +++ b/tests/fm-backend.test.sh @@ -12,7 +12,10 @@ # binaries and fixtures as the REFACTORED versions in this checkout, then # diffs the two command logs byte-for-byte - the report's P1 checklist # item "run current main scripts and refactored scripts against the same -# fake tools and compare command logs". +# fake tools and compare command logs". The teardown old-vs-new case also +# overlays a content-historical permissive tmux kill fixture: after the +# exact-selector change lands on the default branch, merge-base with main +# collapses to HEAD and can no longer supply that baseline. # 3. Asserts the `--backend`/`FM_BACKEND` selection refuses unknown backends # and the blocked `codex-app` backend loudly. # @@ -80,6 +83,9 @@ SH } # The commit this branch started from - the P1 "current main" baseline. +# Suitable for byte-identical old-vs-new checks while a branch still diverges +# from main. After a squash lands, merge-base(HEAD, main) collapses to HEAD, so +# callers that need a true pre-change fixture must not rely on this alone. resolve_base_ref() { local ref base for ref in main refs/heads/main origin/main refs/remotes/origin/main origin/HEAD refs/remotes/origin/HEAD; do @@ -95,6 +101,30 @@ resolve_base_ref() { BASE_REF=$(resolve_base_ref) \ || fail "fm-backend baseline requires local main or origin/main; fetch the default branch before running this test" +# Newest first-parent revision whose bin/backends/tmux.sh still uses the +# pre-exact permissive kill-window target. Content-addressed from history so the +# fixture stays historical on default-branch CI and on branches cut after the +# exact-selector change, where merge-base with main is self-referential. +resolve_permissive_tmux_kill_ref() { + local commit body + while IFS= read -r commit; do + [ -n "$commit" ] || continue + body=$(git -C "$ROOT" show "$commit:bin/backends/tmux.sh" 2>/dev/null) || continue + # shellcheck disable=SC2016 + case "$body" in + *'tmux kill-window -t "=$session:=$window"'*) continue ;; + esac + # shellcheck disable=SC2016 + case "$body" in + *'tmux kill-window -t "$1"'*|*'tmux kill-window -t "$target"'*) + printf '%s\n' "$commit" + return 0 + ;; + esac + done < <(git -C "$ROOT" log --first-parent --format='%H' HEAD -- bin/backends/tmux.sh) + return 1 +} + # --- shared: a pre-refactor bin/ shim -------------------------------------- # # build_old_bin echoes a directory whose bin/ subdir holds the PRE-REFACTOR @@ -929,10 +959,52 @@ run_teardown_case() { "$script" "$id" } +test_permissive_tmux_kill_ref_stays_historical() { + local ref body_hist body_head head + head=$(git -C "$ROOT" rev-parse HEAD) + ref=$(resolve_permissive_tmux_kill_ref) \ + || fail "unable to locate a historical bin/backends/tmux.sh with permissive kill-window selectors" + body_hist=$(git -C "$ROOT" show "$ref:bin/backends/tmux.sh") \ + || fail "could not read historical tmux adapter at $ref" + body_head=$(cat "$ROOT/bin/backends/tmux.sh") + + # shellcheck disable=SC2016 + case "$body_hist" in + *'tmux kill-window -t "=$session:=$window"'*) + fail "resolve_permissive_tmux_kill_ref returned exact selectors at $ref" + ;; + esac + # shellcheck disable=SC2016 + case "$body_hist" in + *'tmux kill-window -t "$1"'*|*'tmux kill-window -t "$target"'*) ;; + *) fail "historical tmux adapter at $ref lacks a permissive kill-window target" ;; + esac + # shellcheck disable=SC2016 + case "$body_head" in + *'tmux kill-window -t "=$session:=$window"'*) ;; + *) fail "current tmux adapter lost exact kill-window selectors" ;; + esac + [ "$ref" != "$head" ] \ + || fail "permissive tmux baseline collapsed to HEAD; fixture is no longer historical" + + pass "historical permissive tmux kill baseline stays distinct from current exact selectors" +} + test_teardown_conformance_old_vs_new() { - local old_bin fb proj wt id + local old_bin fb proj wt id old_tmux_ref saved_base_ref local state_old state_new config_old config_new data log_old log_new out_old out_new rc_old rc_new + # Force the post-squash topology inside this case: merge-base with main may + # equal HEAD on default-branch CI, and that must not make the legacy kill + # fixture self-referential. build_old_bin still uses BASE_REF for entrypoints; + # only the tmux kill adapter is pinned to the content-historical permissive ref. + saved_base_ref=$BASE_REF + BASE_REF=$(git -C "$ROOT" rev-parse HEAD) + old_tmux_ref=$(resolve_permissive_tmux_kill_ref) \ + || { BASE_REF=$saved_base_ref; fail "unable to locate a historical bin/backends/tmux.sh with permissive kill-window selectors"; } old_bin=$(build_old_bin teardown-old) + git -C "$ROOT" show "$old_tmux_ref:bin/backends/tmux.sh" > "$old_bin/bin/backends/tmux.sh" \ + || { BASE_REF=$saved_base_ref; fail "could not materialize historical tmux adapter from $old_tmux_ref"; } + BASE_REF=$saved_base_ref proj="$TMP_ROOT/teardown-project"; wt="$TMP_ROOT/teardown-wt" id="teardownconform1" fm_git_worktree "$proj" "$wt" "fm/$id" @@ -1106,6 +1178,7 @@ test_backend_of_selector_matches_explicit_target_meta test_send_conformance_old_vs_new test_peek_conformance_old_vs_new test_spawn_symlinked_project_prefix_avoids_false_refusal +test_permissive_tmux_kill_ref_stays_historical test_teardown_conformance_old_vs_new test_spawn_refuses_unknown_backend_flag test_spawn_refuses_codex_app_backend_flag From b6e351d341173dfee26f19dd74c173a1f4844873 Mon Sep 17 00:00:00 2001 From: Kun Chen <3233006+kunchenguid@users.noreply.github.com> Date: Tue, 28 Jul 2026 12:25:52 -0700 Subject: [PATCH 03/70] docs: slim quota-array-dispatch to the pace selection core (#1197) Cut the runtime skill to the compact pace-aware selection procedure plus minimum owner pointers. Keep every distinct decision rule and move expanded acceptance scenarios to deterministic fixture ownership assertions. Size: 170/1374/10187 -> 63/544/4068 (about 63%/60%/60% reduction). --- .agents/skills/quota-array-dispatch/SKILL.md | 173 ++++--------------- tests/fm-quota-array-dispatch.test.sh | 39 +++-- 2 files changed, 57 insertions(+), 155 deletions(-) diff --git a/.agents/skills/quota-array-dispatch/SKILL.md b/.agents/skills/quota-array-dispatch/SKILL.md index a5fe06d6ec..d9de90ffba 100644 --- a/.agents/skills/quota-array-dispatch/SKILL.md +++ b/.agents/skills/quota-array-dispatch/SKILL.md @@ -12,159 +12,52 @@ metadata: # quota-array-dispatch This skill is the single owner of the pace-aware profile-array selection procedure. -The concise always-loaded intake boundary remains in `AGENTS.md` section 4. -`docs/configuration.md` owns the `config/crew-dispatch.json` schema only. +`AGENTS.md` section 4 owns the always-loaded intake boundary, load trigger, malformed-config refusal, every-candidate accounting, and strongest-reasoning/tie safety rules. +`harness-adapters` owns harness verification, model/provider discovery, and effort fallback. `quota-axi` remains data-only and never recommends a route. -Firstmate owns the judgment. Do not add a daemon, opaque composite score, routing wrapper, hard-coded model-specific policy, or producer-side route recommendation. -## When to load +## Collect facts -Load this skill whenever a matched dispatch rule or the configured default resolves to a profile array (more than one candidate), before choosing the concrete `--harness`, `--model`, and `--effort` passed to `fm-spawn`. -Keep using `harness-adapters` for harness verification, model/provider discovery, and effort fallback. +Run `quota-axi --json` once per intake and reuse that snapshot for every candidate. +For each candidate, establish the harness/model/provider relationship from `harness-adapters`, then record only inspectable facts: -## Intake boundary this skill does not relax +- task/profile fit and required reasoning class +- raw applicable headroom (`effectivePercentRemaining` or the tightest applicable remaining percentage) +- effective pace status, signed reserve per applicable window, and worst applicable reserve (`worstReservePercentPoints` when present, else the minimum signed reserve) +- whether any applicable window or effective summary is ahead of reset, or any applicable pace is `unknown` +- schema note when pace fields are absent -1. Explicit per-task captain overrides still win over configured profiles. -2. Configured profile matching precedence is unchanged: best-fit rule, then configured default, then static crewmate harness. -3. Malformed `config/crew-dispatch.json` remains an actionable error; never select around it. -4. Every configured candidate in the matched array must be accounted for. -5. If any harness/model/provider relationship, applicable quota data, or interpretation cannot be established, stop and report that candidate instead of omitting it, guessing, falling back, or calling the result quota-informed. -6. When every candidate is tight, preserve the captain's strongest-reasoning class rather than silently downgrading it solely to conserve quota; stop and report the tight choice if that class cannot proceed. -7. Genuine ties must remain free of array-order or harness bias. +Stale raw windows are diagnostic only, never current headroom. +Read every bounding window named by `boundedBy`, `limitingWindowIds`, `aheadWindowIds`, `behindWindowIds`, `onPaceWindowIds`, and `unknownWindowIds`. -## Collect inspectable facts for every candidate +## Pace semantics -For each candidate profile: +`reservePercentPoints = percentRemaining - timeRemainingPercent`. +Negative reserve means usage is ahead of reset pace and creates conservation pressure. +Positive reserve means usage is behind reset pace. +`on_pace` is neutral. +Conservation pressure is present when effective pace status is `ahead`, effective pace status is `mixed` and any `aheadWindowIds` remain, or any applicable bounding window itself has pace status `ahead`. +`unknown` is valid explicit uncertainty from quota-axi, not a parser failure and not permission to assume the window is healthy or exhausted. -1. Establish the harness/model/provider relationship from current authoritative discovery owned by `harness-adapters`. - Fail loudly on an unresolved relationship. -2. Run `quota-axi --json` once per intake and reuse that snapshot for every candidate. -3. Require a current provider report with known quota semantics and a known applicable effective-availability record for that candidate's provider and model scope. - Stale raw windows remain diagnostic evidence only and are never current headroom. -4. Read every bounding window relevant to that candidate, including windows named by `boundedBy`, `limitingWindowIds`, `aheadWindowIds`, `behindWindowIds`, `onPaceWindowIds`, and `unknownWindowIds` on the effective record. -5. Record these inspectable facts, never a hidden score: - - task/profile fit - - reasoning class required by the captain request or task ambiguity - - raw applicable headroom (`effectivePercentRemaining` or the tightest applicable remaining percentage) - - effective pace status when present - - signed reserve for each applicable window and the effective worst reserve when present - - whether any applicable window or effective summary is ahead of reset - - whether any applicable pace is `unknown` - - schema compatibility note when pace fields are absent +## Selection order -## Pace signals +Apply only among candidates that already satisfy required fit and the strongest reasoning class the request needs. +Never use pace or raw headroom to silently replace that reasoning class. -quota-axi `schemaVersion` 3 window pace uses: - -- `reservePercentPoints = percentRemaining - timeRemainingPercent` -- Negative reserve means usage is ahead of reset pace and creates conservation pressure. -- Positive reserve means usage is behind reset pace. -- `on_pace` is neutral. - -Effective-availability pace summaries may report `ahead`, `behind`, `on_pace`, `mixed`, or `unknown`. - -Treat conservation pressure as present when: - -- effective pace status is `ahead`, or -- effective pace status is `mixed` and any `aheadWindowIds` remain, or -- any applicable bounding window itself has pace status `ahead`. - -An effective `mixed` result is never healthy merely because one window is behind. -Any remaining `aheadWindowIds` keep conservation pressure. - -Signed reserve comparison uses the worst applicable reserve, preferring the producer field `worstReservePercentPoints` when present and otherwise the minimum signed reserve across applicable bounding windows. - -## Selection procedure - -Apply these steps only among candidates that already satisfy required task/profile fit and the strongest reasoning class the request genuinely needs. -Never use pace or raw headroom to silently replace that reasoning class with a weaker one. - -1. **Unresolved relationship or quota data** - Stop and report the blocked candidate. -2. **Strongest-reasoning / all-tight** - If every remaining candidate is tight, keep the strongest-reasoning class and either dispatch inside that class or stop and report that the tight choice cannot proceed. - Do not conserve quota through an unapproved downgrade. -3. **Conservation pressure vs sustainable pace** - When fit and reasoning class are comparable, prefer a candidate without ahead-of-reset conservation pressure over one with conservation pressure, even when the pressured candidate has somewhat higher raw remaining percentage. -4. **Among pressured candidates** - Prefer the least-negative worst applicable reserve. - Example: worst reserve `-4` is safer than `-18` when other inspectable facts are comparable. -5. **Among sustainable candidates** - Use known behind/on-pace evidence plus raw headroom transparently. - Do not collapse those facts into an opaque composite score. +1. Unresolved relationship or quota data: stop and report the blocked candidate. +2. All-tight: keep the strongest-reasoning class; dispatch inside it or stop and report that the tight choice cannot proceed. +3. When fit and reasoning are comparable, prefer a candidate without ahead-of-reset conservation pressure over one with conservation pressure, even when the pressured candidate has somewhat higher raw remaining percentage. +4. Among pressured candidates, prefer the least-negative worst applicable reserve. +5. Among sustainable candidates, use known behind/on-pace evidence plus raw headroom transparently. Prefer known sustainable evidence over `unknown` pace when otherwise comparable. - Between known sustainable candidates, prefer the clearly better inspectable pair of pace reserve and raw headroom; state both facts in the choice rationale. -6. **Unknown pace** - `unknown` is valid explicit uncertainty from quota-axi, not a parser failure and not permission to assume the window is healthy or exhausted. - Inspect `unknownWindowIds` and each window's pace `reason` so the rationale preserves the producer's stated uncertainty. - Prefer known sustainable evidence when otherwise comparable. - If the dispatch choice materially hinges on unresolved pace, report the uncertainty rather than inventing a conclusion. -7. **Absent pace / older schema** - `schemaVersion` 2 payloads or missing pace fields must degrade explicitly and safely. - Do not crash, fabricate pace, or silently reinterpret absence as healthy/`on_pace`. - Compare raw applicable headroom only, using known effective availability rather than stale or isolated window percentages, state that pace is unavailable, and keep every other safety rule above. -8. **Genuine ties** - If every inspectable selection fact is equal, stop and report every tied candidate for captain choice. + Do not collapse those facts into an opaque composite score. +6. If the dispatch choice materially hinges on unresolved pace, report the uncertainty rather than inventing a conclusion. +7. Absent pace or older schema: do not crash, fabricate pace, or silently reinterpret absence as healthy/`on_pace`. + Compare raw applicable headroom only, state that pace is unavailable, and keep every other safety rule. +8. Genuine ties: stop and report every tied candidate for captain choice. Do not select by array order, harness name, or another arbitrary identity ordering. Report duplicate concrete profiles as a configuration error. -The intake rationale must name the inspectable facts used for every candidate. +Name the inspectable facts used for every candidate. Never conclude with an unexplained "best quota" label. - -## Acceptance scenarios - -These scenarios are normative examples of the procedure above. - -### Higher raw quota but materially ahead vs lower raw quota on/behind pace - -Candidate A has higher `effectivePercentRemaining` but conservation pressure from an ahead bounding window. -Candidate B has lower raw headroom, no conservation pressure, and known behind or on-pace evidence. -Choose B when fit and reasoning class are comparable. - -### Mixed effective pace with an ahead bound - -Effective pace status is `mixed` and `aheadWindowIds` is non-empty. -Treat the candidate as conservation-pressured even if another window is behind or on pace. - -### Both candidates ahead with different worst reserves - -Both candidates have conservation pressure. -Choose the least-negative worst applicable reserve when fit and reasoning class are comparable. - -### Known sustainable versus unknown - -Candidate A has known behind or on-pace evidence. -Candidate B has comparable fit, reasoning class, and raw headroom but `unknown` pace. -Prefer A. -If the only way to prefer one side depends on unresolved pace and no known sustainable candidate remains, report the uncertainty. - -### Every candidate tight while strongest-reasoning applies - -All candidates are tight on real headroom. -Keep the strongest reasoning class required by the request. -Do not pick a weaker class only to save quota. -Dispatch inside that class or stop and report that the tight strongest-class choice cannot proceed. - -### Genuine tie without array-order or harness bias - -Two candidates match on fit, reasoning class, conservation pressure, worst reserve, pace class, raw headroom, and unknown flags. -Choosing either array order or a standing harness preference is forbidden. -Stop and report both tied candidates for captain choice. - -### schemaVersion 2 or absent-pace compatibility - -Older quota-axi output or missing pace fields still allow array resolution. -Compare raw headroom only, state that pace is unavailable, and do not invent ahead/behind/on_pace. - -## Sanitized producer shape - -Validate consumers against a sanitized `schemaVersion` 3 shape derived from quota-axi 0.1.15: - -- top level: `schemaVersion`, `generatedAt`, `providers[]` -- each provider: `provider`, `state`, `windows[]`, and optional `quotaSemantics` with `status` and `effectiveAvailability[]` -- each window: `id`, `label`, `kind`, and optional `percentRemaining` and `pace`; pace has `status` plus optional `reason`, `timeRemainingPercent`, and `reservePercentPoints` -- each effective-availability entry: `scope`, `status`, `boundedBy`, optional `effectivePercentRemaining`, optional `limitingWindowIds`, and optional pace summary -- each effective pace summary: `status` plus optional `aheadWindowIds`, `behindWindowIds`, `onPaceWindowIds`, `unknownWindowIds`, `worstReservePercentPoints`, and `worstReserveWindowId` - -Never persist live provider balances, reset timestamps, account identifiers, or other private account details in tracked fixtures. diff --git a/tests/fm-quota-array-dispatch.test.sh b/tests/fm-quota-array-dispatch.test.sh index a958e56c30..0c0d848fa0 100755 --- a/tests/fm-quota-array-dispatch.test.sh +++ b/tests/fm-quota-array-dispatch.test.sh @@ -153,8 +153,8 @@ test_owner_and_always_loaded_boundary() { pass "quota-array-dispatch has one conditional owner and a concise always-loaded boundary" } -test_owner_contains_acceptance_procedure() { - local phrase +test_owner_contains_selection_procedure() { + local phrase lines words bytes for phrase in \ 'reservePercentPoints = percentRemaining - timeRemainingPercent' \ 'Negative reserve means usage is ahead of reset pace and creates conservation pressure' \ @@ -163,30 +163,39 @@ test_owner_contains_acceptance_procedure() { 'effective pace status is `mixed` and any `aheadWindowIds` remain' \ 'prefer a candidate without ahead-of-reset conservation pressure over one with conservation pressure' \ 'even when the pressured candidate has somewhat higher raw remaining percentage' \ - 'Prefer the least-negative worst applicable reserve' \ - 'Use known behind/on-pace evidence plus raw headroom transparently' \ + 'prefer the least-negative worst applicable reserve' \ + 'use known behind/on-pace evidence plus raw headroom transparently' \ 'Do not collapse those facts into an opaque composite score' \ '`unknown` is valid explicit uncertainty from quota-axi' \ 'Prefer known sustainable evidence over `unknown` pace when otherwise comparable' \ 'If the dispatch choice materially hinges on unresolved pace, report the uncertainty' \ - 'Do not crash, fabricate pace, or silently reinterpret absence as healthy' \ + 'do not crash, fabricate pace, or silently reinterpret absence as healthy' \ 'stop and report every tied candidate for captain choice' \ 'Do not select by array order, harness name, or another arbitrary identity ordering' \ - 'Do not add a daemon, opaque composite score, routing wrapper, hard-coded model-specific policy'; do + 'Do not add a daemon, opaque composite score, routing wrapper, hard-coded model-specific policy' \ + 'Report duplicate concrete profiles as a configuration error' \ + 'Name the inspectable facts used for every candidate'; do assert_grep "$phrase" "$OWNER" "quota-array-dispatch procedure lost '$phrase'" done + # Expanded acceptance scenarios live in deterministic fixtures, not runtime prose. for phrase in \ 'Higher raw quota but materially ahead vs lower raw quota on/behind pace' \ - 'Mixed effective pace with an ahead bound' \ - 'Both candidates ahead with different worst reserves' \ - 'Known sustainable versus unknown' \ - 'Every candidate tight while strongest-reasoning applies' \ - 'Genuine tie without array-order or harness bias' \ - 'schemaVersion 2 or absent-pace compatibility'; do - assert_grep "$phrase" "$OWNER" "acceptance scenario missing: $phrase" + 'Sanitized producer shape' \ + '## When to load' \ + '## Intake boundary this skill does not relax'; do + if grep -Fq -- "$phrase" "$OWNER"; then + fail "quota-array-dispatch should not keep removed runtime prose: $phrase" + fi done - pass "quota-array-dispatch owns the full pace procedure and acceptance scenarios" + + lines=$(wc -l < "$OWNER" | tr -d ' ') + words=$(wc -w < "$OWNER" | tr -d ' ') + bytes=$(wc -c < "$OWNER" | tr -d ' ') + [ "$lines" -le 65 ] || fail "quota-array-dispatch skill is too long: $lines lines (want <= 65)" + [ "$words" -le 550 ] || fail "quota-array-dispatch skill is too wordy: $words words (want <= 550)" + [ "$bytes" -le 4600 ] || fail "quota-array-dispatch skill is too large: $bytes bytes (want <= 4600)" + pass "quota-array-dispatch owns the compact pace procedure ($lines lines, $words words, $bytes bytes)" } test_cross_references_stay_pointers() { @@ -271,7 +280,7 @@ test_no_duplicate_procedure_in_agents() { } test_owner_and_always_loaded_boundary -test_owner_contains_acceptance_procedure +test_owner_contains_selection_procedure test_cross_references_stay_pointers test_schema_v3_shape_fixture test_deterministic_acceptance_cases From e5bd082a82202062f5e4957e249f88f95d5a49e9 Mon Sep 17 00:00:00 2001 From: Kun Chen <3233006+kunchenguid@users.noreply.github.com> Date: Tue, 28 Jul 2026 16:54:20 -0700 Subject: [PATCH 04/70] feat(bin): inherit backend config into secondmate homes (#1219) * Inherit config/backend into secondmate homes with deliberate-override preservation Add backend to the shared inheritable config allowlist so launch, locked bootstrap, and config-push converge a primary pin into secondmate homes as each home local future-spawn default. Track last-inherited bytes in a private state provenance marker so deliberate per-home overrides survive present and absent primary convergence, keep --backend and FM_BACKEND stronger, and extend the existing inheritance tests plus docs and skill claims. * no-mistakes(review): Preserve equal unprovenanced backend overrides * no-mistakes(review): Preserve symlink overrides and verify spawn precedence * no-mistakes(review): Snapshot backend inheritance for consistent provenance * no-mistakes(review): Simplify backend inheritance to primary-authoritative convergence * no-mistakes(document): Document inherited backend override preservation * fix: restore primary-authoritative backend inheritance after document regression The document step reintroduced provenance and deliberate per-home override semantics after review had simplified config/backend to plain primary-authoritative allowlist membership. Restore the primary-always-wins path: present overwrites, absent removes, no provenance marker, and docs/tests match that contract. * no-mistakes(review): Add divergent backend precedence regression fixtures * no-mistakes(document): Document backend inheritance contract --- .../skills/secondmate-provisioning/SKILL.md | 7 +- AGENTS.md | 2 +- bin/fm-config-inherit-lib.sh | 12 +- bin/fm-config-push.sh | 4 +- docs/configuration.md | 4 +- .../fm-backend-herdr-presentation-e2e.test.sh | 2 +- tests/fm-secondmate-harness.test.sh | 173 +++++++++++++++--- 7 files changed, 170 insertions(+), 34 deletions(-) diff --git a/.agents/skills/secondmate-provisioning/SKILL.md b/.agents/skills/secondmate-provisioning/SKILL.md index ecc364939a..f9e68937ab 100644 --- a/.agents/skills/secondmate-provisioning/SKILL.md +++ b/.agents/skills/secondmate-provisioning/SKILL.md @@ -78,10 +78,13 @@ This section is the single owner of the secondmate sync and inherited-local-mate Before launch, `fm-spawn.sh --secondmate` locally fast-forwards the home to the primary firstmate checkout's current default-branch commit when it is safe; dirty, diverged, or in-flight homes launch unchanged with a warning. The locked session-start bootstrap sweep runs the same guarded fast-forward for every live secondmate home, discovered from `state/.meta` records with `kind=secondmate` (`data/secondmates.md` only backfills `home=` for older records). That no-fetch path is a purely local fast-forward of tracked files, never an origin fetch, and it never touches the gitignored operational dirs, so a secondmate's backlog, projects, and in-flight work are never disturbed; a linked worktree advances immediately, while a standalone clone that lacks the target receives firstmate updates through `/updatefirstmate`'s origin refresh. -The same launch and the same locked bootstrap sweep also propagate the primary's declared inherited local material: `config/crew-dispatch.json`, `config/crew-harness`, `config/backlog-backend`, `config/herdr-presentation-spaces`, and the one shared captain-preference file `data/captain-shared.md`. +The same launch and the same locked bootstrap sweep also propagate the primary's declared inherited local material: `config/crew-dispatch.json`, `config/crew-harness`, `config/backlog-backend`, `config/backend`, `config/herdr-presentation-spaces`, and the one shared captain-preference file `data/captain-shared.md`. Because these paths are gitignored, that propagation is a separate, primary-authoritative copy independent of the tracked-files fast-forward: it re-converges every live home whether or not its tracked files advanced, and it touches only the declared items. Propagation failures warn without blocking secondmate launch or session-start continuation, and the destination keeps whatever safely validated state the helper left behind. Inheritance copies the literal `config/crew-harness` file, so a secondmate's own crewmates use the primary's crewmate harness only when it names a concrete adapter such as `codex`; an unset or `default` value has nothing concrete to inherit, and the secondmate's own crewmates fall back to the secondmate's own or detected harness instead. +Inherited `config/backend` becomes that secondmate home's local runtime-backend default for future spawns only; it never retargets, rewrites, migrates, stops, or restarts an already-live worker endpoint. +A present primary value always converges byte-exact into validated secondmate homes, and primary absence removes the destination so those homes keep runtime auto-detection. +Explicit per-spawn `--backend` and `FM_BACKEND` remain stronger than every home's local `config/backend`, including an inherited default. `config/secondmate-harness` is not inherited because it is only the primary's knob for launching secondmate agents. `data/captain-shared.md` is main-authoritative in the primary home and read-only in secondmate homes. Its primary file header must state that the file is main-authoritative, read-only in secondmate homes, must not be edited there, and that new captain-preference discoveries are routed to the main firstmate through marked status or a document pointer. @@ -96,7 +99,7 @@ Keep every `data/learnings.md` fully local by captain decision; route fleet-gene No AGENTS.md reread nudge is needed at spawn or respawn because the agent reads instructions fresh on launch; only the bootstrap sweep's running-home instruction-surface advance needs that AGENTS.md re-read. Bootstrap reports successful AGENTS.md re-read sends as `BOOTSTRAP_INFO:` and only emits `NUDGE_SECONDMATES:` when that send fails and needs retry. A separate, literal-content config reread is required whenever inherited `config/*` material changes under an already-running secondmate. -After each successful allowlisted config write, both the locked bootstrap convergence path and mid-session `bin/fm-config-push.sh` use the shared propagation report to build one per-home generation-specific private instruction file from the validated destination post-write bytes for only the allowlisted config items that actually changed for that home (`config/crew-dispatch.json`, `config/crew-harness`, `config/backlog-backend`, `config/herdr-presentation-spaces`), in deterministic allowlist order. +After each successful allowlisted config write, both the locked bootstrap convergence path and mid-session `bin/fm-config-push.sh` use the shared propagation report to build one per-home generation-specific private instruction file from the validated destination post-write bytes for only the allowlisted config items that actually changed for that home (`config/crew-dispatch.json`, `config/crew-harness`, `config/backlog-backend`, `config/backend`, `config/herdr-presentation-spaces`), in deterministic allowlist order. Each changed path is printed with clear begin/end delimiters and the destination file's full exact new bytes unparsed, or the explicit token `ABSENT` when propagation removed the destination copy. The instruction uses only minimal framing that these are defaults/rules and do not remove judgment; it never includes SHA values, selected profiles, parsed summaries, or any other generated interpretation. `data/captain-shared.md` is not a config file and is never inlined into this instruction file or message. diff --git a/AGENTS.md b/AGENTS.md index 4991118cb4..82bcbe8e55 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -67,7 +67,7 @@ config/crew-harness crewmate harness override; LOCAL, gitignored; absent or "de 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/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; inherited by secondmate homes under the primary-authoritative contract in secondmate-provisioning config/calm Pi Calm presentation preference; LOCAL, gitignored, and not inherited; see docs/configuration.md "Pi Calm preference" config/herdr-presentation-spaces optional presence flag for Herdr's default-off disposable single-task visual projection; LOCAL, gitignored; inherited by secondmate homes; see docs/herdr-backend.md "Optional presentation spaces" 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") diff --git a/bin/fm-config-inherit-lib.sh b/bin/fm-config-inherit-lib.sh index 95abba2439..22109aa87a 100644 --- a/bin/fm-config-inherit-lib.sh +++ b/bin/fm-config-inherit-lib.sh @@ -5,10 +5,12 @@ # (e.g. primary config/crew-dispatch.json makes a secondmate use the same dispatch # profile rules, primary config/crew-harness=codex makes a secondmate's crewmates # spawn on codex too, primary config/backlog-backend=manual makes that home -# hand-edit backlog files too, and primary config/herdr-presentation-spaces -# enables the same default-off Herdr presentation projection). It also pushes -# the one primary-authoritative shared captain-preference file, -# data/captain-shared.md, into each secondmate home's data/ as a read-only copy. +# hand-edit backlog files too, primary config/backend pins that home's local +# runtime-backend default for future spawns, and primary +# config/herdr-presentation-spaces enables the same default-off Herdr presentation +# projection). It also pushes the one primary-authoritative shared +# captain-preference file, data/captain-shared.md, into each secondmate home's +# data/ as a read-only copy. # # Usage: . bin/fm-config-inherit-lib.sh (no FM_* setup required) # @@ -40,7 +42,7 @@ FM_SHARED_CAPTAIN_MODE="444" # The declared inheritable set (space-separated, config-dir-relative item paths). # Extend here to inherit more of the primary's local config; override via the # environment only in tests. Items must not contain whitespace. -FM_INHERITABLE_CONFIG="${FM_INHERITABLE_CONFIG:-crew-dispatch.json crew-harness backlog-backend herdr-presentation-spaces}" +FM_INHERITABLE_CONFIG="${FM_INHERITABLE_CONFIG:-crew-dispatch.json crew-harness backlog-backend backend herdr-presentation-spaces}" fm_inherit_file_mode() { if [ "$(uname)" = Darwin ]; then diff --git a/bin/fm-config-push.sh b/bin/fm-config-push.sh index b4056744bc..b760666dd1 100755 --- a/bin/fm-config-push.sh +++ b/bin/fm-config-push.sh @@ -3,8 +3,8 @@ # Usage: fm-config-push.sh [--help] # # Mid-session convergence for inherited local material such as -# config/crew-dispatch.json edits or data/captain-shared.md updates. This -# discovers live secondmate homes from state/*.meta, backfills +# config/crew-dispatch.json, config/backend, or data/captain-shared.md updates. +# This discovers live secondmate homes from state/*.meta, backfills # home= from data/secondmates.md for older meta records, and reuses the same # propagation machinery as bootstrap, but deliberately does not # fast-forward tracked files. diff --git a/docs/configuration.md b/docs/configuration.md index d9a06bf4ca..1b8ea3bb59 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -93,7 +93,7 @@ Use the guarded cleanup path described in [`docs/zellij-backend.md`](zellij-back cmux has no session layer at all - one workspace per task, in whatever cmux window is open - and its socket password (when configured) is read from local, gitignored `config/cmux-socket-password` under the effective config directory, never committed. The caller-facing label remains `fm-`, but the actual cmux workspace title is scoped by the active `FM_HOME` readable label plus a short hash of the resolved `FM_ROOT` path as `fm--`. Test cleanup must use the guarded path in [`docs/cmux-backend.md`](cmux-backend.md#current-operation-and-safety), never enumerate-and-close every workspace. -The `config/backend` file is not inherited by secondmate homes. +`config/backend` is inherited into secondmate homes under the primary-authoritative contract owned by [`secondmate-provisioning`](../.agents/skills/secondmate-provisioning/SKILL.md). ## Away-mode supervisor backend (FM_SUPERVISOR_BACKEND / FM_SUPERVISOR_TARGET) @@ -283,7 +283,7 @@ When a running home advances and its loaded instruction surface (`AGENTS.md`, `b If that send fails, bootstrap keeps an idempotent retry marker and emits `NUDGE_SECONDMATES:` with the failure reason. The same bootstrap run emits `SECONDMATE_LIVENESS:` only when a registered secondmate is skipped or its relaunch fails; already-live and successfully relaunched secondmates are handled silently. For a mid-session inherited local-material edit where tracked-file sync is not needed, run `bin/fm-config-push.sh`. -It uses the same live secondmate discovery and propagation helper as bootstrap, prints each live home's `crew-dispatch.json`, `crew-harness`, `backlog-backend`, `herdr-presentation-spaces`, and `data/captain-shared.md` result as `pushed`, `unchanged`, `skipped`, or `error`, and exits non-zero for real propagation errors or config-reread send failures. +It uses the same live secondmate discovery and propagation helper as bootstrap, prints each live home's `crew-dispatch.json`, `crew-harness`, `backlog-backend`, `backend`, `herdr-presentation-spaces`, and `data/captain-shared.md` result as `pushed`, `unchanged`, `skipped`, or `error`, and exits non-zero for real propagation errors or config-reread send failures. When an allowlisted config item changes for an already-running home, it sends the literal-content reread pointer described in [`secondmate-provisioning`](../.agents/skills/secondmate-provisioning/SKILL.md); unchanged allowlisted config sends no pointer unless a previous delivery is pending. The locked bootstrap inheritance pass uses the same per-home changed-set and reread path for already-running homes; see `secondmate-provisioning` for the single contract owner. That live discovery starts from `state/*.meta` records with `kind=secondmate`; `data/secondmates.md` only backfills `home=` for older or incomplete meta records. diff --git a/tests/fm-backend-herdr-presentation-e2e.test.sh b/tests/fm-backend-herdr-presentation-e2e.test.sh index 194d2053ce..158e943211 100755 --- a/tests/fm-backend-herdr-presentation-e2e.test.sh +++ b/tests/fm-backend-herdr-presentation-e2e.test.sh @@ -866,7 +866,7 @@ touch "$SECOND_HOME_A/state/.last-watcher-beat" "$SECOND_HOME_B/state/.last-watc # may write config/herdr-presentation-spaces. git -C "$SECOND_HOME_A" init -q git -C "$SECOND_HOME_B" init -q -printf 'config/herdr-presentation-spaces\nconfig/crew-harness\nconfig/crew-dispatch.json\nconfig/backlog-backend\n' \ +printf 'config/herdr-presentation-spaces\nconfig/crew-harness\nconfig/crew-dispatch.json\nconfig/backlog-backend\nconfig/backend\n' \ > "$SECOND_HOME_A/.gitignore" cp "$SECOND_HOME_A/.gitignore" "$SECOND_HOME_B/.gitignore" git -C "$SECOND_HOME_A" add .gitignore diff --git a/tests/fm-secondmate-harness.test.sh b/tests/fm-secondmate-harness.test.sh index 39ca2021bf..b9efe54033 100755 --- a/tests/fm-secondmate-harness.test.sh +++ b/tests/fm-secondmate-harness.test.sh @@ -14,11 +14,12 @@ # explicit per-spawn harness arg still wins. # B) Inheritance. The primary pushes a declared, extensible set of LOCAL # (gitignored) config items - config/crew-dispatch.json, config/crew-harness, -# config/backlog-backend, and config/herdr-presentation-spaces - down into -# each secondmate home's config/, so the secondmate's OWN crewmates, -# dispatch profiles, backlog backend, and Herdr presentation opt-in inherit -# the primary's settings. It is primary-authoritative (re-pushed at -# secondmate spawn, on the bootstrap secondmate sweep, and by config push). +# config/backlog-backend, config/backend, and config/herdr-presentation-spaces - +# down into each secondmate home's config/, so the secondmate's OWN crewmates, +# dispatch profiles, backlog backend, runtime-backend default, and Herdr +# presentation opt-in inherit the primary's settings. It is primary-authoritative +# (re-pushed at secondmate spawn, on the bootstrap secondmate sweep, and by +# config push). # config/secondmate-harness is deliberately NOT inherited (secondmates do # not spawn secondmates). After a successful push that changes allowlisted # config under an already-running home, a literal-content reread instruction @@ -191,16 +192,18 @@ SH # B) propagate_inheritable_config unit behavior # =========================================================================== test_propagate_lib() { - local d src dest m1 m2 outside stdout stderr guard_repo err_text + local d src dest home m1 m2 outside stdout stderr guard_repo err_text d="$TMP_ROOT/prop-lib" src="$d/src" - dest="$d/dest" - mkdir -p "$src" "$dest" + home="$d/home1" + dest="$home/config" + mkdir -p "$src" "$dest" "$home/state" # 1. present source is copied printf '{"default":{"harness":"codex"}}\n' > "$src/crew-dispatch.json" printf 'codex\n' > "$src/crew-harness" printf 'manual\n' > "$src/backlog-backend" + printf 'tmux\n' > "$src/backend" : > "$src/herdr-presentation-spaces" stdout="$d/clean-copy.out" stderr="$d/clean-copy.err" @@ -210,7 +213,11 @@ test_propagate_lib() { [ "$(cat "$dest/crew-dispatch.json")" = '{"default":{"harness":"codex"}}' ] || fail "crew-dispatch.json not propagated" [ "$(cat "$dest/crew-harness")" = codex ] || fail "crew-harness not propagated" [ "$(cat "$dest/backlog-backend")" = manual ] || fail "backlog-backend not propagated" + [ "$(cat "$dest/backend")" = tmux ] || fail "backend not propagated" [ -f "$dest/herdr-presentation-spaces" ] || fail "herdr-presentation-spaces not propagated" + printf 'herdr\n' > "$dest/backend" + propagate_inheritable_config "$src" "$dest" + [ "$(cat "$dest/backend")" = tmux ] || fail "primary backend did not overwrite a divergent destination" # 2. idempotent: an unchanged re-run does not churn the mtime m1=$(date -r "$dest/crew-harness" +%s 2>/dev/null || stat -c %Y "$dest/crew-harness") @@ -227,10 +234,12 @@ test_propagate_lib() { printf '{"default":{"harness":"claude"}}\n' > "$src/crew-dispatch.json" printf 'claude\n' > "$src/crew-harness" printf 'tasks-axi\n' > "$src/backlog-backend" + printf 'zellij\n' > "$src/backend" propagate_inheritable_config "$src" "$dest" [ "$(cat "$dest/crew-dispatch.json")" = '{"default":{"harness":"claude"}}' ] || fail "changed dispatch profile did not converge" [ "$(cat "$dest/crew-harness")" = claude ] || fail "changed value did not converge" [ "$(cat "$dest/backlog-backend")" = tasks-axi ] || fail "changed backlog backend did not converge" + [ "$(cat "$dest/backend")" = zellij ] || fail "changed backend did not converge" outside="$d/outside-target" rm -f "$dest/crew-harness" "$outside" @@ -243,11 +252,14 @@ test_propagate_lib() { [ "$(cat "$outside")" = outside ] || fail "destination symlink target was overwritten" # 4. removing the source mirrors absence downstream (primary-authoritative) - rm -f "$src/crew-dispatch.json" "$src/crew-harness" "$src/backlog-backend" "$src/herdr-presentation-spaces" + printf 'herdr\n' > "$dest/backend" + rm -f "$src/crew-dispatch.json" "$src/crew-harness" "$src/backlog-backend" \ + "$src/backend" "$src/herdr-presentation-spaces" propagate_inheritable_config "$src" "$dest" [ -e "$dest/crew-dispatch.json" ] && fail "dispatch profile absence not mirrored downstream" [ -e "$dest/crew-harness" ] && fail "absence not mirrored downstream" [ -e "$dest/backlog-backend" ] && fail "backlog-backend absence not mirrored downstream" + [ -e "$dest/backend" ] && fail "backend absence not mirrored downstream" [ -e "$dest/herdr-presentation-spaces" ] && fail "herdr-presentation-spaces absence not mirrored downstream" rm -f "$dest/crew-harness" @@ -265,22 +277,25 @@ test_propagate_lib() { [ -d "$dest/crew-harness" ] || fail "failed absence mirror removed the wrong path" rm -rf "$dest/crew-harness" - # 5. secondmate-harness is never inherited + # 5. secondmate-harness is never inherited; backend still is printf 'grok\n' > "$src/secondmate-harness" printf '{"default":{"harness":"codex"}}\n' > "$src/crew-dispatch.json" printf 'codex\n' > "$src/crew-harness" printf 'manual\n' > "$src/backlog-backend" - rm -rf "$d/dest2" - mkdir -p "$d/dest2" - propagate_inheritable_config "$src" "$d/dest2" - [ -e "$d/dest2/secondmate-harness" ] && fail "secondmate-harness was inherited (must not be)" - [ "$(cat "$d/dest2/crew-dispatch.json")" = '{"default":{"harness":"codex"}}' ] || fail "crew-dispatch.json not propagated alongside" - [ "$(cat "$d/dest2/crew-harness")" = codex ] || fail "crew-harness not propagated alongside" - [ "$(cat "$d/dest2/backlog-backend")" = manual ] || fail "backlog-backend not propagated alongside" + printf 'herdr\n' > "$src/backend" + rm -rf "$d/home2" + mkdir -p "$d/home2/config" "$d/home2/state" + propagate_inheritable_config "$src" "$d/home2/config" + [ -e "$d/home2/config/secondmate-harness" ] && fail "secondmate-harness was inherited (must not be)" + [ "$(cat "$d/home2/config/crew-dispatch.json")" = '{"default":{"harness":"codex"}}' ] || fail "crew-dispatch.json not propagated alongside" + [ "$(cat "$d/home2/config/crew-harness")" = codex ] || fail "crew-harness not propagated alongside" + [ "$(cat "$d/home2/config/backlog-backend")" = manual ] || fail "backlog-backend not propagated alongside" + [ "$(cat "$d/home2/config/backend")" = herdr ] || fail "backend not propagated alongside" # 6. nothing to propagate -> destination dir is never created (a true no-op) rm -rf "$d/src3" "$d/dest3" mkdir -p "$d/src3" + # Keep backend out of the empty-source case by clearing it from src3 only. propagate_inheritable_config "$d/src3" "$d/dest3/config" [ -e "$d/dest3/config" ] && fail "empty-source propagation created a destination dir" @@ -371,6 +386,7 @@ test_spawn_split_and_inherit() { printf 'claude\n' > "$w/home/config/crew-harness" printf 'codex\n' > "$w/home/config/secondmate-harness" printf 'manual\n' > "$w/home/config/backlog-backend" + printf 'zellij\n' > "$w/home/config/backend" make_seeded_home "$sm" sm spawn_secondmate "$w" sm "$sm" @@ -385,6 +401,8 @@ test_spawn_split_and_inherit() { || fail "split: home crew-dispatch.json not inherited" [ "$(cat "$sm/config/backlog-backend" 2>/dev/null)" = manual ] \ || fail "split: home backlog-backend not inherited as manual" + [ "$(cat "$sm/config/backend" 2>/dev/null)" = zellij ] \ + || fail "split: home backend not inherited as zellij" [ -e "$sm/config/secondmate-harness" ] \ && fail "split: secondmate-harness leaked into the secondmate home" pass "B2 spawn: secondmate runs the secondmate harness; its home inherits declared config" @@ -537,6 +555,50 @@ spawn_secondmate_capture() { "$ROOT/bin/fm-spawn.sh" "$id" "$home" "$@" --secondmate } +test_spawn_backend_precedence_over_inherited_config() { + local w sm meta launchlog out status + w="$TMP_ROOT/spawn-backend-env-precedence" + sm="$w/sm" + launchlog="$w/launch.log" + mkdir -p "$w/home/config" + printf 'herdr\n' > "$w/home/config/backend" + make_seeded_home "$sm" sm + + out=$(FM_BACKEND=tmux spawn_secondmate_capture \ + "$w" sm "$sm" "$launchlog" 2>&1); status=$? + expect_code 0 "$status" \ + "FM_BACKEND=tmux should beat inherited config/backend=herdr"$'\n'"$out" + + meta="$w/home/state/sm.meta" + [ "$(cat "$sm/config/backend")" = herdr ] \ + || fail "backend precedence fixture did not inherit config/backend=herdr" + assert_no_grep '^backend=' "$meta" \ + "FM_BACKEND=tmux did not beat inherited config/backend=herdr" + pass "B5b spawn: FM_BACKEND wins over inherited config/backend" +} + +test_spawn_explicit_backend_precedence_over_env_and_inherited_config() { + local w sm meta launchlog out status + w="$TMP_ROOT/spawn-backend-flag-precedence" + sm="$w/sm" + launchlog="$w/launch.log" + mkdir -p "$w/home/config" + printf 'herdr\n' > "$w/home/config/backend" + make_seeded_home "$sm" sm + + out=$(FM_BACKEND=zellij spawn_secondmate_capture \ + "$w" sm "$sm" "$launchlog" --backend tmux 2>&1); status=$? + expect_code 0 "$status" \ + "explicit --backend tmux should beat FM_BACKEND=zellij and inherited config/backend=herdr"$'\n'"$out" + + meta="$w/home/state/sm.meta" + [ "$(cat "$sm/config/backend")" = herdr ] \ + || fail "explicit backend precedence fixture did not inherit config/backend=herdr" + assert_no_grep '^backend=' "$meta" \ + "explicit --backend tmux did not beat FM_BACKEND=zellij and inherited config/backend=herdr" + pass "B5c spawn: explicit --backend wins over FM_BACKEND and inherited config/backend" +} + # A bare "" secondmate-harness file (today's format) must launch with # NO --model/--effort flag at all, and meta must keep recording model=default, # effort=default - the core backward-compat requirement of the new format. @@ -770,6 +832,7 @@ new_world() { printf 'projects/\nstate/\ndata/\n.no-mistakes/\n' [ "$dispatch_ignore" = no ] || printf 'config/crew-dispatch.json\n' printf 'config/crew-harness\nconfig/secondmate-harness\nconfig/backlog-backend\n' + printf 'config/backend\nconfig/herdr-presentation-spaces\n' } > "$w/main/.gitignore" printf 'v1\n' > "$w/main/AGENTS.md" printf 'r1\n' > "$w/main/README.md" @@ -965,6 +1028,7 @@ test_bootstrap_sweep_propagates_and_reconverges() { printf '{"default":{"harness":"codex"}}\n' > "$w/home/config/crew-dispatch.json" printf 'codex\n' > "$w/home/config/crew-harness" printf 'manual\n' > "$w/home/config/backlog-backend" + printf 'tmux\n' > "$w/home/config/backend" printf 'grok\n' > "$w/home/config/secondmate-harness" run_bootstrap "$w" >/dev/null [ "$(cat "$w/sm/config/crew-harness" 2>/dev/null)" = codex ] \ @@ -973,6 +1037,8 @@ test_bootstrap_sweep_propagates_and_reconverges() { || fail "sweep: crew-dispatch.json not pushed into the live home" [ "$(cat "$w/sm/config/backlog-backend" 2>/dev/null)" = manual ] \ || fail "sweep: backlog-backend not pushed into the live home" + [ "$(cat "$w/sm/config/backend" 2>/dev/null)" = tmux ] \ + || fail "sweep: backend not pushed into the live home" [ -e "$w/sm/config/secondmate-harness" ] \ && fail "sweep: secondmate-harness was inherited (must not be)" @@ -980,6 +1046,7 @@ test_bootstrap_sweep_propagates_and_reconverges() { printf '{"default":{"harness":"claude"}}\n' > "$w/home/config/crew-dispatch.json" printf 'claude\n' > "$w/home/config/crew-harness" printf 'tasks-axi\n' > "$w/home/config/backlog-backend" + printf 'zellij\n' > "$w/home/config/backend" run_bootstrap "$w" >/dev/null [ "$(cat "$w/sm/config/crew-harness" 2>/dev/null)" = claude ] \ || fail "sweep: home did not re-converge to the primary's new crew-harness" @@ -987,9 +1054,12 @@ test_bootstrap_sweep_propagates_and_reconverges() { || fail "sweep: home did not re-converge to the primary's new crew-dispatch.json" [ "$(cat "$w/sm/config/backlog-backend" 2>/dev/null)" = tasks-axi ] \ || fail "sweep: home did not re-converge to the primary's new backlog-backend" + [ "$(cat "$w/sm/config/backend" 2>/dev/null)" = zellij ] \ + || fail "sweep: home did not re-converge to the primary's new backend" # Mirror absence: primary clears inherited config; the home's copies are removed. - rm -f "$w/home/config/crew-dispatch.json" "$w/home/config/crew-harness" "$w/home/config/backlog-backend" + rm -f "$w/home/config/crew-dispatch.json" "$w/home/config/crew-harness" \ + "$w/home/config/backlog-backend" "$w/home/config/backend" run_bootstrap "$w" >/dev/null [ -e "$w/sm/config/crew-dispatch.json" ] \ && fail "sweep: home crew-dispatch.json not removed after the primary cleared it" @@ -997,6 +1067,8 @@ test_bootstrap_sweep_propagates_and_reconverges() { && fail "sweep: home crew-harness not removed after the primary cleared it" [ -e "$w/sm/config/backlog-backend" ] \ && fail "sweep: home backlog-backend not removed after the primary cleared it" + [ -e "$w/sm/config/backend" ] \ + && fail "sweep: home backend not removed after the primary cleared it" pass "B7 bootstrap sweep pushes, re-converges, and mirrors absence; never inherits secondmate-harness" } @@ -1011,6 +1083,7 @@ test_bootstrap_sweep_propagates_when_tracked_current() { printf '{"default":{"harness":"codex"}}\n' > "$w/home/config/crew-dispatch.json" printf 'codex\n' > "$w/home/config/crew-harness" printf 'manual\n' > "$w/home/config/backlog-backend" + printf 'tmux\n' > "$w/home/config/backend" run_bootstrap "$w" >/dev/null [ "$(cat "$w/sm/config/crew-dispatch.json" 2>/dev/null)" = '{"default":{"harness":"codex"}}' ] \ || fail "crew-dispatch.json did not propagate to a tracked-current home" @@ -1018,6 +1091,8 @@ test_bootstrap_sweep_propagates_when_tracked_current() { || fail "config did not propagate to a tracked-current home" [ "$(cat "$w/sm/config/backlog-backend" 2>/dev/null)" = manual ] \ || fail "backlog-backend did not propagate to a tracked-current home" + [ "$(cat "$w/sm/config/backend" 2>/dev/null)" = tmux ] \ + || fail "backend did not propagate to a tracked-current home" pass "B8 bootstrap sweep propagates config even when the home's tracked files are already current" } @@ -1069,12 +1144,49 @@ test_bootstrap_sweep_no_inheritance_is_noop() { [ -e "$w/sm/config/crew-dispatch.json" ] && fail "no-inheritance sweep created a home crew-dispatch.json" [ -e "$w/sm/config/crew-harness" ] && fail "no-inheritance sweep created a home crew-harness" + [ -e "$w/sm/config/backend" ] && fail "no-inheritance sweep created a home backend" [ -e "$w/sm/config" ] && fail "no-inheritance sweep created a home config/ dir" [ "$(git -C "$w/sm" rev-parse HEAD)" = "$head" ] \ || fail "no-inheritance sweep did not still fast-forward the tracked files" pass "B10 bootstrap sweep with no inherited config is a config no-op and still fast-forwards" } +# config/backend: present and absent primary state converges exactly. +test_backend_inheritance_present_and_absent() { + local w head out err status instruction + w=$(new_world backend-inherit) + head=$(git -C "$w/main" rev-parse HEAD) + add_sm_worktree "$w" sm "$head" + + printf 'tmux\n' > "$w/home/config/backend" + err="$w/backend-inherit.err" + out=$(run_config_push "$w" 2>"$err"); status=$? + expect_code 0 "$status" "backend present push should succeed" + assert_contains "$out" "backend: pushed" "backend present value should report pushed" + [ "$(cat "$w/sm/config/backend")" = tmux ] || fail "backend present value not pushed" + instruction=$(reread_instruction_path "$w/sm") || fail "backend present reread instruction missing" + assert_contains "$(cat "$instruction")" $'-----BEGIN config/backend-----\ntmux\n-----END config/backend-----' \ + "backend present reread must include exact bytes" + + printf 'herdr\n' > "$w/sm/config/backend" + printf 'zellij\n' > "$w/home/config/backend" + out=$(run_config_push "$w" 2>"$err"); status=$? + expect_code 0 "$status" "backend changed push should succeed" + assert_contains "$out" "backend: pushed" "backend changed value should report pushed" + [ "$(cat "$w/sm/config/backend")" = zellij ] \ + || fail "primary backend did not overwrite the divergent destination" + + rm -f "$w/home/config/backend" + out=$(run_config_push "$w" 2>"$err"); status=$? + expect_code 0 "$status" "backend absence push should succeed" + assert_contains "$out" "backend: pushed - mirrored primary absence" "backend should mirror primary absence" + [ -e "$w/sm/config/backend" ] && fail "backend not removed on primary absence" + instruction=$(reread_instruction_path "$w/sm") || fail "backend absence reread instruction missing" + assert_contains "$(cat "$instruction")" $'-----BEGIN config/backend-----\nABSENT\n-----END config/backend-----' \ + "backend absence reread must use ABSENT token" + pass "B12b backend inheritance: present values and primary absence converge exactly" +} + test_bootstrap_sweep_surfaces_config_propagation_failure() { local w c1 out fail_line w=$(new_world boot-prop-fail) @@ -1113,7 +1225,7 @@ test_bootstrap_rereads_after_partial_propagation() { } test_config_push_propagates_reports_without_ff_or_nudge() { - local w c1 sm_real old_head out err status out2 tmp log + local w c1 sm_real old_head out err status out2 tmp log instruction w=$(new_world config-push-basic) c1=$(git -C "$w/main" rev-parse HEAD) add_sm_worktree "$w" sm "$c1" @@ -1131,6 +1243,7 @@ test_config_push_propagates_reports_without_ff_or_nudge() { printf '{"default":{"harness":"codex"}}\n' > "$w/home/config/crew-dispatch.json" printf 'codex\n' > "$w/home/config/crew-harness" printf 'manual\n' > "$w/home/config/backlog-backend" + printf 'tmux\n' > "$w/home/config/backend" err="$w/config-push-basic.err" log="$w/config-push-basic.tmux.log" out=$(run_config_push "$w" "$log" 2>"$err"); status=$? @@ -1146,12 +1259,18 @@ test_config_push_propagates_reports_without_ff_or_nudge() { "config push did not report crew-harness as pushed" assert_contains "$out" "backlog-backend: pushed" \ "config push did not report backlog-backend as pushed" + assert_contains "$out" "backend: pushed" \ + "config push did not report backend as pushed" assert_contains "$out" "config-reread: sent" \ "config push with changed config must send a literal reread instruction" assert_not_contains "$out" "NUDGE_SECONDMATES" \ "config push must not use the AGENTS.md instruction-surface nudge channel" [ "$(git -C "$w/sm" rev-parse HEAD)" = "$old_head" ] \ || fail "config push fast-forwarded tracked files" + [ "$(cat "$w/sm/config/backend")" = tmux ] || fail "config push did not write backend" + instruction=$(reread_instruction_path "$w/sm") || fail "config-push reread instruction missing" + assert_contains "$(cat "$instruction")" $'-----BEGIN config/backend-----\ntmux\n-----END config/backend-----' \ + "config-push reread must include exact backend bytes" [ ! -s "$err" ] || fail "clean config push wrote unexpected stderr: $(cat "$err")" assert_contains "$(cat "$log")" "[fm-from-firstmate]" \ "config reread must use the marked routed secondmate path" @@ -1165,6 +1284,8 @@ test_config_push_propagates_reports_without_ff_or_nudge() { "idempotent config push did not report crew-harness as unchanged" assert_contains "$out2" "backlog-backend: unchanged" \ "idempotent config push did not report backlog-backend as unchanged" + assert_contains "$out2" "backend: unchanged" \ + "idempotent config push did not report backend as unchanged" assert_not_contains "$out2" "config-reread: sent" \ "unchanged config must not send a reread message" [ ! -s "$log" ] || fail "unchanged config push still invoked tmux send: $(cat "$log")" @@ -1301,6 +1422,7 @@ test_config_reread_per_home_changed_sets_and_exact_bytes() { printf '%s' "$multiline_json" > "$w/home/config/crew-dispatch.json" printf 'codex\n' > "$w/home/config/crew-harness" printf 'manual\n' > "$w/home/config/backlog-backend" + printf 'tmux\n' > "$w/home/config/backend" { shared_captain_header_for_tests printf '%s\n' "shared secret preference body that must never appear in a config reread" @@ -1319,6 +1441,7 @@ test_config_reread_per_home_changed_sets_and_exact_bytes() { || fail "beta did not receive multiline dispatch" [ "$(cat "$w/alpha/config/crew-harness")" = codex ] || fail "alpha harness not updated" [ "$(cat "$w/alpha/config/backlog-backend")" = manual ] || fail "alpha backlog-backend not updated" + [ "$(cat "$w/alpha/config/backend")" = tmux ] || fail "alpha backend not updated" instr_a=$(reread_instruction_path "$w/alpha") || fail "alpha instruction missing after config push" instr_b=$(reread_instruction_path "$w/beta") || fail "beta instruction missing after config push" @@ -1328,19 +1451,21 @@ test_config_reread_per_home_changed_sets_and_exact_bytes() { [ "$(reread_mode "$instr_b")" = 600 ] || fail "beta instruction is not private" # Deterministic allowlist path order and exact destination bytes for alpha - # (all three config items were missing/stale and therefore pushed). + # (allowlisted config items were missing/stale and therefore pushed). assert_grep "These inherited config files changed" "$instr_a" "alpha framing missing" assert_grep "defaults/rules" "$instr_a" "alpha must preserve agent judgment framing" assert_contains "$(cat "$instr_a")" "config/crew-dispatch.json" "alpha missing dispatch path" assert_contains "$(cat "$instr_a")" "config/crew-harness" "alpha missing harness path" assert_contains "$(cat "$instr_a")" "config/backlog-backend" "alpha missing backlog path" + assert_contains "$(cat "$instr_a")" "config/backend" "alpha missing backend path" # Path order follows FM_INHERITABLE_CONFIG. awk ' /config\/crew-dispatch\.json/ { d=NR } /config\/crew-harness/ { h=NR } /config\/backlog-backend/ { b=NR } + /config\/backend/ && !/backlog-backend/ { k=NR } END { - if (!(d && h && b && d < h && h < b)) exit 1 + if (!(d && h && b && k && d < h && h < b && b < k)) exit 1 } ' "$instr_a" || fail "alpha instruction path order is not deterministic allowlist order" @@ -1351,6 +1476,8 @@ test_config_reread_per_home_changed_sets_and_exact_bytes() { "alpha instruction must include exact harness scalar bytes" assert_contains "$(cat "$instr_a")" $'-----BEGIN config/backlog-backend-----\nmanual\n-----END config/backlog-backend-----' \ "alpha instruction must include exact backlog-backend scalar bytes" + assert_contains "$(cat "$instr_a")" $'-----BEGIN config/backend-----\ntmux\n-----END config/backend-----' \ + "alpha instruction must include exact backend scalar bytes" # No parsed/effective summary, no SHA, no captain-shared dump. assert_not_contains "$(cat "$instr_a")" "Default worker" "must not emit parsed worker summary" @@ -1434,6 +1561,7 @@ test_config_reread_isolation_and_absent_and_send_failure() { printf '%s\n' $'crew-dispatch.json\tpushed\tmirrored primary absence' printf '%s\n' $'crew-harness\tunchanged\t' printf '%s\n' $'backlog-backend\tunchanged\t' + printf '%s\n' $'backend\tunchanged\t' printf '%s\n' $'data/captain-shared.md\tpushed\t' } > "$report" rm -f "$w/beta/config/crew-dispatch.json" @@ -2124,6 +2252,8 @@ test_spawn_backward_compat_crew_fallback test_spawn_bare_backward_compat test_spawn_explicit_harness_wins test_spawn_unverified_secondmate_harness_refused +test_spawn_backend_precedence_over_inherited_config +test_spawn_explicit_backend_precedence_over_env_and_inherited_config test_spawn_bare_harness_no_model_effort_flag test_spawn_secondmate_harness_model_token test_spawn_secondmate_harness_model_and_effort_tokens @@ -2136,6 +2266,7 @@ test_bootstrap_sweep_propagates_and_reconverges test_bootstrap_sweep_propagates_when_tracked_current test_bootstrap_sweep_defers_dispatch_on_stale_unignored_home test_bootstrap_sweep_no_inheritance_is_noop +test_backend_inheritance_present_and_absent test_bootstrap_sweep_surfaces_config_propagation_failure test_bootstrap_rereads_after_partial_propagation test_config_push_propagates_reports_without_ff_or_nudge From c0c088153064492f9fd971512550abb7e869df44 Mon Sep 17 00:00:00 2001 From: Kun Chen <3233006+kunchenguid@users.noreply.github.com> Date: Tue, 28 Jul 2026 18:31:23 -0700 Subject: [PATCH 05/70] fix(pi): remove Calm's upper version ceiling (#1226) * fix(pi): remove Calm's exclusive Pi upper-version ceiling tests/fm-calm-pi-extension.test.sh gated on a closed PI_COMPAT_VERSIONS allowlist ("0.81.1 0.82.0") that refused any other installed Pi, and docs described that range as "supported" rather than verified evidence. The Calm CHANGELOG shows no API introduced at either version, so there is no evidence for a real minimum; the presentation adapters already probe the exact method they patch rather than checking a version. Replace the allowlist with dated version evidence that never rejects a newer Pi, and make each presentation adapter degrade independently with a diagnostic if a future Pi removes its API, instead of the whole Calm extension failing to load. Rewrite the feasibility doc's "Pi 0.81.1 through 0.82.0" phrasing to state it as verified evidence, not a ceiling. * no-mistakes(review): Probe missing Calm adapter exports safely * no-mistakes(document): Document Calm's unbounded Pi compatibility --- .pi/extensions/fm-calm.ts | 25 ++- .../lib/fm-calm-assistant-layout.ts | 13 +- .../lib/fm-calm-operational-user-layout.ts | 25 ++- docs/calm-mode-feasibility.md | 28 ++- docs/calm.md | 6 + tests/fm-calm-pi-extension.test.sh | 202 ++++++++++++++++-- 6 files changed, 260 insertions(+), 39 deletions(-) diff --git a/.pi/extensions/fm-calm.ts b/.pi/extensions/fm-calm.ts index eb009fd8e3..f78c1b5acd 100644 --- a/.pi/extensions/fm-calm.ts +++ b/.pi/extensions/fm-calm.ts @@ -1,11 +1,13 @@ // Firstmate's home-persistent Pi transcript presentation toggle. // -// Compatibility boundary: Pi 0.81.1 and 0.82.0 expose built-in ToolDefinitions, per-slot +// Verified against Pi 0.81.1 and 0.82.0, which expose built-in ToolDefinitions, per-slot // renderers, renderShell: "self", session_start replacement reasons, // ExtensionUIContext.setToolsExpanded(), setWorkingVisible(), and -// setHiddenThinkingLabel(). The focused tests pin those assumptions. Version-bounded -// presentation adapters cover collapsed assistant thinking and operational user rows; -// Pi still exposes no global renderer for arbitrary built-in or custom rows. +// setHiddenThinkingLabel(). The focused tests pin those assumptions but never reject a +// newer Pi solely for its version. The collapsed-thinking and operational-user +// presentation adapters probe the exact API they patch and degrade independently with a +// diagnostic (see installCalmPresentationAdapter below) if a future Pi removes it; Pi +// still exposes no global renderer for arbitrary built-in or custom rows. // docs/configuration.md owns the home-local Calm preference contract. import { randomUUID } from "node:crypto"; import { @@ -74,9 +76,20 @@ const extensionFile = fileURLToPath(import.meta.url); const extensionDir = dirname(extensionFile); const root = resolve(extensionDir, "../.."); +// Each presentation adapter probes the exact Pi API it patches. If a future Pi removes +// that API, only the affected adapter degrades; the rest of Calm keeps working. +function installCalmPresentationAdapter(name: string, install: () => void): void { + try { + install(); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + console.error(`Firstmate Calm: ${name} presentation adapter unavailable, skipping. ${reason}`); + } +} + export default function (pi: ExtensionAPI) { - installCalmAssistantLayout(); - installCalmOperationalUserLayout(); + installCalmPresentationAdapter("collapsed-thinking", installCalmAssistantLayout); + installCalmPresentationAdapter("operational-user-row", installCalmOperationalUserLayout); let exportRendering = false; let removeTerminalInputHandler: (() => void) | undefined; diff --git a/.pi/extensions/lib/fm-calm-assistant-layout.ts b/.pi/extensions/lib/fm-calm-assistant-layout.ts index dbb5b2dd5f..33be71095e 100644 --- a/.pi/extensions/lib/fm-calm-assistant-layout.ts +++ b/.pi/extensions/lib/fm-calm-assistant-layout.ts @@ -1,7 +1,12 @@ -import { AssistantMessageComponent } from "@earendil-works/pi-coding-agent"; +// Verified against Pi 0.81.1 and 0.82.0, which export AssistantMessageComponent with an +// updateContent method. installCalmAssistantLayout() probes that exact method and throws +// if it is missing; fm-calm.ts catches that and skips only this adapter with a diagnostic +// instead of blocking Calm or Pi. +import type { AssistantMessageComponent as PiAssistantMessageComponent } from "@earendil-works/pi-coding-agent"; +import * as PiCodingAgent from "@earendil-works/pi-coding-agent"; import { calmPresentationHides } from "./fm-calm-visibility.ts"; -type AssistantMessage = Parameters[0]; +type AssistantMessage = Parameters[0]; type AssistantMessagePresentationState = { hiddenThinkingLabel: string; @@ -31,6 +36,10 @@ export function installCalmAssistantLayout(): void { } const patch: CalmAssistantLayoutPatch = { hidesThinking }; + const AssistantMessageComponent = PiCodingAgent.AssistantMessageComponent; + if (typeof AssistantMessageComponent !== "function") { + throw new Error("Firstmate Calm requires Pi AssistantMessageComponent"); + } const originalUpdateContent = AssistantMessageComponent.prototype.updateContent; if (typeof originalUpdateContent !== "function") { throw new Error("Firstmate Calm requires Pi AssistantMessageComponent.updateContent"); diff --git a/.pi/extensions/lib/fm-calm-operational-user-layout.ts b/.pi/extensions/lib/fm-calm-operational-user-layout.ts index 82c69eda01..ca9b0bbcc0 100644 --- a/.pi/extensions/lib/fm-calm-operational-user-layout.ts +++ b/.pi/extensions/lib/fm-calm-operational-user-layout.ts @@ -1,13 +1,14 @@ -// Pi 0.81.1 and 0.82.0 add the ordinary-user spacer and row together. -// This version-bounded adapter changes only that presentation and never message delivery. -import { - InteractiveMode, - UserMessageComponent, -} from "@earendil-works/pi-coding-agent"; +// Verified against Pi 0.81.1 and 0.82.0, which add the ordinary-user spacer and row +// together via InteractiveMode.addMessageToChat. This adapter probes that exact method +// and throws if it is missing; fm-calm.ts catches that and skips only this adapter with a +// diagnostic instead of blocking Calm or Pi. It changes only that presentation and never +// message delivery. +import type { UserMessageComponent as PiUserMessageComponent } from "@earendil-works/pi-coding-agent"; +import * as PiCodingAgent from "@earendil-works/pi-coding-agent"; import { calmPresentationHides } from "./fm-calm-visibility.ts"; import { classifyFirstmateCurrentOperationalText } from "./fm-operational-input.ts"; -type UserMessageConstructorArgs = ConstructorParameters; +type UserMessageConstructorArgs = ConstructorParameters; type UserMessageLike = { role: string; content: unknown; @@ -18,7 +19,7 @@ type AddMessageOptions = { type InteractiveModePresentation = { chatContainer: { children: unknown[]; - addChild(component: UserMessageComponent): void; + addChild(component: PiUserMessageComponent): void; }; editor: { addToHistory?(text: string): void; @@ -81,12 +82,20 @@ export function installCalmOperationalUserLayout(): void { hidesOperationalInput, isOperationalInput, }; + const InteractiveMode = PiCodingAgent.InteractiveMode; + if (typeof InteractiveMode !== "function") { + throw new Error("Firstmate Calm requires Pi InteractiveMode"); + } const prototype = InteractiveMode.prototype as unknown as InteractiveModePrototype; const originalAddMessageToChat = prototype.addMessageToChat; if (typeof originalAddMessageToChat !== "function") { throw new Error("Firstmate Calm requires Pi InteractiveMode.addMessageToChat"); } + const UserMessageComponent = PiCodingAgent.UserMessageComponent; + if (typeof UserMessageComponent !== "function") { + throw new Error("Firstmate Calm requires Pi UserMessageComponent"); + } class CalmOperationalUserMessageComponent extends UserMessageComponent { private readonly hasLeadingSpacer: boolean; diff --git a/docs/calm-mode-feasibility.md b/docs/calm-mode-feasibility.md index c4a051b9cc..b94b6a6aef 100644 --- a/docs/calm-mode-feasibility.md +++ b/docs/calm-mode-feasibility.md @@ -9,9 +9,17 @@ A qualifying implementation must auto-load from the trusted project, persist the The governing presentation policy allows genuine original user prompts, genuine user-facing assistant text, and Pi's native working activity. Changing persisted context to remove hidden content, filtering provider context, patching installed harness code, or claiming coverage outside a supported renderer does not satisfy that boundary. +## Compatibility evidence + +[`calm.md`](calm.md#pi-compatibility) owns the current Pi compatibility contract. +Pi 0.81.1 was installed when Calm was first built, and Pi 0.82.0 was the later reverification target. +The inspected Pi CHANGELOG shows no relevant presentation API introduced at either version, so those versions remain verification evidence rather than compatibility bounds. +The exported classes used by the adapters (`AssistantMessageComponent` and `InteractiveMode`) are undocumented internals with no stated version guarantee. +`tests/fm-calm-pi-extension.test.sh` records the installed Pi version as evidence without gating on it and covers both newer synthetic versions and an unavailable adapter seam. + ## Pi 0.81.1 end-to-end reproduction -The current installed and regression-supported Pi version was verified on 2026-07-22. +The Pi version installed at the time was verified on 2026-07-22. ```text $ pi --version @@ -57,7 +65,8 @@ The single-thinking, tool-call-only, tool-result, Calm-off, and `clearOnShrink` PR 927 made Calm persistent and described controlled rows as gapless while retaining a documented unsupported boundary for collapsed-thinking spacing. PR 936 removed the unsafe operational-input reroute and preserved legacy zero-height entries but did not change assistant-message layout. -The fix installs one idempotent Pi 0.81.1 through 0.82.0 presentation adapter on the exported `AssistantMessageComponent.updateContent` method. +The fix installs one idempotent presentation adapter, verified on Pi 0.81.1 through 0.82.0, on the exported `AssistantMessageComponent.updateContent` method. +The adapter probes for that exact method and, per the [compatibility contract](calm.md#pi-compatibility), degrades independently with a diagnostic rather than gating on a version number. Only while Calm is active and Pi has collapsed thinking does the adapter pass a shallow thinking-free presentation copy into Pi's ordinary layout calculation, then retain the original message on the component for invalidation and thinking expansion. The persisted assistant message, provider context, tool execution, export data, and expansion history remain unchanged. Collapsed thinking-only assistant messages now render zero rows, thinking before visible assistant text adds no spacing beyond the text-only baseline, and expanding thinking still renders the original reasoning. @@ -114,7 +123,8 @@ The real Pi viewport moved the unchanged assistant text from row 7 to row 2, ren The leading cause would have been falsified if the row or height remained, the provider lost or duplicated the message, or the persisted role or bytes changed. None occurred. -The fix installs a separate idempotent Pi 0.81.1 through 0.82.0 presentation adapter on the exported `InteractiveMode.addMessageToChat` method. +The fix installs a separate idempotent presentation adapter, verified on Pi 0.81.1 through 0.82.0, on the exported `InteractiveMode.addMessageToChat` method. +The adapter probes for that exact method and, per the [compatibility contract](calm.md#pi-compatibility), degrades independently with a diagnostic rather than gating on a version number. It delegates current recognition to `bin/fm-operational-input.sh`, adds only the evidence-backed bare-U+2063 `Supervisor escalate (` presentation compatibility shape, mounts a `UserMessageComponent` subclass that preserves Pi's stock row plus leading spacer while Calm is off, and returns zero rendered lines while Calm is on. It never intercepts the input event, rewrites the message, changes its role, filters model context, or changes session data. Messages containing an image are left on Pi's ordinary path even when their text equals an operational envelope because Firstmate's authoritative producers are text-only. @@ -148,7 +158,7 @@ Serialized session data and Pi 0.81.1's sidebar tree also retain legacy hidden o The taxonomy was derived from Pi 0.81.1's installed public declarations, documentation, examples, `interactive-mode.js`, and its exported component implementations. The test fixture enumerates every class below through the centralized policy, and the interactive fixture exercises the screenshot classes, current user-role operational input, and legacy synthetic presentation entries. -| Policy class | Pi transcript path | Calm result on Pi 0.81.1 through 0.82.0 | +| Policy class | Pi transcript path | Calm result (verified on Pi 0.81.1 through 0.82.0) | | --- | --- | --- | | `genuine-user-prompt` | `UserMessageComponent` | Visible, including every tested operational near miss. | | `genuine-agent-response` | Assistant text in `AssistantMessageComponent` | Visible. | @@ -167,12 +177,12 @@ The test fixture enumerates every class below through the centralized policy, an | `system-notice` | `showStatus`, `showError`, compaction, retry, and startup warning rows | Unsupported boundary; remains visible. | | `cache-notice` | Non-persisted cache-miss `Text` row | Unsupported boundary; remains visible. | | `project-trust-warning` | Non-persisted startup `Text` row | Unsupported boundary; remains visible. | -| `synthetic-user` | Firstmate extension `sendUserMessage`, terminal-injected input, Firstmate-generated Pi positional brief, or the already non-displayed session-start nudge | Canonically classified text-only operational user messages stay ordinary semantic user messages but render through the zero-height Pi 0.81.1 through 0.82.0 adapter under Calm; legacy entries stay gaplessly controllable, and the session-start nudge retains its existing non-displayed custom-message path. | +| `synthetic-user` | Firstmate extension `sendUserMessage`, terminal-injected input, Firstmate-generated Pi positional brief, or the already non-displayed session-start nudge | Canonically classified text-only operational user messages stay ordinary semantic user messages but render through the zero-height adapter (verified on Pi 0.81.1 through 0.82.0) under Calm; legacy entries stay gaplessly controllable, and the session-start nudge retains its existing non-displayed custom-message path. | | `synthetic-assistant` | No authoritative Firstmate source found | Policy-hidden, but Pi exposes no generic assistant-role renderer. | | `unknown` | Future or unclassified transcript component | Policy-hidden, but no generic renderer exists; never claimed as covered. | The installed extension API has no supported global transcript filter, user-message renderer, assistant-message renderer, chat-container API, or generic custom-tool wrapper. -Pi 0.81.1 through 0.82.0 export `AssistantMessageComponent` and `InteractiveMode`, so Calm uses separate version-bounded, idempotent adapters for assistant thinking layout and the complete operational-user transcript row while leaving all message data and non-Calm rendering unchanged. +Pi 0.81.1 through 0.82.0 export `AssistantMessageComponent` and `InteractiveMode`, so Calm uses separate idempotent, API-probed adapters for assistant thinking layout and the complete operational-user transcript row while leaving all message data and non-Calm rendering unchanged; see the [compatibility contract](calm.md#pi-compatibility) for how a future Pi lacking one of those exports is handled. General component replacement, ANSI cursor erasure, provider-context mutation, and installed-file patching remain rejected as unsupported or preservation-breaking workarounds. ## Cross-harness verification record @@ -197,7 +207,7 @@ grok 0.2.106 (bde89716f679) | Claude Code 2.1.218 | Not feasible through the inspected supported project surface. | Project hooks can observe lifecycle and tool events, while the plugin CLI packages supported components; neither inspected surface exposes a transcript-row renderer or transcript-wide redraw API. | | Codex CLI 0.144.6 | Not feasible through the inspected supported project surface. | The tracked hooks expose session, pre-tool, and stop handling, while the plugin and feature inventories expose no TUI tool-row renderer or transcript redraw control. | | OpenCode 1.17.18 | Not feasible without violating the preservation boundary. | Plugins expose events and tool execution hooks, not a built-in transcript-row renderer; same-name tool replacement changes execution rather than presentation alone. | -| Pi 0.81.1 through 0.82.0 | Partially feasible with two version-bounded exported-class adapters. | Public APIs control working visibility, collapsed labels, known tool slots, custom entries, and expansion redraws; exported assistant and interactive-mode classes provide the version-pinned collapsed-thinking and operational-user layout boundaries, while generic user, tool, and status filtering remains unavailable. | +| Pi (verified 0.81.1 through 0.82.0) | Partially feasible with two API-probed exported-class adapters. | Public APIs control working visibility, collapsed labels, known tool slots, custom entries, and expansion redraws; exported assistant and interactive-mode classes provide the collapsed-thinking and operational-user layout boundaries, gated on the exact method's presence rather than a version number, while generic user, tool, and status filtering remains unavailable. | | Grok CLI 0.2.106 | Not feasible through the inspected supported project surface. | Project hooks expose lifecycle and tool interception, while the plugin CLI exposes no row-renderer contract; `--minimal` changes the whole screen mode rather than selected transcript rows. | These conclusions are deliberately limited to the named versions and supported surfaces. @@ -259,8 +269,8 @@ skip: set FM_PI_LIVE_E2E=1 to run the isolated interactive Pi regression ## 2026-07-26 Pi 0.82.0 compatibility verification -Pi 0.82.0 preserved both version-bounded presentation seams and every deterministic Calm TUI guarantee. -The globally installed declaration package remained 0.81.1, so the strict typecheck continued to cover that lower supported boundary while the real CLI exercised 0.82.0. +Pi 0.82.0 preserved both API-probed presentation seams and every deterministic Calm TUI guarantee. +The globally installed declaration package remained 0.81.1, so the strict typecheck continued to cover that earlier declaration-evidence version while the real CLI exercised 0.82.0. ```text $ pi --version diff --git a/docs/calm.md b/docs/calm.md index 6a2c1d14b9..8d63b6d0b5 100644 --- a/docs/calm.md +++ b/docs/calm.md @@ -18,6 +18,12 @@ Pi's supported presentation API does not expose a global transcript filter. Expanded reasoning and its reserved spacing, built-in tool images, user-bash rows, skill and summary rows, generic status notices, and arbitrary custom-tool or extension rows remain visible. These are supported-API boundaries rather than hidden-content failures. +## Pi compatibility + +Calm has no numeric Pi version minimum or maximum and never refuses Pi solely because its version is newer than a previously verified version. +The collapsed-thinking and operational-user-row presentation adapters probe the exact Pi API seam they patch when Calm loads. +If Pi removes one of those seams, Calm logs a diagnostic naming the unavailable adapter and skips only that adapter; `/calm`, the other adapter, and unrelated Pi extensions remain available. + [`calm-mode-feasibility.md`](calm-mode-feasibility.md) owns the version-scoped renderer taxonomy and empirical evidence. [`configuration.md`](configuration.md#pi-calm-preference-configcalm) owns the persisted preference file and resolution rules. `.pi/extensions/lib/fm-calm-visibility.ts` owns the visibility policy, and `.pi/extensions/lib/fm-calm-operational-user-layout.ts` owns the zero-height operational-user row adapter. diff --git a/tests/fm-calm-pi-extension.test.sh b/tests/fm-calm-pi-extension.test.sh index b63661ad7a..46b945e23f 100755 --- a/tests/fm-calm-pi-extension.test.sh +++ b/tests/fm-calm-pi-extension.test.sh @@ -16,14 +16,15 @@ PI_OPERATIONAL_INPUT="$ROOT/.pi/extensions/lib/fm-operational-input.ts" PI_PACKAGE_DIR=${FM_PI_PACKAGE_DIR:-"$(npm root -g 2>/dev/null)/@earendil-works/pi-coding-agent"} TMUX_SOCKET="fm-calm-$$" TMUX_SESSION="fm-calm-e2e" -PI_COMPAT_VERSIONS="0.81.1 0.82.0" - -require_pi_compat_version() { +# Verified against Pi 0.81.1 and 0.82.0 (docs/calm-mode-feasibility.md). This is +# known-good evidence, not a support ceiling: the fixtures below run against whatever +# Pi is actually installed, and record_pi_version_evidence never rejects a newer +# version. The tracked presentation adapters probe the exact API they patch (see +# .pi/extensions/fm-calm.ts) instead of relying on version inference, so a version +# string is evidence for the record, not a gate. +record_pi_version_evidence() { local version=$1 context=$2 - case " $PI_COMPAT_VERSIONS " in - *" $version "*) return 0 ;; - *) fail "$context requires Pi $PI_COMPAT_VERSIONS, found $version" ;; - esac + [ -n "$version" ] || fail "$context could not determine the installed Pi version" } cleanup() { @@ -91,10 +92,13 @@ test_static_contract() { assert_contains "$text" 'ctx.ui.setWorkingVisible(true)' "Pi calm extension does not preserve Pi's live working row" assert_not_contains "$text" 'ctx.ui.setWorkingVisible(!active)' "Pi calm extension still hides Pi's live working row" assert_contains "$text" 'ctx.ui.setHiddenThinkingLabel(active ? "" : undefined)' "Pi calm extension does not hide collapsed thinking labels" - assert_contains "$text" 'installCalmAssistantLayout()' "Pi Calm extension does not install its zero-height assistant layout" - assert_contains "$text" 'installCalmOperationalUserLayout()' "Pi Calm extension does not install its operational-user layout" + assert_contains "$text" 'installCalmPresentationAdapter("collapsed-thinking", installCalmAssistantLayout)' "Pi Calm extension does not install its zero-height assistant layout" + assert_contains "$text" 'installCalmPresentationAdapter("operational-user-row", installCalmOperationalUserLayout)' "Pi Calm extension does not install its operational-user layout" + assert_contains "$text" 'function installCalmPresentationAdapter' "Pi Calm extension does not degrade a missing presentation adapter independently with a diagnostic" + assert_contains "$assistant_layout" 'import * as PiCodingAgent' "Pi Calm assistant layout still requires its optional runtime class as a named import" assert_contains "$assistant_layout" 'AssistantMessageComponent.prototype.updateContent' "Pi Calm assistant layout does not control the exported component presentation path" assert_contains "$assistant_layout" 'block.type !== "thinking"' "Pi Calm assistant layout does not remove thinking from its presentation copy" + assert_contains "$operational_user_layout" 'import * as PiCodingAgent' "Pi Calm operational-user layout still requires its optional runtime class as a named import" assert_contains "$operational_user_layout" 'InteractiveMode.prototype' "Pi Calm operational-user layout does not control the transcript owner" assert_contains "$operational_user_layout" 'classifyFirstmateCurrentOperationalText(text)' "Pi Calm operational-user layout bypasses canonical current classification" assert_contains "$operational_user_layout" 'text.includes("\u2063")' "Pi Calm operational-user layout spawns its classifier for ordinary captain rows" @@ -135,7 +139,7 @@ test_home_resolution() { return 0 fi version=$(node -p "require('$PI_PACKAGE_DIR/package.json').version") - require_pi_compat_version "$version" "Pi calm compatibility assumptions" + record_pi_version_evidence "$version" "Pi calm compatibility assumptions" fixture="$TMP_ROOT/home-resolution" mkdir -p \ @@ -233,6 +237,173 @@ JS pass "Pi calm resolves its persistent home independently of Pi's launch directory" } +test_pi_compat_no_upper_bound() { + local version + for version in 0.83.0 0.90.0 1.0.0 2.3.4 0.82.1 10.20.30; do + record_pi_version_evidence "$version" "synthetic newer Pi" \ + || fail "record_pi_version_evidence rejected Pi $version solely for being newer than 0.82.0" + done + if (record_pi_version_evidence "" "malformed Pi version probe") 2>/dev/null; then + fail "record_pi_version_evidence accepted a missing/malformed Pi version" + fi + pass "Pi calm compatibility evidence never rejects a Pi version for being newer than 0.82.0, and still fails closed on a missing or malformed version" +} + +test_pi_compat_degraded_adapter() { + local fixture out status + if ! command -v node >/dev/null 2>&1 || ! command -v npm >/dev/null 2>&1; then + echo "skip: node or npm not found for Pi calm degraded-adapter test" + return 0 + fi + if [ ! -f "$PI_PACKAGE_DIR/package.json" ]; then + echo "skip: installed @earendil-works/pi-coding-agent package not found" + return 0 + fi + + fixture="$TMP_ROOT/degraded-adapter" + mkdir -p \ + "$fixture/project/.pi/extensions/lib" \ + "$fixture/project/node_modules/@earendil-works" + cp "$EXT" "$fixture/project/.pi/extensions/fm-calm.ts" + cp "$ASSISTANT_LAYOUT" "$fixture/project/.pi/extensions/lib/fm-calm-assistant-layout.ts" + cp "$OPERATIONAL_USER_LAYOUT" "$fixture/project/.pi/extensions/lib/fm-calm-operational-user-layout.ts" + cp "$VISIBILITY" "$fixture/project/.pi/extensions/lib/fm-calm-visibility.ts" + cp "$PI_OPERATIONAL_INPUT" "$fixture/project/.pi/extensions/lib/fm-operational-input.ts" + ln -s "$PI_PACKAGE_DIR" "$fixture/project/node_modules/@earendil-works/pi-coding-agent" + ln -s "$PI_PACKAGE_DIR/node_modules/@earendil-works/pi-tui" "$fixture/project/node_modules/@earendil-works/pi-tui" + ln -s "$PI_PACKAGE_DIR/node_modules/typebox" "$fixture/project/node_modules/typebox" + printf '%s\n' '{"type":"module"}' >"$fixture/project/package.json" + + out=$(cd "$fixture/project" && \ + EXT="$fixture/project/.pi/extensions/fm-calm.ts" \ + PI_PACKAGE_DIR="$PI_PACKAGE_DIR" \ + node --input-type=module 2>&1 <<'JS' +import { pathToFileURL } from "node:url"; + +const packageRoot = process.env.PI_PACKAGE_DIR; +const { AssistantMessageComponent } = await import( + pathToFileURL(`${packageRoot}/dist/modes/interactive/components/assistant-message.js`).href +); +const originalUpdateContent = AssistantMessageComponent.prototype.updateContent; +if (typeof originalUpdateContent !== "function") { + throw new Error( + "fixture precondition failed: installed Pi lacks AssistantMessageComponent.prototype.updateContent", + ); +} +delete AssistantMessageComponent.prototype.updateContent; + +const diagnostics = []; +const originalConsoleError = console.error; +console.error = (...args) => diagnostics.push(args.join(" ")); + +let calmCommand; +const handlers = new Map(); +const pi = { + events: { emit() {}, on() {} }, + on(event, handler) { + handlers.set(event, handler); + }, + registerCommand(name, command) { + if (name === "calm") calmCommand = command; + }, + registerEntryRenderer() {}, + registerTool() {}, +}; + +let threw = false; +try { + const extension = await import(`${pathToFileURL(process.env.EXT).href}?degraded=${Date.now()}`); + extension.default(pi); +} catch { + threw = true; +} +console.error = originalConsoleError; + +if (threw) { + throw new Error( + "a missing presentation API crashed the whole Calm extension instead of degrading just that adapter", + ); +} +if (!calmCommand || !handlers.has("session_start")) { + throw new Error( + "Calm command/session lifecycle did not register when only one presentation adapter was unavailable", + ); +} +if (typeof AssistantMessageComponent.prototype.updateContent !== "undefined") { + throw new Error( + "the degraded adapter path patched updateContent anyway despite the missing API, which would claim false success", + ); +} +const sawClearSkipReason = diagnostics.some( + (line) => line.includes("collapsed-thinking") && /unavailable|skip/i.test(line), +); +if (!sawClearSkipReason) { + throw new Error( + `missing a clear skip reason for the degraded collapsed-thinking adapter; saw: ${JSON.stringify(diagnostics)}`, + ); +} + +AssistantMessageComponent.prototype.updateContent = originalUpdateContent; +JS +) + status=$? + [ "$status" -eq 0 ] || fail "Pi calm degraded-adapter path failed: $out" + [ -z "$out" ] || fail "Pi calm degraded-adapter test printed output: $out" + pass "a missing collapsed-thinking presentation API degrades only that Calm adapter with a clear skip reason, while the rest of Calm still registers" +} + +test_pi_compat_missing_adapter_exports() { + local fixture out status + if ! command -v node >/dev/null 2>&1; then + echo "skip: node not found for Pi calm missing-adapter-export test" + return 0 + fi + + fixture="$TMP_ROOT/missing-adapter-exports" + mkdir -p \ + "$fixture/project/.pi/extensions/lib" \ + "$fixture/project/node_modules/@earendil-works/pi-coding-agent" + cp "$ASSISTANT_LAYOUT" "$fixture/project/.pi/extensions/lib/fm-calm-assistant-layout.ts" + cp "$OPERATIONAL_USER_LAYOUT" "$fixture/project/.pi/extensions/lib/fm-calm-operational-user-layout.ts" + cp "$VISIBILITY" "$fixture/project/.pi/extensions/lib/fm-calm-visibility.ts" + cp "$PI_OPERATIONAL_INPUT" "$fixture/project/.pi/extensions/lib/fm-operational-input.ts" + printf '%s\n' '{"type":"module"}' >"$fixture/project/package.json" + printf '%s\n' \ + '{"name":"@earendil-works/pi-coding-agent","type":"module","exports":"./index.js"}' \ + >"$fixture/project/node_modules/@earendil-works/pi-coding-agent/package.json" + printf '%s\n' \ + 'export function getMarkdownTheme() { return {}; }' \ + 'export class UserMessageComponent {}' \ + >"$fixture/project/node_modules/@earendil-works/pi-coding-agent/index.js" + + out=$(cd "$fixture/project" && node --input-type=module 2>&1 <<'JS' +const assistant = await import("./.pi/extensions/lib/fm-calm-assistant-layout.ts"); +const operational = await import("./.pi/extensions/lib/fm-calm-operational-user-layout.ts"); + +for (const [name, install, expected] of [ + ["collapsed-thinking", assistant.installCalmAssistantLayout, "AssistantMessageComponent"], + ["operational-user-row", operational.installCalmOperationalUserLayout, "InteractiveMode"], +]) { + let reason; + try { + install(); + } catch (error) { + reason = error instanceof Error ? error.message : String(error); + } + if (!reason?.includes(expected)) { + throw new Error( + `${name} adapter did not load and report its missing runtime export: ${String(reason)}`, + ); + } +} +JS +) + status=$? + [ "$status" -eq 0 ] || fail "Pi calm missing-adapter-export path failed: $out" + [ -z "$out" ] || fail "Pi calm missing-adapter-export test printed output: $out" + pass "missing Pi presentation class exports reach the independent adapter degradation path" +} + test_rendering_and_session_lifecycle() { local fixture out status version if ! command -v node >/dev/null 2>&1 || ! command -v npm >/dev/null 2>&1; then @@ -244,7 +415,7 @@ test_rendering_and_session_lifecycle() { return 0 fi version=$(node -p "require('$PI_PACKAGE_DIR/package.json').version") - require_pi_compat_version "$version" "Pi calm compatibility assumptions" + record_pi_version_evidence "$version" "Pi calm compatibility assumptions" fixture="$TMP_ROOT/renderer" mkdir -p "$fixture/home" "$fixture/lib" "$fixture/node_modules/@earendil-works" @@ -895,7 +1066,7 @@ test_operational_followup_turn_e2e() { return 0 fi version=$(pi --version 2>/dev/null || true) - require_pi_compat_version "$version" "Pi operational follow-up E2E" + record_pi_version_evidence "$version" "Pi operational follow-up E2E" project="$TMP_ROOT/followup-project" home="$TMP_ROOT/followup-home" @@ -1248,7 +1419,7 @@ test_hidden_block_geometry_e2e() { return 0 fi version=$(pi --version 2>/dev/null || true) - require_pi_compat_version "$version" "Pi Calm hidden-block geometry E2E" + record_pi_version_evidence "$version" "Pi Calm hidden-block geometry E2E" project="$TMP_ROOT/geometry-project" home="$TMP_ROOT/geometry-home" @@ -1488,7 +1659,7 @@ test_interactive_terminal_e2e() { return 0 fi version=$(pi --version 2>/dev/null || true) - require_pi_compat_version "$version" "Pi calm interactive E2E" + record_pi_version_evidence "$version" "Pi calm interactive E2E" project="$TMP_ROOT/e2e-project" config="$TMP_ROOT/e2e-config" @@ -1977,6 +2148,9 @@ JS test_static_contract test_home_resolution +test_pi_compat_no_upper_bound +test_pi_compat_degraded_adapter +test_pi_compat_missing_adapter_exports test_rendering_and_session_lifecycle test_operational_followup_turn_e2e test_hidden_block_geometry_e2e From 7cbb3f63a2e4319cd38fba13ee2324fb47b0ecc6 Mon Sep 17 00:00:00 2001 From: Daniel Kuykendall IV Date: Tue, 28 Jul 2026 23:14:05 -0400 Subject: [PATCH 06/70] fix(bin): allow session-local todo tools in the subagent guard (#1204) * fix(guard): allow session-local todo tools in the primary The delegation-shape guard denied TaskCreate and TaskUpdate because their normalized names contain the `task` stem. Those tools write only the harness's session-local todo list, which has no executor: it spawns no agent, allocates no worktree, registers no schedule, and starts nothing that outlives the session. That is not the unaccounted work the guard exists to stop, so the stem match was a false positive, and the deny text told the primary to run bin/fm-brief.sh and bin/fm-spawn.sh to create a todo entry. Add a separately-reasoned PLAN_ONLY_TOOLS exact-name exclusion rather than widening OBSERVE_ONLY_TOOLS, whose documented contract is tools that only observe or stop existing work. Both lists stay exact-name so neither can widen by substring. Tests cover the two allowed names and six near-miss names that a substring or shortened-stem widening would release; both mutations were watched red. * no-mistakes(review): drop session-local todo tools from recommended deny list * no-mistakes: apply CI fixes --- bin/fm-subagent-pretool-check.sh | 15 ++++++++++- docs/subagent-guard.md | 29 +++++++++++++------- tests/fm-backend.test.sh | 11 ++++++-- tests/fm-subagent-pretool-check.test.sh | 36 +++++++++++++++++++++++++ 4 files changed, 78 insertions(+), 13 deletions(-) diff --git a/bin/fm-subagent-pretool-check.sh b/bin/fm-subagent-pretool-check.sh index 169e3079d2..8edb507218 100755 --- a/bin/fm-subagent-pretool-check.sh +++ b/bin/fm-subagent-pretool-check.sh @@ -65,6 +65,19 @@ DELEGATION_STEMS='agent subagent task workflow cron schedul worktree delegate sp # reason a runaway task cannot be stopped. OBSERVE_ONLY_TOOLS='taskoutput taskstop taskget tasklist cronlist bashoutput killshell' +# Exact lowercase tool names that match a stem above but create no RUNNABLE +# work. These write only the harness's session-local todo list, which has no +# executor: it spawns no agent, allocates no worktree, registers no schedule, +# and starts nothing that could outlive the session or escape a firstmate +# guard. Denying them stops the primary tracking its own plan while granting no +# delegation power, and the deny text would tell it to run bin/fm-brief.sh for a +# todo entry, so the stem match here is a false positive rather than a policy. +# This is a separate list from OBSERVE_ONLY_TOOLS on purpose: these tools WRITE, +# so folding them into a list documented as observe-or-stop would make that +# contract untrue. Both lists are exact-name, never substring, so neither can +# widen by accident. +PLAN_ONLY_TOOLS='taskcreate taskupdate' + TOOL="" TOOL_SET=0 CLAUDE_MODE=0 @@ -139,7 +152,7 @@ case "$TOOL" in mcp__*) exit 0 ;; esac -for allowed in $OBSERVE_ONLY_TOOLS; do +for allowed in $OBSERVE_ONLY_TOOLS $PLAN_ONLY_TOOLS; do [ "$NORMALIZED" != "$allowed" ] || exit 0 done diff --git a/docs/subagent-guard.md b/docs/subagent-guard.md index 87f194d9d1..47aaf10e0f 100644 --- a/docs/subagent-guard.md +++ b/docs/subagent-guard.md @@ -47,14 +47,22 @@ agent subagent task workflow cron schedul worktree delegate spawn dispatch handoff remote sendmessage monitor ``` -Two exclusions keep the shape test from producing false positives. +Three exclusions keep the shape test from producing false positives. - A name beginning `mcp__` is never classified. An MCP server chooses its own tool names, a task or agent noun there is common, and it has no bearing on fleet dispatch. -- The exact names `taskoutput`, `taskstop`, `taskget`, `tasklist`, `cronlist`, `bashoutput`, and `killshell` are allowed. +- `OBSERVE_ONLY_TOOLS`: the exact names `taskoutput`, `taskstop`, `taskget`, `tasklist`, `cronlist`, `bashoutput`, and `killshell` are allowed. These observe or stop work that already exists rather than creating it, and denying them at this layer could strand already-running work with no way to inspect or end it. A Claude primary's optional local deny list may still remove them from the schema. The shipped guard stays narrower on purpose so it can never be the reason a runaway task cannot be stopped. +- `PLAN_ONLY_TOOLS`: the exact names `taskcreate` and `taskupdate` are allowed. + These write, which is why they are a separate list rather than more entries in the observe-or-stop one, but what they write is the harness's session-local todo list. + That list has no executor: it spawns no agent, allocates no worktree, registers no schedule, and starts nothing that could outlive the session or escape a firstmate guard. + So it is not the "work, agent, schedule, or isolated workspace that firstmate would not know about" the guard exists to stop, and the stem match on `task` is a false positive rather than a policy. + The cost of the false positive was concrete: the primary could not track its own plan, and the deny text told it to run `bin/fm-brief.sh` and `bin/fm-spawn.sh` to create a todo entry. + +Both exclusion lists match the whole normalized name, never a substring, so neither can widen by accident: `TaskCreateAgent` and `RemoteTaskCreate` stay denied. +Folding the two lists together would be the drift risk, because the observe-or-stop rationale is not true of a tool that writes. The shipped guard fires on every delegation-shaped name that reaches it, including future names that no deny list knows about yet. That future-name behavior is the reason the tracked matcher must match all tools and let the script filter. @@ -79,10 +87,8 @@ Claude primaries should add this deny list in untracked per-home local settings, "CronCreate", "CronDelete", "CronList", - "TaskCreate", "TaskGet", "TaskList", - "TaskUpdate", "TaskStop", "TaskOutput" ] @@ -103,8 +109,11 @@ It is not tracked for two reasons. The width of the list remains a captain-owned decision, because denying some of these changes how the captain works with the primary session. Keep it as one flat local array that is reviewable at a glance and narrowable in one line. -In particular `TaskOutput`, `TaskStop`, `TaskGet`, `TaskList`, and `CronList` only observe or stop work that already exists, but the recommended local deny list still removes them by default. -The hook deliberately allows those names, so the shipped guard can never strand a runaway task with no way to inspect or end it. +In particular `TaskOutput`, `TaskStop`, `TaskGet`, `TaskList`, and `CronList` only observe or stop work that already exists, yet the recommended local deny list still removes all five by default. +The hook deliberately allows those five, so the shipped guard can never strand a runaway task with no way to inspect or end it, and it allows `TaskCreate` and `TaskUpdate` too, so it can never be the reason the primary cannot track its own plan. +The two session-local todo tools are no longer recommended for local denial at all, because they write only the harness's session-local todo list, which has no executor and spawns nothing, so removing them from the schema removes no delegation power. +Denying them there would instead reproduce at a stronger layer the exact false positive the shipped guard now avoids, leaving anyone who adopts this list verbatim unable to let a primary track its own plan. +Narrowing the list further, including the five observe-or-stop names, is the captain's call, and this local list is the only layer that can remove a todo tool from the primary's schema. `permissions.allow` is a pre-approval list, not an availability list, so there is no fail-closed positive allowlist available. That is why any fixed deny list is fail-open against future tools and why the shape-based guard still exists. @@ -171,7 +180,7 @@ Applicability turns on one question: does the harness expose built-in delegation | Harness | Delegation surface | Status | | --- | --- | --- | -| Claude | 18 known tools, listed above | Scoped guard wired and live-verified; untracked local deny list verified and recommended. | +| Claude | 16 known tools, listed above | Scoped guard wired and live-verified; untracked local deny list verified and recommended. | | Codex | none | Not applicable, verified empirically below. Codex 0.144.1 exposes no subagent, sub-task, or delegated-agent tool, so there is nothing to remove or intercept. `.codex/hooks.json` is unchanged. | | Grok | present, exact tokens unconfirmed | Not wired pending live verification. See below. | | OpenCode | present, exact tokens unconfirmed | Not wired pending live verification. See below. | @@ -285,8 +294,8 @@ This distinction matters when reading the next result: a tool absent from a plai ### Local deny-list hardening -Run in a scratch firstmate-shaped project containing `AGENTS.md`, `state/`, a full copy of `bin/`, and a Claude settings file containing the recommended local deny-list JSON above. -The result validates the recommended local deny-list JSON above, not tracked repo state. +Run in a scratch firstmate-shaped project containing `AGENTS.md`, `state/`, a full copy of `bin/`, and a Claude settings file containing the local deny list exactly as recommended on that date, which was the 18-name form that still included `TaskCreate` and `TaskUpdate`. +The result validates that local deny list rather than tracked repo state, and the recommendation above has since dropped those two session-local todo tools. Asking for deferred entries explicitly returned: ```text @@ -344,7 +353,7 @@ The live consequence is confirmed by the shipped-guard result above: Claude hono ## Automated validation `tests/fm-subagent-pretool-check.test.sh` owns the acceptance matrix and is registered in the `pure-contract-unit` family in `bin/fm-test-run.sh`. -It covers the tracked Claude settings boundary that forbids a `permissions` key; the match-all Claude hook registration; denial of every work-creating delegation tool by shape; denial of twelve hypothetical future tool names that appear on no list; the observe-or-stop and MCP exclusions; the scout-present and scout-absent message variants; the escape hatch including its fail-closed values; inertness in a linked task worktree and in a non-firstmate repo; in-scope enforcement for a marked secondmate home; both stdin transports; the empty-stdout requirement; fail-open transport behavior; and the preserved `Bash` seatbelts and `Stop` guard. +It covers the tracked Claude settings boundary that forbids a `permissions` key; the match-all Claude hook registration; denial of every work-creating delegation tool by shape; denial of twelve hypothetical future tool names that appear on no list; the observe-or-stop, plan-only, and MCP exclusions; the exactness of the plan-only exclusion against six near-miss names a substring or shorter-stem widening would release; the scout-present and scout-absent message variants; the escape hatch including its fail-closed values; inertness in a linked task worktree and in a non-firstmate repo; in-scope enforcement for a marked secondmate home; both stdin transports; the empty-stdout requirement; fail-open transport behavior; and the preserved `Bash` seatbelts and `Stop` guard. Run: diff --git a/tests/fm-backend.test.sh b/tests/fm-backend.test.sh index 922b227cec..622bb12839 100755 --- a/tests/fm-backend.test.sh +++ b/tests/fm-backend.test.sh @@ -1036,8 +1036,15 @@ test_teardown_conformance_old_vs_new() { expect_code 0 "$rc_new" "new fm-teardown.sh (scout, report present) should succeed"$'\n'"$out_new" assert_contains "$(cat "$log_new")" "treehouse"$'\x1f''return'$'\x1f''--force'$'\x1f'"$wt" \ "teardown did not call treehouse return --force " - assert_contains "$(cat "$log_old")" "tmux"$'\x1f''kill-window'$'\x1f''-t'$'\x1f'"firstmate:fm-$id" \ - "legacy teardown fixture did not exercise tmux's permissive target selector" + # The legacy fixture's adapter comes from BASE_REF, so its selector form is + # whatever the merge-base carried: permissive while the exact-selector change + # was still on a branch, exact for every branch cut after it landed on main. + # Pinning the old form here would make this case pass once and then fail + # forever, so the '=' exactness markers are normalized away and the legacy run + # is only required to have reached tmux window cleanup for this task. The + # exact-selector contract belongs to the current script, asserted below. + assert_contains "$(tr -d '=' < "$log_old")" "tmux"$'\x1f''kill-window'$'\x1f''-t'$'\x1f'"firstmate:fm-$id" \ + "legacy teardown fixture did not exercise tmux window cleanup for the task" assert_contains "$(cat "$log_new")" "tmux"$'\x1f''kill-window'$'\x1f''-t'$'\x1f'"=firstmate:=fm-$id" \ "teardown did not call tmux kill-window with exact session and window selectors" diff --git a/tests/fm-subagent-pretool-check.test.sh b/tests/fm-subagent-pretool-check.test.sh index 6b4868b1d6..27fbc96dc7 100755 --- a/tests/fm-subagent-pretool-check.test.sh +++ b/tests/fm-subagent-pretool-check.test.sh @@ -31,6 +31,17 @@ DELEGATION_TOOLS='Task Agent Workflow RemoteTrigger Monitor ScheduleWakeup SendM # Tools that must stay available: denying these would break ordinary work. PRESERVED_TOOLS='Bash Edit Read Write Skill ToolSearch WebFetch WebSearch NotebookEdit ReportFindings DesignSync PushNotification' +# Session-local todo-list tools. They match a delegation stem but create no +# runnable work, so the guard's plan-only exclusion must allow them. +PLAN_ONLY_TOOLS='TaskCreate TaskUpdate' + +# Names the plan-only exclusion must NOT release. Five of them contain a +# plan-only name as a substring and would be let through by a substring rather +# than exact-name match; bare Task is what a shortened entry of "task" would +# release. Together they make the exact-name contract testable instead of +# assumed. +PLAN_ONLY_NEAR_MISSES='TaskCreateAgent TaskCreateWorktree TaskUpdateAgent RemoteTaskCreate Task TaskCreator' + run_tool() { local tool=$1 rc=0 shift @@ -76,6 +87,7 @@ test_guard_denies_every_currently_known_delegation_tool() { for tool in $DELEGATION_TOOLS; do case "$tool" in TaskOutput|TaskStop|TaskGet|TaskList|CronList) continue ;; + TaskCreate|TaskUpdate) continue ;; esac expect_deny "known delegation tool" "$tool" done @@ -107,6 +119,28 @@ test_guard_allows_ordinary_and_observe_only_tools() { pass "the guard leaves ordinary tools and observe-or-stop operations alone" } +test_guard_allows_session_local_todo_tools() { + # These write, so they are not observe-or-stop, but what they write is the + # harness's session-local todo list: no executor, no agent, no worktree, no + # schedule, nothing that outlives the session. Denying them stops the primary + # tracking its own plan and grants no delegation power in exchange. + local tool + for tool in $PLAN_ONLY_TOOLS; do + expect_allow "session-local todo tool" "$tool" + done + pass "the guard leaves the session-local todo list alone" +} + +test_plan_only_exclusion_is_exact_name() { + # The plan-only exclusion must never widen by substring or by a shorter stem. + # Every name here would be released by such a widening and must stay denied. + local tool + for tool in $PLAN_ONLY_NEAR_MISSES; do + expect_deny "plan-only near miss" "$tool" + done + pass "the plan-only exclusion releases exactly two names and nothing that merely contains them" +} + test_guard_never_classifies_mcp_tools() { # An MCP server names its own tools; a task or agent noun there is common and # has nothing to do with fleet dispatch. @@ -277,6 +311,8 @@ test_tracked_settings_do_not_ship_permissions_deny test_guard_denies_every_currently_known_delegation_tool test_guard_denies_hypothetical_future_tools test_guard_allows_ordinary_and_observe_only_tools +test_guard_allows_session_local_todo_tools +test_plan_only_exclusion_is_exact_name test_guard_never_classifies_mcp_tools test_deny_message_defers_to_intake_classification test_escape_hatch_allows_deliberate_use From a117b4111ce4eebd663245bf0589c18e48370f12 Mon Sep 17 00:00:00 2001 From: Trillium Smith Date: Tue, 28 Jul 2026 20:15:26 -0700 Subject: [PATCH 07/70] fix(session-lock): resolve Claude bg-spare ancestry to the outermost claude pid (#1206) * fix(session-lock): resolve Claude bg-spare ancestry to the outermost claude pid fm_harness_ancestry_pid() previously returned the first ancestor process whose command matched a verified harness name. Claude Code's Stop hook fires as a bg-spare worker several levels below the session's actual lock-owning claude process (hook shell -> claude bg-spare -> claude bg-pty-host -> claude -> claude(lock)), so the first match was the bg-spare worker, not the lock owner. fm_session_lock_owned_by_self() then never matched state/.lock, and the Claude Stop auto-arm silently treated its own primary session as an unrelated live owner and never armed the watcher. The walk now keeps going past a claude-named match, looking for a still more ancestral claude-named match, and stops the instant a non-match follows an already-found match (bounding it to a contiguous run rather than the literal ancestry top, so an unrelated claude-named process further up the real process tree is never mistaken for part of this session's own nested chain). Every other harness keeps the original first-match-wins behavior, since e.g. Pi's shared signed-wrapper ancestry actually holds the session at the inner engine pid, not an outer wrapper pid. Hop limit raised from 8 to 16 to cover the deeper bg-spare chain. * no-mistakes(review): Add nested-claude-ancestry regression test; fix nudge doc depth claim * no-mistakes: apply CI fixes --- bin/backends/herdr.sh | 12 +++++- bin/fm-session-lock-lib.sh | 58 +++++++++++++++++++++------- docs/configuration.md | 2 +- docs/sessionstart-nudge.md | 2 +- docs/verification/supervision.md | 1 + tests/fm-claude-stop-autoarm.test.sh | 33 ++++++++++++++++ 6 files changed, 91 insertions(+), 17 deletions(-) diff --git a/bin/backends/herdr.sh b/bin/backends/herdr.sh index 3a28daaa33..237d2348c5 100644 --- a/bin/backends/herdr.sh +++ b/bin/backends/herdr.sh @@ -1831,7 +1831,15 @@ FM_BACKEND_HERDR_IDLE_RE=${FM_BACKEND_HERDR_IDLE_RE:-'^Type a message\.\.\.$'} # Known bare (unbordered) prompt glyphs a composer row may start with: ❯ # (claude) and › (codex) only. Generic shell-style glyphs > $ % # are still # recognized after a bordered composer row has already been structurally found. -FM_BACKEND_HERDR_BARE_PROMPT_RE=${FM_BACKEND_HERDR_BARE_PROMPT_RE:-'^[❯›]'} +# Deliberately an alternation, not a `[...]` bracket expression: under a C/POSIX +# locale (LC_CTYPE=C, the fleet default), grep's bracket expressions match +# individual BYTES rather than whole multibyte characters, so `[❯›]` silently +# decomposes into the shared leading UTF-8 byte (0xE2) and spuriously matches +# ANY multibyte glyph in that range - including box-drawing corners like ╰, +# misclassifying a bordered composer's bottom border row as the bare shape. +# An alternation's branches are matched as whole literal byte sequences and +# stay correct regardless of locale. +FM_BACKEND_HERDR_BARE_PROMPT_RE=${FM_BACKEND_HERDR_BARE_PROMPT_RE:-'^(❯|›)'} # Pi allows a multi-line composer between its horizontal separators. Bound the # structural candidate so two unrelated transcript rules with an arbitrarily # large region between them can never be promoted into a composer. @@ -1995,7 +2003,7 @@ EOF fi # Delegate the empty/pending/unknown decision to the shared owner. The bare # shape only ever starts with an AGENT glyph (FM_BACKEND_HERDR_BARE_PROMPT_RE - # is '^[❯›]'), so a bare shell prompt never reaches here - it stays 'unknown' + # is '^(❯|›)'), so a bare shell prompt never reaches here - it stays 'unknown' # via the no-composer-row path above, exactly as before. fm_composer_classify_content "$bordered" "$stripped" "$FM_BACKEND_HERDR_IDLE_RE" } diff --git a/bin/fm-session-lock-lib.sh b/bin/fm-session-lock-lib.sh index 90303cda1c..0e518c1c8d 100644 --- a/bin/fm-session-lock-lib.sh +++ b/bin/fm-session-lock-lib.sh @@ -11,24 +11,56 @@ # Known harness command names; extend when a new adapter is verified. FM_HARNESS_RE='claude|codex|opencode|grok|kimi|^pi$|^pi-signed$' -# Walk the current process ancestry (up to 8 hops) and print the first pid whose -# command looks like a verified harness. The harness pid lives as long as the -# session, unlike the transient subshell pid of any one tool call. +# Walk the current process ancestry (up to 16 hops) and print a harness pid. +# For every harness except Claude, the first match wins (innermost pid), which +# is where e.g. Pi's shared signed-wrapper ancestry actually holds the session: +# a "pi-signed" launcher can be the direct parent of the inner "pi" engine +# pid that owns the lock, and the wrapper pid above it is not that owner. +# Claude Code's bg-spare hook worker chain is the opposite shape: it nests +# several claude-named processes directly parent-child with no non-harness +# process between them, and the lock is held by the outermost pid of that +# run. So once a claude-named match is found, this keeps walking past it +# looking for a still-more-ancestral claude-named match, and stops the +# instant a non-match follows - never walking past that gap to an unrelated +# claude-named process further up the real process tree (e.g. the live +# session that launched a test as its own subprocess). The harness pid lives +# as long as the session, unlike the transient subshell pid of any one tool +# call. fm_harness_ancestry_pid() { - local pid=$$ comm args - for _ in 1 2 3 4 5 6 7 8; do - comm=$(ps -o comm= -p "$pid" 2>/dev/null) || return 1 + local pid=$$ comm args best='' bc extending=0 hit=0 is_claude=0 + for _ in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16; do + comm=$(ps -o comm= -p "$pid" 2>/dev/null) || break args=$(ps -o args= -p "$pid" 2>/dev/null) - if printf '%s' "$(basename "$comm")" | grep -qE "$FM_HARNESS_RE"; then - echo "$pid"; return 0 + bc=$(basename "$comm") + hit=0; is_claude=0 + if printf '%s' "$bc" | grep -qE "$FM_HARNESS_RE"; then + hit=1 + case "$bc" in *claude*) is_claude=1 ;; esac + else + # Bare interpreter (e.g. node): match the harness name in its script path. + case "$comm" in + *node*|*python*) + if printf '%s' "$args" | grep -qE "$FM_HARNESS_RE"; then + hit=1 + case "$args" in *claude*) is_claude=1 ;; esac + fi + ;; + esac + fi + if [ "$hit" -eq 1 ]; then + best="$pid" + if [ "$is_claude" -eq 1 ]; then + extending=1 + else + break + fi + elif [ "$extending" -eq 1 ]; then + break fi - # Bare interpreter (e.g. node): match the harness name in its script path. - case "$comm" in - *node*|*python*) printf '%s' "$args" | grep -qE "$FM_HARNESS_RE" && { echo "$pid"; return 0; } ;; - esac pid=$(ps -o ppid= -p "$pid" 2>/dev/null | tr -d ' ') - [ -n "$pid" ] && [ "$pid" -gt 1 ] || return 1 + [ -n "$pid" ] && [ "$pid" -gt 1 ] || break done + [ -n "$best" ] && { echo "$best"; return 0; } return 1 } diff --git a/docs/configuration.md b/docs/configuration.md index 1b8ea3bb59..642d750183 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -379,7 +379,7 @@ FM_BACKEND= # optional runtime backend override for new spawns; tmux HERDR_SESSION=default # herdr-only: named session for normal backend ops; not enough for destructive cleanup (docs/herdr-backend.md) FM_BACKEND_HERDR_COMPOSER_LINES=20 # herdr-only: tail lines scanned by composer-state guard/fallback paths; idle-baseline submit confirmation uses agent-state FM_BACKEND_HERDR_IDLE_RE='^Type a message\.\.\.$' # herdr-only: empty-composer placeholder regex after shared ghost extraction plus border and prompt stripping -FM_BACKEND_HERDR_BARE_PROMPT_RE='^[❯›]' # herdr-only: verified agent glyphs recognized as an UNBORDERED (bare) composer row, e.g. Claude's ❯ or Codex's ›; shell glyphs remain unknown rather than empty, and de-emphasised ghost/placeholder text reads empty through shared fm_composer_strip_ghost (docs/herdr-backend.md "Composer and injection safety") +FM_BACKEND_HERDR_BARE_PROMPT_RE='^(❯|›)' # herdr-only: verified agent glyphs recognized as an UNBORDERED (bare) composer row, e.g. Claude's ❯ or Codex's ›; an alternation, not a `[...]` bracket expression, so a C-locale byte-decomposed match can never misfire on an unrelated multibyte glyph; shell glyphs remain unknown rather than empty, and de-emphasised ghost/placeholder text reads empty through shared fm_composer_strip_ghost (docs/herdr-backend.md "Composer and injection safety") FM_BACKEND_HERDR_PI_COMPOSER_MAX_LINES=8 # herdr-only: maximum rows admitted between Pi's native-identity-corroborated separator pair; taller or ambiguous candidates stay unknown (docs/herdr-backend.md "Composer and injection safety") FM_BACKEND_HERDR_SUBMIT_POLLS=6 # herdr-only: agent-state samples spread across each Enter attempt's budget when confirming a submit (docs/herdr-backend.md "Current transport behavior") FM_BACKEND_HERDR_SUBMIT_MIN_SLEEP=0.6 # herdr-only: minimum per-Enter confirmation budget before polling agent-state after an idle baseline diff --git a/docs/sessionstart-nudge.md b/docs/sessionstart-nudge.md index ef21cea132..7830dfcb3b 100644 --- a/docs/sessionstart-nudge.md +++ b/docs/sessionstart-nudge.md @@ -12,7 +12,7 @@ It sources `bin/fm-gate-refuse-lib.sh` and stays silent for a no-mistakes gate a It shares `bin/fm-primary-scope-lib.sh` with `bin/fm-turnend-guard.sh`, so the hooks use one primary-detection owner. The Shared Predicate section of [`turnend-guard.md`](turnend-guard.md#shared-predicate) owns marker validation, plain-checkout detection, and required Firstmate-shaped paths. -Before printing, the wrapper reads `state/.lock` and walks at most eight parents from its own pid, matching `bin/fm-lock.sh` and Pi's `lockOwnership()` ancestry depth. +Before printing, the wrapper reads `state/.lock` and walks at most eight parents from its own pid in its own separate, hard-coded loop, independent of `bin/fm-lock.sh`'s ancestry walk (`fm_harness_ancestry_pid()` in `bin/fm-session-lock-lib.sh`, which now walks up to sixteen parents and can extend past a claude-named match to a still-more-ancestral one) and of Pi's `lockOwnership()`. If the lock names a live pid in that ancestry, session start already ran in this harness session and the wrapper stays silent. Every path exits 0, including malformed state and adapter errors, because a Claude SessionStart exit 2 blocks session initialization. diff --git a/docs/verification/supervision.md b/docs/verification/supervision.md index 326d21d73e..a364f8db04 100644 --- a/docs/verification/supervision.md +++ b/docs/verification/supervision.md @@ -94,6 +94,7 @@ The same run proved the Claude-compatible Stop entries stay inert under `GROK_AG The secondmate-home scope and manual-repair wake path were measured with Claude Code 2.1.207 on 2026-07-12, when a native background completion re-invoked the idle model with no human input. The current Stop-owned main/secondmate inclusion and child-worktree exclusion are covered deterministically by `tests/fm-claude-stop-autoarm.test.sh`. +On 2026-07-28 with Claude Code 2.1.205, `fm_harness_ancestry_pid()` in `bin/fm-session-lock-lib.sh` was fixed to resolve the outermost pid of a contiguous nested-harness run instead of the first match, so the Stop auto-arm correctly reaches the session's true lock owner through Claude Code's multi-level `bg-spare` hook worker chain. The Claude product live path ran with Claude Code 2.1.219 on 2026-07-24: diff --git a/tests/fm-claude-stop-autoarm.test.sh b/tests/fm-claude-stop-autoarm.test.sh index 63ddb8a7b1..bcc4fceefb 100755 --- a/tests/fm-claude-stop-autoarm.test.sh +++ b/tests/fm-claude-stop-autoarm.test.sh @@ -20,6 +20,7 @@ fm_git_identity fmtest fmtest@example.invalid FAKEBIN=$(fm_fakebin "$TMP_ROOT/fakebin") ln -s /bin/bash "$FAKEBIN/claude" FAKE_CLAUDE="$FAKEBIN/claude" +export FAKE_CLAUDE # Copy the hook and its sourced dependencies into a fixture checkout. install_autoarm_scripts() { @@ -274,6 +275,37 @@ test_stale_lock_recovery_preserves_afk_and_need_gates() { pass "auto-arm: stale-owner recovery leaves the AFK and supervision-need gates unchanged" } +test_resolves_outermost_claude_pid_in_nested_bgspare_chain() { + local dir out status inner_pid lock_pid + dir=$(make_primary_dir "$TMP_ROOT/nested-chain") + : > "$dir/state/task.meta" + write_arm_fixture "$dir" actionable + # A genuine multi-level contiguous claude-named ancestry: the hook fires + # inside an inner fake-claude process (its recorded pid is distinct from its + # own parent, a second, outer fake-claude process holding the session lock - + # the bg-spare shape). Only the outer pid may own the lock; a + # first-match-wins walk would resolve to the inner pid instead and leave the + # hook inert. The inner process records its own pid before running the hook + # so bash cannot tail-exec-collapse it into the outer pid, which would + # collapse the two-hop chain this test depends on down to one hop. + out=$(printf '%s\n' '{"session_id":"nested"}' \ + | FM_HOME="$dir" "$FAKE_CLAUDE" -c ' + printf "%s\n" "$$" > "$FM_HOME/state/.lock" + "$FAKE_CLAUDE" -c " + printf \"%s\n\" \"\$\$\" > \"\$FM_HOME/state/inner-pid\" + \"\$FM_HOME/bin/fm-claude-stop-autoarm.sh\" + " + ' 2>&1); status=$? + inner_pid=$(cat "$dir/state/inner-pid" 2>/dev/null || true) + lock_pid=$(cat "$dir/state/.lock" 2>/dev/null || true) + [ -n "$inner_pid" ] && [ "$inner_pid" != "$lock_pid" ] \ + || fail "test setup did not produce a genuine two-hop claude chain: inner=$inner_pid lock=$lock_pid" + expect_code 2 "$status" "a nested contiguous claude ancestry must resolve to the outer lock-owning pid and arm" + [ -e "$dir/state/arm-ran" ] || fail "hook did not resolve past the inner claude-named process to the outer lock owner" + [ "$(epoch_outcome "$dir")" = rewake ] || fail "nested-chain arm must record outcome=rewake" + pass "auto-arm: resolves the outermost pid of a nested contiguous claude ancestry (bg-spare chain)" +} + test_inert_when_fleet_idle() { local dir out status dir=$(make_primary_dir "$TMP_ROOT/idle") @@ -413,6 +445,7 @@ test_reclaims_stale_session_lock_before_arming test_inert_when_lock_held_by_other_harness test_inert_when_afk test_stale_lock_recovery_preserves_afk_and_need_gates +test_resolves_outermost_claude_pid_in_nested_bgspare_chain test_inert_when_fleet_idle test_actionable_close_rewakes_with_reason test_failed_close_rewakes_with_failure_banner From a323c2bf295cba7ff31c90771f466d71ad980924 Mon Sep 17 00:00:00 2001 From: Unknownzed <45267749+Unknownzed@users.noreply.github.com> Date: Wed, 29 Jul 2026 05:17:12 +0200 Subject: [PATCH 08/70] fix: conferma l'avvio del watcher su Windows/MSYS (#1212) * fix: confirm watcher startup on MSYS * no-mistakes(review): gate MSYS arm ready timeout, cache uname, harden locale test * no-mistakes(review): validate OpenCode ready timeout, make uname cache internal --- .opencode/plugins/fm-primary-watch-arm.js | 6 +- .pi/extensions/fm-primary-pi-watch.ts | 8 +- bin/fm-wake-lib.sh | 18 +++-- bin/fm-watch-arm.sh | 8 +- docs/configuration.md | 6 +- tests/fm-watcher-lock.test.sh | 94 ++++++++++++++++++----- 6 files changed, 110 insertions(+), 30 deletions(-) diff --git a/.opencode/plugins/fm-primary-watch-arm.js b/.opencode/plugins/fm-primary-watch-arm.js index 8b98340cfa..433edb80ab 100644 --- a/.opencode/plugins/fm-primary-watch-arm.js +++ b/.opencode/plugins/fm-primary-watch-arm.js @@ -4,7 +4,11 @@ import { resolve } from "node:path"; import { encodeFirstmateOperationalInput } from "./lib/fm-operational-input.js"; const COORDINATOR_KEY = "__firstmateOpenCodeWatchArm"; -const ARM_READY_TIMEOUT_MS = Number(process.env.FM_OPENCODE_ARM_READY_TIMEOUT_MS || 12000); +// 35s on Windows so the budget stays above arm's MSYS confirm default (30s in +// bin/fm-watch-arm.sh): a slow but successful Git Bash cold start must not be +// SIGTERMed mid-confirmation. Conditioned on win32 so other platforms keep 12s. +const ARM_READY_TIMEOUT_DEFAULT_MS = process.platform === "win32" ? 35000 : 12000; +const ARM_READY_TIMEOUT_MS = positiveInteger("FM_OPENCODE_ARM_READY_TIMEOUT_MS", ARM_READY_TIMEOUT_DEFAULT_MS); const ARM_RETIRE_TIMEOUT_MS = positiveInteger("FM_WATCH_ARM_RETIRE_TIMEOUT_MS", 1000); const REARM_RETRY_BASE_MS = positiveInteger("FM_WATCH_REARM_RETRY_BASE_MS", 250); const REARM_RETRY_MAX_MS = positiveInteger("FM_WATCH_REARM_RETRY_MAX_MS", 4000); diff --git a/.pi/extensions/fm-primary-pi-watch.ts b/.pi/extensions/fm-primary-pi-watch.ts index 92c39e4fd2..9d5124aff2 100644 --- a/.pi/extensions/fm-primary-pi-watch.ts +++ b/.pi/extensions/fm-primary-pi-watch.ts @@ -88,7 +88,13 @@ const extensionVersion = `sha256:${createHash("sha256").update(readFileSync(exte const retryBaseMs = positiveInteger("FM_WATCH_REARM_RETRY_BASE_MS", 250); const retryMaxMs = positiveInteger("FM_WATCH_REARM_RETRY_MAX_MS", 4000); const retryLimit = positiveInteger("FM_WATCH_REARM_RETRY_LIMIT", 5); -const armReadyTimeoutMs = positiveInteger("FM_PI_ARM_READY_TIMEOUT_MS", 12000); +// 35s on Windows so the budget stays above arm's MSYS confirm default (30s in +// bin/fm-watch-arm.sh): a slow but successful Git Bash cold start must not be +// SIGTERMed mid-confirmation. Conditioned on win32 so other platforms keep 12s. +const armReadyTimeoutMs = positiveInteger( + "FM_PI_ARM_READY_TIMEOUT_MS", + process.platform === "win32" ? 35000 : 12000, +); const armRetireTimeoutMs = positiveInteger("FM_WATCH_ARM_RETIRE_TIMEOUT_MS", 1000); const repairOnlyHint = "call fm_watch_arm_pi again only after a later notification says the cycle is missing, failed, or unhealthy"; const shuttingDownMessage = "watcher: not armed - Pi session is shutting down"; diff --git a/bin/fm-wake-lib.sh b/bin/fm-wake-lib.sh index 929c7231a4..8cec58bec1 100755 --- a/bin/fm-wake-lib.sh +++ b/bin/fm-wake-lib.sh @@ -9,6 +9,10 @@ STATE="${FM_STATE_OVERRIDE:-${STATE:-$FM_HOME/state}}" FM_WAKE_QUEUE="${FM_WAKE_QUEUE:-$STATE/.wake-queue}" FM_WAKE_QUEUE_LOCK="${FM_WAKE_QUEUE_LOCK:-$STATE/.wake-queue.lock}" FM_LOCK_STALE_AFTER="${FM_LOCK_STALE_AFTER:-2}" +# Resolved once at source time: fm_pid_identity and fm_path_mtime run inside 0.2s +# confirm and 0.5s attach polls, and forking uname per call is a measurable cost on +# the platform (Git Bash/MSYS) that already pays the highest fork price. +_FM_UNAME=$(uname 2>/dev/null || echo unknown) mkdir -p "$STATE" fm_current_pid() { @@ -24,17 +28,19 @@ fm_pid_alive() { } fm_pid_identity() { - local pid=$1 out proc_root stat_line starttime cmdline_hex + local pid=$1 out proc_root stat_line starttime cmdline_hex identity_key local -a stat_fields case "$pid" in ''|*[!0-9]*) return 1 ;; esac proc_root=${FM_PROC_ROOT_OVERRIDE:-/proc} - # Prefer /proc on Linux: stat field 22 (starttime, clock ticks since boot) is + # Prefer a Linux-compatible /proc when present: stat field 22 (starttime, clock ticks since boot) is # immune to the wall-clock steps that re-render the ps lstart fallback's date # (observed as WSL2 btime drift) and would evict a live watcher; combining the # full NUL-separated cmdline keeps PID reuse a mismatch even on a tick collision. - if [ "$(uname)" = Linux ] && [ -r "$proc_root/$pid/stat" ] && [ -r "$proc_root/$pid/cmdline" ]; then + # Git Bash/MSYS exposes these compatible files but its Cygwin ps rejects the + # portable fallback's -o fields, so capability detection must not key on uname. + if [ -r "$proc_root/$pid/stat" ] && [ -r "$proc_root/$pid/cmdline" ]; then stat_line=$(cat "$proc_root/$pid/stat" 2>/dev/null) || return 1 # After the final comm delimiter, array index 19 is proc stat field 22. read -r -a stat_fields <<< "${stat_line##*)}" @@ -45,7 +51,9 @@ fm_pid_identity() { esac cmdline_hex=$(od -An -v -tx1 "$proc_root/$pid/cmdline" 2>/dev/null | tr -d '[:space:]') || return 1 [ -n "$cmdline_hex" ] || return 1 - printf 'linux-starttime=%s cmdline-hex=%s\n' "$starttime" "$cmdline_hex" + identity_key=proc-starttime + [ "$_FM_UNAME" != Linux ] || identity_key=linux-starttime + printf '%s=%s cmdline-hex=%s\n' "$identity_key" "$starttime" "$cmdline_hex" return 0 fi # Pin LC_ALL=C so lstart's date format is locale-invariant: the identity is @@ -57,7 +65,7 @@ fm_pid_identity() { } fm_path_mtime() { - if [ "$(uname)" = Darwin ]; then + if [ "$_FM_UNAME" = Darwin ]; then stat -f %m "$1" 2>/dev/null else stat -c %Y "$1" 2>/dev/null diff --git a/bin/fm-watch-arm.sh b/bin/fm-watch-arm.sh index 0a783ce287..3c2df49c89 100755 --- a/bin/fm-watch-arm.sh +++ b/bin/fm-watch-arm.sh @@ -68,7 +68,13 @@ BEAT="$STATE/.last-watcher-beat" # "Fresh" reuses the guard's threshold so there is one definition of liveness. GRACE=${FM_GUARD_GRACE:-300} # How long to wait for a freshly forked watcher to acquire the lock and beat. -CONFIRM_TIMEOUT=${FM_ARM_CONFIRM_TIMEOUT:-10} +# Git Bash/MSYS pays a much higher fork cost while the watcher completes its +# required pre-lock migration, so its bounded default covers that cold start. +case "${OSTYPE:-}" in + msys*|mingw*|cygwin*) ARM_CONFIRM_DEFAULT=30 ;; + *) ARM_CONFIRM_DEFAULT=10 ;; +esac +CONFIRM_TIMEOUT=${FM_ARM_CONFIRM_TIMEOUT:-$ARM_CONFIRM_DEFAULT} # Poll interval while attached to an existing healthy watcher. ATTACH_POLL=${FM_ARM_ATTACH_POLL:-0.5} CYCLE_LOG="$STATE/.watch-cycle-exits.log" diff --git a/docs/configuration.md b/docs/configuration.md index 642d750183..c5215512b2 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -416,10 +416,10 @@ FM_GUARD_GRACE=300 # seconds before guard warnings, arm health checks, and FM_CLAUDE_AUTOARM_SYNC_WAIT_MS=800 # milliseconds the --claude turn-end guard waits for the Stop auto-arm's claim, health, or fresh rewake epoch before re-blocking FM_CLAUDE_AUTOARM_EPOCH_FRESH=15 # seconds a recorded auto-arm rewake outcome counts as this event epoch's owned recovery FM_CLAUDE_TURNEND_BLOCK_BUDGET=3 # consecutive --claude guard re-blocks before a degraded allow; safely below Claude Code's 8-block override -FM_ARM_CONFIRM_TIMEOUT=10 # seconds fm-watch-arm waits to confirm a fresh watcher before reporting FAILED +FM_ARM_CONFIRM_TIMEOUT=10 # seconds fm-watch-arm waits to confirm a fresh watcher before reporting FAILED; default 30 on Git Bash/MSYS FM_ARM_ATTACH_POLL=0.5 # seconds between checks while fm-watch-arm is attached to an existing healthy watcher cycle -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_PI_ARM_READY_TIMEOUT_MS=12000 # milliseconds the Pi watcher extension waits for a successor arm to report started or attached +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; default 35000 on Windows to stay above the MSYS confirm budget +FM_PI_ARM_READY_TIMEOUT_MS=12000 # milliseconds the Pi watcher extension waits for a successor arm to report started or attached; default 35000 on Windows to stay above the MSYS confirm budget FM_WATCH_ARM_RETIRE_TIMEOUT_MS=1000 # milliseconds Pi/OpenCode wait for an unready successor arm to exit before abandoning retries FM_WATCH_REARM_RETRY_BASE_MS=250 # Pi/OpenCode adapter base delay for continuity restoration retries FM_WATCH_REARM_RETRY_MAX_MS=4000 # Pi/OpenCode adapter cap for exponential continuity retry delay diff --git a/tests/fm-watcher-lock.test.sh b/tests/fm-watcher-lock.test.sh index 640e913318..e741ec21e8 100755 --- a/tests/fm-watcher-lock.test.sh +++ b/tests/fm-watcher-lock.test.sh @@ -901,18 +901,54 @@ test_pid_identity_is_locale_invariant() { # fm_pid_identity, so its output must be byte-identical regardless of the caller's # exported LC_ALL/LC_TIME. This stays deterministic on CI even where an alternate # locale like ko_KR.UTF-8 is not installed (the equality then holds trivially). - local live no_proc baseline via_lc_all via_lc_time + local live no_proc fakebin locale_log baseline via_lc_all via_lc_time + local real_first real_second observed sleep 300 & live=$! no_proc="$TMP_ROOT/no-proc" - baseline=$(FM_PROC_ROOT_OVERRIDE="$no_proc" LC_ALL=C bash -c '. "$1"; fm_pid_identity "$2"' _ "$LIB" "$live" 2>/dev/null) - via_lc_all=$(FM_PROC_ROOT_OVERRIDE="$no_proc" LC_ALL=ko_KR.UTF-8 bash -c '. "$1"; fm_pid_identity "$2"' _ "$LIB" "$live" 2>/dev/null) - via_lc_time=$(FM_PROC_ROOT_OVERRIDE="$no_proc" LC_TIME=ko_KR.UTF-8 bash -c 'unset LC_ALL; . "$1"; fm_pid_identity "$2"' _ "$LIB" "$live" 2>/dev/null) + fakebin="$TMP_ROOT/locale-ps" + locale_log="$TMP_ROOT/locale-ps.observed" + mkdir -p "$fakebin" + : > "$locale_log" + # The stub renders lstart through date under whatever locale it inherits, so its + # output really does change when the caller's locale leaks through. Dropping the + # LC_ALL=C pin in fm_pid_identity therefore breaks the equality assertions below + # on any host with a second locale installed, and the recorded LC_ALL below keeps + # the pin asserted even where ko_KR.UTF-8 is missing and date falls back to C. + cat > "$fakebin/ps" <<'SH' +#!/usr/bin/env bash +printf '%s\n' "${LC_ALL-}" >> "$FAKE_PS_LOCALE_LOG" +stamp=$(date -d @1784094040 '+%a %b %e %H:%M:%S %Y' 2>/dev/null) \ + || stamp=$(date -r 1784094040 '+%a %b %e %H:%M:%S %Y' 2>/dev/null) \ + || stamp='Mon Jul 28 20:00:00 2026' +printf '%s sleep 300\n' "$stamp" +SH + chmod +x "$fakebin/ps" + baseline=$(PATH="$fakebin:$PATH" FAKE_PS_LOCALE_LOG="$locale_log" FM_PROC_ROOT_OVERRIDE="$no_proc" LC_ALL=C bash -c '. "$1"; fm_pid_identity "$2"' _ "$LIB" "$live" 2>/dev/null) + via_lc_all=$(PATH="$fakebin:$PATH" FAKE_PS_LOCALE_LOG="$locale_log" FM_PROC_ROOT_OVERRIDE="$no_proc" LC_ALL=ko_KR.UTF-8 bash -c '. "$1"; fm_pid_identity "$2"' _ "$LIB" "$live" 2>/dev/null) + via_lc_time=$(PATH="$fakebin:$PATH" FAKE_PS_LOCALE_LOG="$locale_log" FM_PROC_ROOT_OVERRIDE="$no_proc" LC_TIME=ko_KR.UTF-8 bash -c 'unset LC_ALL; . "$1"; fm_pid_identity "$2"' _ "$LIB" "$live" 2>/dev/null) + # Keep the real ps fallback exercised wherever it supports the portable -o fields. + real_first= + real_second= + if LC_ALL=C ps -p "$live" -o lstart= -o command= >/dev/null 2>&1; then + real_first=$(FM_PROC_ROOT_OVERRIDE="$no_proc" LC_ALL=C bash -c '. "$1"; fm_pid_identity "$2"' _ "$LIB" "$live" 2>/dev/null) + real_second=$(FM_PROC_ROOT_OVERRIDE="$no_proc" LC_TIME=ko_KR.UTF-8 bash -c 'unset LC_ALL; . "$1"; fm_pid_identity "$2"' _ "$LIB" "$live" 2>/dev/null) + fi kill "$live" 2>/dev/null || true wait "$live" 2>/dev/null || true [ -n "$baseline" ] || fail "fm_pid_identity produced no baseline identity under LC_ALL=C" [ "$via_lc_all" = "$baseline" ] || fail "fm_pid_identity varied with exported LC_ALL (got '$via_lc_all', want '$baseline')" [ "$via_lc_time" = "$baseline" ] || fail "fm_pid_identity varied with exported LC_TIME (got '$via_lc_time', want '$baseline')" + while read -r observed; do + [ "$observed" = C ] || fail "fm_pid_identity invoked ps without pinning LC_ALL=C (saw '$observed')" + done < "$locale_log" + if [ -n "$real_first" ]; then + [ "$real_second" = "$real_first" ] \ + || fail "real ps fallback varied with exported LC_TIME (got '$real_second', want '$real_first')" + pass "fm_pid_identity real ps fallback is locale-invariant" + else + pass "real ps fallback locale check skipped where ps -o lstart= is unsupported" + fi pass "fm_pid_identity is locale-invariant across LC_ALL/LC_TIME" } @@ -923,16 +959,14 @@ write_fake_proc_identity() { printf 'bash\0/path with spaces/fm-watch.sh\0--flag\0' > "$proc_root/$pid/cmdline" } -test_linux_pid_identity_ignores_wall_clock_and_detects_pid_reuse() { - local dir state proc_root pid before after_time_jump after_pid_reuse - [ "$(uname)" = Linux ] || { - pass "Linux process identity clock-step regression skipped on non-Linux host" - return - } - dir=$(make_case linux-pid-identity) +test_proc_pid_identity_ignores_wall_clock_and_detects_pid_reuse() { + local dir state proc_root pid identity_key before after_time_jump after_pid_reuse + dir=$(make_case proc-pid-identity) state="$dir/state" proc_root="$dir/proc" pid=4242 + identity_key=proc-starttime + [ "$(uname)" != Linux ] || identity_key=linux-starttime mkdir -p "$proc_root" printf 'btime 1784094040\n' > "$proc_root/stat" write_fake_proc_identity "$proc_root" "$pid" 987654 @@ -944,21 +978,43 @@ test_linux_pid_identity_ignores_wall_clock_and_detects_pid_reuse() { || fail "could not re-read fake Linux process identity after btime change" [ "$after_time_jump" = "$before" ] \ - || fail "Linux process identity changed with btime (before '$before', after '$after_time_jump')" - [ "$before" = 'linux-starttime=987654 cmdline-hex=62617368002f706174682077697468207370616365732f666d2d77617463682e7368002d2d666c616700' ] \ - || fail "Linux process identity did not combine parsed starttime field 22 with the full cmdline ('$before')" - pass "Linux process identity ignores simulated btime changes" + || fail "/proc process identity changed with btime (before '$before', after '$after_time_jump')" + [ "$before" = "$identity_key=987654 cmdline-hex=62617368002f706174682077697468207370616365732f666d2d77617463682e7368002d2d666c616700" ] \ + || fail "/proc process identity did not combine parsed starttime field 22 with the full cmdline ('$before')" + pass "/proc process identity ignores simulated btime changes" write_fake_proc_identity "$proc_root" "$pid" 987655 after_pid_reuse=$(FM_PROC_ROOT_OVERRIDE="$proc_root" FM_STATE_OVERRIDE="$state" bash -c '. "$1"; fm_pid_identity "$2"' _ "$LIB" "$pid") \ - || fail "could not read reused fake Linux pid identity" - [ "$after_pid_reuse" != "$before" ] || fail "Linux process identity missed changed starttime for reused pid" - pass "Linux process identity detects pid reuse" + || fail "could not read reused fake /proc pid identity" + [ "$after_pid_reuse" != "$before" ] || fail "/proc process identity missed changed starttime for reused pid" + pass "/proc process identity detects pid reuse" +} + +test_msys_pid_identity_uses_proc() { + local live identity + case "$(uname)" in + MSYS*|MINGW*|CYGWIN*) ;; + *) + pass "MSYS /proc process identity regression skipped on non-Windows host" + return + ;; + esac + sleep 300 & + live=$! + identity=$(bash -c '. "$1"; fm_pid_identity "$2"' _ "$LIB" "$live" 2>/dev/null) + kill "$live" 2>/dev/null || true + wait "$live" 2>/dev/null || true + case "$identity" in + proc-starttime=*" cmdline-hex="*) ;; + *) fail "MSYS process identity did not use compatible /proc fields ('$identity')" ;; + esac + pass "MSYS process identity uses compatible /proc fields" } test_singleton_start test_pid_identity_is_locale_invariant -test_linux_pid_identity_ignores_wall_clock_and_detects_pid_reuse +test_proc_pid_identity_ignores_wall_clock_and_detects_pid_reuse +test_msys_pid_identity_uses_proc test_stale_watch_lock_reclaimed test_live_stale_watch_lock_is_actionable test_guard_warnings From 41ffd4505c0d9a9f2eba0eee2a396e7c582723e7 Mon Sep 17 00:00:00 2001 From: lhalbert Date: Tue, 28 Jul 2026 23:18:02 -0400 Subject: [PATCH 09/70] fix(spawn): forward CLAUDE_CONFIG_DIR to claude crewmates (#1195) * fix(spawn): forward firstmate's CLAUDE_CONFIG_DIR to claude crewmates Crewmate panes are created by a long-lived tmux/herdr daemon that does not inherit firstmate's current environment. When firstmate runs under a non-default CLAUDE_CONFIG_DIR (for example a work-vs-personal subscription split), a bare `claude` in the crewmate pane fell back to the default ~/.claude store and launched unauthenticated, blocking the crewmate before it could do any work. fm-spawn now prefixes the claude launch with firstmate's own resolved CLAUDE_CONFIG_DIR when set, so the crewmate uses the same credential/config store firstmate is authenticated with. An unset value is the single-store default and adds no prefix; non-claude harnesses are unaffected. Adds three tests in fm-spawn-dispatch-profile.test.sh (forwarded-when-set, omitted-when-unset, non-claude-ignored) and pins CLAUDE_CONFIG_DIR in the test helper so launch assertions no longer depend on the developer's environment. * no-mistakes: apply CI fixes --- bin/fm-spawn.sh | 10 +++++ tests/fm-spawn-dispatch-profile.test.sh | 57 +++++++++++++++++++++++++ 2 files changed, 67 insertions(+) diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index 98273f704e..76eed3e736 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -1491,6 +1491,16 @@ LAUNCH=${LAUNCH//__PIEXT__/$sq_piext} LAUNCH=${LAUNCH//__PITURNEND__/$sq_piturnend} LAUNCH=${LAUNCH//__PIWATCH__/$sq_piwatch} LAUNCH=${LAUNCH//__OPINPUT__/$sq_opinput} +# Crewmate panes are created by a long-lived tmux/herdr daemon that does not +# inherit firstmate's current environment, so a bare `claude` in the pane falls +# back to the default ~/.claude store even when firstmate itself runs under a +# different CLAUDE_CONFIG_DIR (for example a work-vs-personal subscription split). +# Forward firstmate's own resolved store onto the claude launch so the crewmate +# uses the same credential/config firstmate is authenticated with. Only when set; +# an unset value is the single-store default and needs no prefix. +if [ "$HARNESS" = claude ] && [ -n "${CLAUDE_CONFIG_DIR:-}" ]; then + LAUNCH="CLAUDE_CONFIG_DIR=$(shell_quote "$CLAUDE_CONFIG_DIR") $LAUNCH" +fi if [ "$KIND" = secondmate ]; then sq_home=$(shell_quote "$PROJ_ABS") LAUNCH="FM_ROOT_OVERRIDE= FM_STATE_OVERRIDE= FM_DATA_OVERRIDE= FM_PROJECTS_OVERRIDE= FM_CONFIG_OVERRIDE= FM_HOME=$sq_home $LAUNCH" diff --git a/tests/fm-spawn-dispatch-profile.test.sh b/tests/fm-spawn-dispatch-profile.test.sh index 3a8dfb3a4e..4f0695e7e5 100755 --- a/tests/fm-spawn-dispatch-profile.test.sh +++ b/tests/fm-spawn-dispatch-profile.test.sh @@ -84,10 +84,15 @@ run_spawn() { local home=$1 wt=$2 fakebin=$3 launchlog=$4 shift 4 : > "$launchlog" + # CLAUDE_CONFIG_DIR is forwarded onto claude launches by fm-spawn, so pin it + # explicitly (empty by default) instead of leaking the invoking shell's value, + # which would make launch assertions depend on the developer's environment. + # A test opts in to the set case via FM_TEST_CLAUDE_CONFIG_DIR. 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="$wt" TMUX="fake,1,0" \ + CLAUDE_CONFIG_DIR="${FM_TEST_CLAUDE_CONFIG_DIR:-}" \ FM_FAKE_LAUNCH_LOG="$launchlog" GROK_HOME="$home/grok-home" PATH="$fakebin:$PATH" \ "$SPAWN" "$@" 2>&1 } @@ -436,6 +441,55 @@ test_batch_forwards_shared_profile_flags() { pass "batch dispatch forwards shared --harness, --model, and --effort to every pair" } +test_claude_forwards_firstmate_config_dir_when_set() { + local rec id out status launch + id=profile-claude-cfgdir-z17 + rec=$(make_spawn_case profile-claude-cfgdir claude "$id") + read_case_record "$rec" + + out=$(FM_TEST_CLAUDE_CONFIG_DIR="/opt/test/claude-work" \ + run_spawn "$HOME_DIR" "$WT_DIR" "$FAKEBIN_DIR" "$LAUNCH_LOG" "$id" "$PROJ_DIR") + status=$? + expect_code 0 "$status" "claude spawn with CLAUDE_CONFIG_DIR set should succeed" + launch=$(cat "$LAUNCH_LOG") + assert_contains "$launch" "CLAUDE_CONFIG_DIR='/opt/test/claude-work' CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false claude" \ + "claude launch did not forward firstmate's CLAUDE_CONFIG_DIR to the crewmate pane" + pass "claude forwards firstmate's CLAUDE_CONFIG_DIR so the crewmate uses the same credential store" +} + +test_claude_omits_config_dir_prefix_when_unset() { + local rec id out status launch + id=profile-claude-nocfgdir-z18 + rec=$(make_spawn_case profile-claude-nocfgdir claude "$id") + read_case_record "$rec" + + # run_spawn pins CLAUDE_CONFIG_DIR empty by default, exercising the single-store + # default path where fm-spawn adds no prefix. + out=$(run_spawn "$HOME_DIR" "$WT_DIR" "$FAKEBIN_DIR" "$LAUNCH_LOG" "$id" "$PROJ_DIR") + status=$? + expect_code 0 "$status" "claude spawn without CLAUDE_CONFIG_DIR should succeed" + launch=$(cat "$LAUNCH_LOG") + assert_not_contains "$launch" "CLAUDE_CONFIG_DIR=" \ + "claude launch must not add a config-dir prefix when firstmate has no CLAUDE_CONFIG_DIR set" + pass "claude omits the config-dir prefix when firstmate runs with the single-store default" +} + +test_non_claude_harness_ignores_config_dir() { + local rec id out status launch + id=profile-codex-nocfgdir-z19 + rec=$(make_spawn_case profile-codex-nocfgdir codex "$id") + read_case_record "$rec" + + out=$(FM_TEST_CLAUDE_CONFIG_DIR="/opt/test/claude-work" \ + run_spawn "$HOME_DIR" "$WT_DIR" "$FAKEBIN_DIR" "$LAUNCH_LOG" "$id" "$PROJ_DIR") + status=$? + expect_code 0 "$status" "codex spawn with CLAUDE_CONFIG_DIR set should succeed" + launch=$(cat "$LAUNCH_LOG") + assert_not_contains "$launch" "CLAUDE_CONFIG_DIR=" \ + "non-claude harness launch must not receive the claude-specific config-dir prefix" + pass "non-claude harnesses do not receive the claude CLAUDE_CONFIG_DIR prefix" +} + test_active_dispatch_profile_does_not_block_secondmate_launch() { local rec id sm out status id=profile-secondmate-z16 @@ -472,6 +526,9 @@ test_pi_signed_threads_shared_pi_profile_and_preserves_identity test_pi_signed_missing_binary_refuses_before_endpoint_or_metadata test_pi_signed_persistent_secondmate_uses_pi_extensions_and_identity test_batch_forwards_shared_profile_flags +test_claude_forwards_firstmate_config_dir_when_set +test_claude_omits_config_dir_prefix_when_unset +test_non_claude_harness_ignores_config_dir test_active_dispatch_profile_does_not_block_secondmate_launch echo "# all fm-spawn-dispatch-profile tests passed" From 99533c5d7d3702050e6084429dddff6ea4fe1aa0 Mon Sep 17 00:00:00 2001 From: Kun Chen <3233006+kunchenguid@users.noreply.github.com> Date: Tue, 28 Jul 2026 23:12:43 -0700 Subject: [PATCH 10/70] fix: preserve dispatch identity across authentication checks (#1233) * fix: preserve dispatch harness identity * no-mistakes(review): Fix Grok counterfactual tuple validation * no-mistakes(document): Scope dispatch authentication to selected tuple * fix: restore dispatch instruction budget * no-mistakes(review): Scope dispatch authentication after candidate selection --- .agents/skills/harness-adapters/SKILL.md | 2 + .agents/skills/quota-array-dispatch/SKILL.md | 36 +++---- .../fixtures/quota-array-dispatch/cases.json | 42 +++++++++ tests/fm-quota-array-dispatch.test.sh | 94 +++++++++++++++++-- 4 files changed, 151 insertions(+), 23 deletions(-) diff --git a/.agents/skills/harness-adapters/SKILL.md b/.agents/skills/harness-adapters/SKILL.md index 429907041a..0ae4ee05b5 100644 --- a/.agents/skills/harness-adapters/SKILL.md +++ b/.agents/skills/harness-adapters/SKILL.md @@ -127,6 +127,8 @@ The supported launch-profile flags below are verified locally; each row records | opencode | `--model ` | none for firstmate's interactive launch | Verified on opencode 1.17.6. `opencode run` has `--variant`, but firstmate launches the interactive `opencode --prompt` path, which has no verified effort flag. | | kimi | `--model ` | none | Verified 2026-07-25 on Kimi Code CLI 0.29.1. | +The concrete `harness` field owns adapter identity independently of the model provider: `harness=pi` with `model=xai/grok-*` is Pi using xAI, not `harness=grok`, and does not require Grok CLI login; `harness=grok` remains the standalone Grok Build CLI adapter. + ### Model support discovery Treat model and provider knowledge as current source-of-truth discovery, not as a permanent namespace or provider mapping. diff --git a/.agents/skills/quota-array-dispatch/SKILL.md b/.agents/skills/quota-array-dispatch/SKILL.md index d9de90ffba..c384553a85 100644 --- a/.agents/skills/quota-array-dispatch/SKILL.md +++ b/.agents/skills/quota-array-dispatch/SKILL.md @@ -20,16 +20,16 @@ Do not add a daemon, opaque composite score, routing wrapper, hard-coded model-s ## Collect facts Run `quota-axi --json` once per intake and reuse that snapshot for every candidate. -For each candidate, establish the harness/model/provider relationship from `harness-adapters`, then record only inspectable facts: +For each candidate, preserve explicit `harness`, `model`, and `provider`; `harness-adapters` owns identity, and model/provider never infer harness: - task/profile fit and required reasoning class -- raw applicable headroom (`effectivePercentRemaining` or the tightest applicable remaining percentage) -- effective pace status, signed reserve per applicable window, and worst applicable reserve (`worstReservePercentPoints` when present, else the minimum signed reserve) -- whether any applicable window or effective summary is ahead of reset, or any applicable pace is `unknown` +- raw applicable headroom (`effectivePercentRemaining` or tightest applicable percentage) +- effective pace, signed reserve per window, and worst reserve (`worstReservePercentPoints` or minimum signed reserve) +- whether applicable windows/summary are ahead, or pace is `unknown` - schema note when pace fields are absent -Stale raw windows are diagnostic only, never current headroom. -Read every bounding window named by `boundedBy`, `limitingWindowIds`, `aheadWindowIds`, `behindWindowIds`, `onPaceWindowIds`, and `unknownWindowIds`. +Stale raw windows are diagnostic, never headroom. +Read all windows named by `boundedBy`, `limitingWindowIds`, `aheadWindowIds`, `behindWindowIds`, `onPaceWindowIds`, and `unknownWindowIds`. ## Pace semantics @@ -37,27 +37,29 @@ Read every bounding window named by `boundedBy`, `limitingWindowIds`, `aheadWind Negative reserve means usage is ahead of reset pace and creates conservation pressure. Positive reserve means usage is behind reset pace. `on_pace` is neutral. -Conservation pressure is present when effective pace status is `ahead`, effective pace status is `mixed` and any `aheadWindowIds` remain, or any applicable bounding window itself has pace status `ahead`. -`unknown` is valid explicit uncertainty from quota-axi, not a parser failure and not permission to assume the window is healthy or exhausted. +Conservation pressure is present for effective pace status `ahead`, effective pace status is `mixed` and any `aheadWindowIds` remain, or a bounding window is `ahead`. +`unknown` is valid explicit uncertainty from quota-axi, not parser failure or permission to assume health. ## Selection order -Apply only among candidates that already satisfy required fit and the strongest reasoning class the request needs. +Apply only among candidates satisfying required fit and strongest reasoning class. Never use pace or raw headroom to silently replace that reasoning class. -1. Unresolved relationship or quota data: stop and report the blocked candidate. -2. All-tight: keep the strongest-reasoning class; dispatch inside it or stop and report that the tight choice cannot proceed. -3. When fit and reasoning are comparable, prefer a candidate without ahead-of-reset conservation pressure over one with conservation pressure, even when the pressured candidate has somewhat higher raw remaining percentage. +1. Unresolved relationship or quota: stop and report the tuple and concrete evidence. +2. All-tight: keep strongest reasoning; dispatch inside it or report if blocked. +3. Comparable fit/reasoning: prefer no ahead pressure over pressure, even with higher raw headroom. 4. Among pressured candidates, prefer the least-negative worst applicable reserve. -5. Among sustainable candidates, use known behind/on-pace evidence plus raw headroom transparently. - Prefer known sustainable evidence over `unknown` pace when otherwise comparable. +5. Sustainable candidates: use known pace plus raw headroom. + Prefer known sustainable evidence over `unknown` when comparable. Do not collapse those facts into an opaque composite score. -6. If the dispatch choice materially hinges on unresolved pace, report the uncertainty rather than inventing a conclusion. -7. Absent pace or older schema: do not crash, fabricate pace, or silently reinterpret absence as healthy/`on_pace`. - Compare raw applicable headroom only, state that pace is unavailable, and keep every other safety rule. +6. If unresolved pace changes the choice, report uncertainty. +7. Absent pace or older schema: do not crash, fabricate pace, or treat absence as healthy/`on_pace`. + Compare raw headroom only, state pace is unavailable, and keep safety rules. 8. Genuine ties: stop and report every tied candidate for captain choice. Do not select by array order, harness name, or another arbitrary identity ordering. Report duplicate concrete profiles as a configuration error. Name the inspectable facts used for every candidate. +After selecting, check auth only through that tuple's surface; another harness CLI cannot block it. +A blocked credential report must name `harness`, `model`, authentication surface, and concrete failure evidence; never emit a bare `Grok unauthenticated` statement. Never conclude with an unexplained "best quota" label. diff --git a/tests/fixtures/quota-array-dispatch/cases.json b/tests/fixtures/quota-array-dispatch/cases.json index 23d097be46..c6fc3c3a86 100644 --- a/tests/fixtures/quota-array-dispatch/cases.json +++ b/tests/fixtures/quota-array-dispatch/cases.json @@ -199,6 +199,48 @@ } ] }, + { + "id": "select-pi-xai-before-authentication", + "expect": "pi-xai", + "reason": "an unauthenticated standalone Grok candidate cannot block selected authenticated Pi/xAI", + "candidates": [ + { + "id": "pi-xai", + "harness": "pi", + "model": "xai/grok-4.5", + "provider": "xai", + "authenticationSurface": "Pi xAI OAuth", + "authAvailable": true, + "fit": "comparable", + "reasoningClass": "strong", + "tight": false, + "rawHeadroom": 55, + "paceStatus": "behind", + "aheadWindowIds": [], + "worstReserve": 15.0, + "unknownPace": false, + "paceAvailable": true + }, + { + "id": "standalone-grok", + "harness": "grok", + "model": "grok-4.5", + "provider": "grok", + "authenticationSurface": "Grok Build CLI", + "authAvailable": false, + "authFailure": "Grok Build CLI login missing", + "fit": "comparable", + "reasoningClass": "strong", + "tight": false, + "rawHeadroom": 80, + "paceStatus": "ahead", + "aheadWindowIds": ["weekly"], + "worstReserve": -12.0, + "unknownPace": false, + "paceAvailable": true + } + ] + }, { "id": "all-tight-strongest-reasoning", "expect": "A", diff --git a/tests/fm-quota-array-dispatch.test.sh b/tests/fm-quota-array-dispatch.test.sh index 0c0d848fa0..4fb1328ba3 100755 --- a/tests/fm-quota-array-dispatch.test.sh +++ b/tests/fm-quota-array-dispatch.test.sh @@ -94,6 +94,14 @@ def select(case): "candidates": sorted(c["id"] for c in winners), } winner = winners[0] + if winner.get("authAvailable") is False: + return { + "error": "selected candidate authentication unavailable", + "harness": winner["harness"], + "model": winner["model"], + "authenticationSurface": winner["authenticationSurface"], + "failureEvidence": winner["authFailure"], + } return { "id": winner["id"], "pressured": conservation_pressure(winner), @@ -161,19 +169,21 @@ test_owner_contains_selection_procedure() { 'Positive reserve means usage is behind reset pace' \ '`on_pace` is neutral' \ 'effective pace status is `mixed` and any `aheadWindowIds` remain' \ - 'prefer a candidate without ahead-of-reset conservation pressure over one with conservation pressure' \ - 'even when the pressured candidate has somewhat higher raw remaining percentage' \ + 'Comparable fit/reasoning: prefer no ahead pressure over pressure' \ + 'even with higher raw headroom' \ 'prefer the least-negative worst applicable reserve' \ - 'use known behind/on-pace evidence plus raw headroom transparently' \ + 'Sustainable candidates: use known pace plus raw headroom' \ 'Do not collapse those facts into an opaque composite score' \ '`unknown` is valid explicit uncertainty from quota-axi' \ - 'Prefer known sustainable evidence over `unknown` pace when otherwise comparable' \ - 'If the dispatch choice materially hinges on unresolved pace, report the uncertainty' \ - 'do not crash, fabricate pace, or silently reinterpret absence as healthy' \ + 'Prefer known sustainable evidence over `unknown` when comparable' \ + 'If unresolved pace changes the choice, report uncertainty' \ + 'do not crash, fabricate pace, or treat absence as healthy' \ 'stop and report every tied candidate for captain choice' \ 'Do not select by array order, harness name, or another arbitrary identity ordering' \ 'Do not add a daemon, opaque composite score, routing wrapper, hard-coded model-specific policy' \ 'Report duplicate concrete profiles as a configuration error' \ + 'Unresolved relationship or quota: stop and report the tuple and concrete evidence' \ + 'After selecting, check auth only through that tuple'\''s surface' \ 'Name the inspectable facts used for every candidate'; do assert_grep "$phrase" "$OWNER" "quota-array-dispatch procedure lost '$phrase'" done @@ -269,6 +279,77 @@ elif got.get("id") != expect: done < <(python3 -c 'import json,sys; data=json.load(sys.stdin); [print(json.dumps(c, separators=(",", ":"))) for c in data["cases"]]' <<<"$raw") } +test_dispatch_identity_and_blocked_report() { + local reports + assert_grep '`harness-adapters` owns identity' "$OWNER" \ + "quota-array-dispatch does not point to the adapter identity owner" + assert_grep "After selecting, check auth only through that tuple's surface; another harness CLI cannot block it" "$OWNER" \ + "quota-array-dispatch does not scope evidence to the concrete tuple" + assert_no_grep 'Unresolved relationship, auth, or quota' "$OWNER" \ + "quota-array-dispatch checks authentication before selecting a candidate" + assert_grep 'A blocked credential report must name `harness`, `model`, authentication surface, and concrete failure evidence' "$OWNER" \ + "blocked reports do not preserve the minimum identity and evidence fields" + assert_grep 'The concrete `harness` field owns adapter identity independently of the model provider' "$HARNESS" \ + "harness-adapters lost the anti-conflation owner paragraph" + assert_grep '`harness=pi` with `model=xai/grok-*` is Pi using xAI, not `harness=grok`' "$HARNESS" \ + "harness-adapters lost the concrete Pi/xAI versus Grok distinction" + assert_grep 'does not require Grok CLI login' "$HARNESS" \ + "Pi/xAI guidance incorrectly requires Grok CLI login" + assert_no_grep '### Dispatch identity mapping' "$HARNESS" \ + "identity guidance grew a separate table instead of one owner paragraph" + + reports=$(python3 - <<'PY' + +def auth_surface(harness, model, provider): + surfaces = { + ("pi", "xai/grok-4.5", "xai"): "Pi xAI OAuth", + ("grok", "grok-4.5", "grok"): "Grok Build CLI", + } + try: + return surfaces[(harness, model, provider)] + except KeyError: + raise AssertionError("unresolved concrete profile") from None + +def evaluate(harness, model, provider, auth_available, failure): + surface = auth_surface(harness, model, provider) + if auth_available: + return (f"ready: harness={harness} model={model} provider={provider} " + f"authentication surface checked={surface}") + return (f"blocked: harness={harness} model={model} provider={provider} " + f"authentication surface checked={surface} failure evidence={failure}") + +pi = evaluate("pi", "xai/grok-4.5", "xai", True, None) +grok = evaluate("grok", "grok-4.5", "grok", False, "Grok Build CLI login missing") +assert "Grok Build CLI" not in pi +assert "Pi xAI OAuth" in pi +assert "Grok Build CLI" in grok +for mismatched in ( + ("grok", "xai/grok-4.5", "xai"), + ("pi", "grok-4.5", "grok"), +): + try: + auth_surface(*mismatched) + except AssertionError: + pass + else: + raise AssertionError(f"accepted mismatched profile: {mismatched}") +print(pi) +print(grok) +PY +) || fail "identity counterfactual fixture failed" + assert_contains "$reports" 'ready: harness=pi model=xai/grok-4.5 provider=xai authentication surface checked=Pi xAI OAuth' \ + "Pi/xAI did not remain dispatchable with its own authentication" + assert_not_contains "$reports" 'harness=pi model=xai/grok-4.5 provider=xai authentication surface checked=Grok Build CLI' \ + "Pi/xAI was reported with the standalone Grok CLI surface" + assert_contains "$reports" 'blocked: harness=grok model=grok-4.5 provider=grok authentication surface checked=Grok Build CLI' \ + "explicit Grok candidate did not use the Grok Build CLI surface" + for field in harness=grok model=grok-4.5 provider=grok 'authentication surface checked=Grok Build CLI' 'failure evidence=Grok Build CLI login missing'; do + assert_contains "$reports" "$field" "Grok blocked report lost '$field'" + done + printf '%s\n' "$reports" + pass "dispatch identity stays concrete across the Pi/xAI versus Grok counterfactual" +} + test_no_duplicate_procedure_in_agents() { # Guard against re-expanding the full procedure into AGENTS.md. local count @@ -284,4 +365,5 @@ test_owner_contains_selection_procedure test_cross_references_stay_pointers test_schema_v3_shape_fixture test_deterministic_acceptance_cases +test_dispatch_identity_and_blocked_report test_no_duplicate_procedure_in_agents From 6ec5e084e1b338fca2dd1ffb90173d705da97414 Mon Sep 17 00:00:00 2001 From: AG Date: Wed, 29 Jul 2026 16:40:31 -0600 Subject: [PATCH 11/70] fix(bin): normalize relative durable paths (#1256) * fix(bin): handle dash-leading harness process names (#2) * fix: handle dash-leading harness process names * no-mistakes(review): Make dash-leading harness regression hermetic * fix: preserve secondmate reply routes across relative homes Resolve relative home, data, and state inputs before durable charter generation, and fail when caller-relative directories cannot be resolved. Use absolute paths at the related spawn, AFK daemon, and X-mode cross-process handoffs so later processes cannot reinterpret them from another working directory. * no-mistakes(review): Preserve absolute overrides and normalize relative durable paths * no-mistakes(review): Normalize relative home before deriving durable paths * no-mistakes(document): Document relative durable-path normalization * no-mistakes(review): Captain: Ignore inherited CDPATH during relative path normalization * no-mistakes(lint): Fix empty CDPATH assignments for ShellCheck --- bin/fm-afk-launch.sh | 22 ++++ bin/fm-bootstrap.sh | 13 +- bin/fm-brief.sh | 27 ++++- bin/fm-harness.sh | 2 +- bin/fm-session-lock-lib.sh | 4 +- bin/fm-spawn.sh | 20 ++++ docs/configuration.md | 2 + tests/fm-afk-launch.test.sh | 49 ++++++++ tests/fm-brief.test.sh | 92 +++++++++++++++ tests/fm-secondmate-harness.test.sh | 52 ++++++++ tests/fm-spawn-dispatch-profile.test.sh | 151 ++++++++++++++++++++++++ tests/fm-x-mode.test.sh | 18 +++ 12 files changed, 443 insertions(+), 9 deletions(-) diff --git a/bin/fm-afk-launch.sh b/bin/fm-afk-launch.sh index 57b7f6590d..3bc1a1cbac 100755 --- a/bin/fm-afk-launch.sh +++ b/bin/fm-afk-launch.sh @@ -48,6 +48,28 @@ set -u FM_AFK_LAUNCH_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$FM_AFK_LAUNCH_DIR/.." && pwd)}" FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" +case "$FM_HOME" in + /*) ;; + *) + FM_AFK_LAUNCH_HOME_INPUT=$FM_HOME + FM_HOME=$(CDPATH='' cd -- "$FM_AFK_LAUNCH_HOME_INPUT" 2>/dev/null && pwd -P) || { + echo "error: FM_HOME directory cannot be resolved: $FM_AFK_LAUNCH_HOME_INPUT" >&2 + exit 1 + } + ;; +esac +if [ -n "${FM_STATE_OVERRIDE:-}" ]; then + case "$FM_STATE_OVERRIDE" in + /*) ;; + *) + FM_AFK_LAUNCH_STATE_INPUT=$FM_STATE_OVERRIDE + FM_STATE_OVERRIDE=$(CDPATH='' cd -- "$FM_AFK_LAUNCH_STATE_INPUT" 2>/dev/null && pwd -P) || { + echo "error: FM_STATE_OVERRIDE directory cannot be resolved: $FM_AFK_LAUNCH_STATE_INPUT" >&2 + exit 1 + } + ;; + esac +fi FM_AFK_LAUNCH_STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" FM_AFK_LAUNCH_RECORD="$FM_AFK_LAUNCH_STATE/.afk-daemon-terminal" FM_AFK_LAUNCH_LOCK="$FM_AFK_LAUNCH_STATE/.afk-launch.lock" diff --git a/bin/fm-bootstrap.sh b/bin/fm-bootstrap.sh index c86b7e839a..fef86ba383 100755 --- a/bin/fm-bootstrap.sh +++ b/bin/fm-bootstrap.sh @@ -620,7 +620,7 @@ x_mode_remove_artifact() { # applying a cadence transition to a running watcher is the caller's job via # the emitted harness-aware supervision repair instruction. x_mode_setup() { - local env_file token shim cadence shim_body cadence_body tool missing + local env_file token shim cadence shim_body cadence_body tool missing shim_home env_file="$FM_HOME/.env" shim="$STATE/x-watch.check.sh" cadence="$CONFIG/x-mode.env" @@ -683,9 +683,16 @@ x_mode_setup() { mkdir -p "$STATE" "$CONFIG" 2>/dev/null || { fmx_arm_failed; return 0; } - shim_body=$(fmx_poll_shim_content "$FM_HOME" "$FM_ROOT") + case "$FM_HOME" in + /*) shim_home=$FM_HOME ;; + *) + shim_home=$(CDPATH='' cd -- "$FM_HOME" 2>/dev/null && pwd -P) \ + || { fmx_arm_failed; return 0; } + ;; + esac + shim_body=$(fmx_poll_shim_content "$shim_home" "$FM_ROOT") x_mode_write_if_changed "$shim" "$shim_body" 700 || { fmx_arm_failed; return 0; } - fmx_poll_shim_valid "$shim" "$FM_HOME" "$FM_ROOT" \ + fmx_poll_shim_valid "$shim" "$shim_home" "$FM_ROOT" \ || { fmx_arm_failed; return 0; } cadence_body=$(cat <<'EOF' diff --git a/bin/fm-brief.sh b/bin/fm-brief.sh index 8125aa2e9e..9c98723b01 100755 --- a/bin/fm-brief.sh +++ b/bin/fm-brief.sh @@ -66,10 +66,31 @@ esac # shellcheck source=bin/fm-classify-lib.sh . "$SCRIPT_DIR/fm-classify-lib.sh" PAUSED_VERB=${FM_CLASSIFY_PAUSED_VERB:-$FM_CLASSIFY_PAUSED_VERB_DEFAULT} + +resolve_directory_input() { + local name=$1 path=$2 resolved + case "$path" in + /*) printf '%s\n' "$path"; return 0 ;; + esac + resolved=$(CDPATH='' cd -- "$path" 2>/dev/null && pwd -P) || { + echo "error: $name directory cannot be resolved: $path" >&2 + return 1 + } + printf '%s\n' "$resolved" +} + 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}" -STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" +FM_HOME=$(resolve_directory_input FM_HOME "${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}") || exit 1 +if [ -n "${FM_DATA_OVERRIDE:-}" ]; then + DATA=$(resolve_directory_input FM_DATA_OVERRIDE "$FM_DATA_OVERRIDE") || exit 1 +else + DATA="$FM_HOME/data" +fi +if [ -n "${FM_STATE_OVERRIDE:-}" ]; then + STATE=$(resolve_directory_input FM_STATE_OVERRIDE "$FM_STATE_OVERRIDE") || exit 1 +else + STATE="$FM_HOME/state" +fi KIND=ship HERDR_LAB=0 NO_PROJECTS=0 diff --git a/bin/fm-harness.sh b/bin/fm-harness.sh index f2ee8fe7e8..824b95804d 100755 --- a/bin/fm-harness.sh +++ b/bin/fm-harness.sh @@ -48,7 +48,7 @@ detect_own() { local pid=$$ comm args for _ in 1 2 3 4 5 6 7 8; do comm=$(ps -o comm= -p "$pid" 2>/dev/null) || break - case "$(basename "$comm")" in + case "$(basename -- "$comm")" in *claude*) echo claude; return ;; *codex*) echo codex; return ;; *opencode*) echo opencode; return ;; diff --git a/bin/fm-session-lock-lib.sh b/bin/fm-session-lock-lib.sh index 0e518c1c8d..8343a8efd9 100644 --- a/bin/fm-session-lock-lib.sh +++ b/bin/fm-session-lock-lib.sh @@ -31,7 +31,7 @@ fm_harness_ancestry_pid() { for _ in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16; do comm=$(ps -o comm= -p "$pid" 2>/dev/null) || break args=$(ps -o args= -p "$pid" 2>/dev/null) - bc=$(basename "$comm") + bc=$(basename -- "$comm") hit=0; is_claude=0 if printf '%s' "$bc" | grep -qE "$FM_HARNESS_RE"; then hit=1 @@ -69,7 +69,7 @@ fm_harness_pid_alive() { local pid=$1 comm args kill -0 "$pid" 2>/dev/null || return 1 comm=$(ps -o comm= -p "$pid" 2>/dev/null) || return 1 - if printf '%s' "$(basename "$comm")" | grep -qE "$FM_HARNESS_RE"; then + if printf '%s' "$(basename -- "$comm")" | grep -qE "$FM_HARNESS_RE"; then return 0 fi case "$comm" in diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index 76eed3e736..3526572550 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -124,6 +124,26 @@ esac FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" + +resolve_directory_input() { + local name=$1 path=$2 resolved + case "$path" in + /*) printf '%s\n' "$path"; return 0 ;; + esac + resolved=$(CDPATH='' cd -- "$path" 2>/dev/null && pwd -P) || { + echo "error: $name directory cannot be resolved: $path" >&2 + return 1 + } + printf '%s\n' "$resolved" +} + +FM_HOME=$(resolve_directory_input FM_HOME "$FM_HOME") || exit 1 +if [ -n "${FM_STATE_OVERRIDE:-}" ]; then + FM_STATE_OVERRIDE=$(resolve_directory_input FM_STATE_OVERRIDE "$FM_STATE_OVERRIDE") || exit 1 +fi +if [ -n "${FM_DATA_OVERRIDE:-}" ]; then + FM_DATA_OVERRIDE=$(resolve_directory_input FM_DATA_OVERRIDE "$FM_DATA_OVERRIDE") || exit 1 +fi STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" DATA="${FM_DATA_OVERRIDE:-$FM_HOME/data}" PROJECTS="${FM_PROJECTS_OVERRIDE:-$FM_HOME/projects}" diff --git a/docs/configuration.md b/docs/configuration.md index c5215512b2..adf215b408 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -169,6 +169,8 @@ When it is unset, most scripts use the repo root as the home; when it is set, sc When `FM_HOME` is unset, it also behaves as the old whole-root override. `bin/fm-send.sh` is intentionally stricter than that general fallback: it requires `FM_HOME` to be set before resolving a target, so operator steers cannot silently resolve against the wrong home. `FM_STATE_OVERRIDE`, `FM_DATA_OVERRIDE`, `FM_PROJECTS_OVERRIDE`, and `FM_CONFIG_OVERRIDE` override individual operational directories for tests and specialized harness setup. +Before `fm-brief.sh`, `fm-spawn.sh`, or `fm-afk-launch.sh` persists a path or passes it to another process, it resolves each applicable relative `FM_HOME`, `FM_STATE_OVERRIDE`, or `FM_DATA_OVERRIDE` directory against the caller's working directory, preserves absolute spellings unchanged, and rejects an unresolvable relative directory with the offending variable named. +Bootstrap applies the same relative `FM_HOME` resolution only when embedding that home in the generated X-mode poll shim; other transient consumers retain their existing shell-relative behavior. For the herdr backend, `FM_HOME` also determines the workspace label used by the adapter. For the zellij backend, `FM_HOME` does not split containers, but it determines the readable home prefix embedded in visible tab titles; use `FM_ZELLIJ_SESSION` when a separate zellij session is needed. The full zellij home label also includes a short hash of the resolved `FM_ROOT` path. diff --git a/tests/fm-afk-launch.test.sh b/tests/fm-afk-launch.test.sh index 8075d7067a..b65bd9cdc2 100755 --- a/tests/fm-afk-launch.test.sh +++ b/tests/fm-afk-launch.test.sh @@ -70,6 +70,54 @@ unit_clear_stale() { rm -rf "$st" } +unit_relative_paths_are_absolute_before_daemon_launch() { + local root home state out status linked_home + root=$(mktemp -d "${TMPDIR:-/tmp}/fm-afk-relative-home.XXXXXX") + mkdir -p "$root/home/state" "$root/cdpath/home/state" + home=$(cd "$root/home" && pwd -P) + state="$home/state" + out=$( + cd "$root" || exit 1 + CDPATH="$root/cdpath" FM_HOME=home FM_STATE_OVERRIDE=home/state \ + bash -c '. "$1"; printf "%s\n%s\n" "$FM_HOME" "$FM_AFK_LAUNCH_STATE"' _ "$LAUNCH" + ) + if [ "$out" = "$home"$'\n'"$state" ]; then + pass "launcher paths: relative home and state ignore CDPATH before daemon command construction" + else + fail "launcher paths: relative home or state remained cwd-dependent ($out)" + fi + linked_home="$root/home-link" + ln -s "$root/home" "$linked_home" + out=$(FM_HOME="$linked_home" FM_STATE_OVERRIDE="$linked_home/state" \ + bash -c '. "$1"; printf "%s\n%s\n" "$FM_HOME" "$FM_AFK_LAUNCH_STATE"' _ "$LAUNCH") + if [ "$out" = "$linked_home"$'\n'"$linked_home/state" ]; then + pass "launcher paths: absolute symlink spellings are preserved" + else + fail "launcher paths: absolute symlink spelling changed ($out)" + fi + out=$( + cd "$root" || exit 1 + FM_HOME=missing-home "$LAUNCH" help 2>&1 + ) + status=$? + if [ "$status" -ne 0 ] && printf '%s\n' "$out" | grep -F "FM_HOME directory cannot be resolved: missing-home" >/dev/null; then + pass "launcher paths: unresolved relative FM_HOME fails loudly" + else + fail "launcher paths: unresolved relative FM_HOME did not name the bad input ($out)" + fi + out=$( + cd "$root" || exit 1 + FM_HOME=home FM_STATE_OVERRIDE=missing-state "$LAUNCH" help 2>&1 + ) + status=$? + if [ "$status" -ne 0 ] && printf '%s\n' "$out" | grep -F "FM_STATE_OVERRIDE directory cannot be resolved: missing-state" >/dev/null; then + pass "launcher paths: unresolved relative FM_STATE_OVERRIDE fails loudly" + else + fail "launcher paths: unresolved relative FM_STATE_OVERRIDE did not name the bad input ($out)" + fi + rm -rf "$root" +} + # --------------------------------------------------------------------------- # UNIT 2: a FRESH entry clears; a REFRESH (daemon already alive) preserves the # current session's buffered escalations. @@ -861,6 +909,7 @@ e2e_tmux() { } unit_clear_stale +unit_relative_paths_are_absolute_before_daemon_launch unit_fresh_vs_refresh unit_stop_ordering unit_stop_rejects_reused_pid diff --git a/tests/fm-brief.test.sh b/tests/fm-brief.test.sh index bed170b741..0199311824 100755 --- a/tests/fm-brief.test.sh +++ b/tests/fm-brief.test.sh @@ -436,6 +436,97 @@ test_secondmate_marked_request_reporting_contract() { pass "fm-brief.sh: marked requests avoid generic acknowledgements and preserve material reporting" } +test_secondmate_directory_paths_are_absolute_and_output_is_stable() { + local root home data_override state_override brief baseline err status + root="$TMP_ROOT/relative-directory-inputs" + mkdir -p "$root" + root=$(cd "$root" && pwd -P) + home="$root/home" + data_override="$root/data-override" + state_override="$root/state-override" + mkdir -p "$home/data" "$home/state" "$data_override" "$state_override" \ + "$root/cdpath/home/data" "$root/cdpath/home/state" \ + "$root/cdpath/data-override" "$root/cdpath/state-override" + + brief="$home/data/relative-home/brief.md" + FM_HOME="$home" FM_SECONDMATE_CHARTER=x \ + "$ROOT/bin/fm-brief.sh" relative-home --secondmate --no-projects >/dev/null 2>&1 + baseline="$root/absolute-home-charter" + cp "$brief" "$baseline" + rm -f "$brief" + ( + cd "$root" || exit 1 + CDPATH="$root/cdpath" FM_HOME=home FM_SECONDMATE_CHARTER=x \ + "$ROOT/bin/fm-brief.sh" relative-home --secondmate --no-projects >/dev/null 2>&1 + ) + cmp -s "$baseline" "$brief" \ + || fail "relative FM_HOME changed charter bytes compared with the same absolute home" + assert_grep ">> '$home/state/relative-home.status'" "$brief" \ + "relative FM_HOME did not render an absolute secondmate status path" + + brief="$home/data/relative-state/brief.md" + FM_HOME="$home" FM_STATE_OVERRIDE="$state_override" FM_SECONDMATE_CHARTER=x \ + "$ROOT/bin/fm-brief.sh" relative-state --secondmate --no-projects >/dev/null 2>&1 + baseline="$root/absolute-state-charter" + cp "$brief" "$baseline" + rm -f "$brief" + ( + cd "$root" || exit 1 + CDPATH="$root/cdpath" FM_HOME="$home" FM_STATE_OVERRIDE=state-override FM_SECONDMATE_CHARTER=x \ + "$ROOT/bin/fm-brief.sh" relative-state --secondmate --no-projects >/dev/null 2>&1 + ) + cmp -s "$baseline" "$brief" \ + || fail "relative FM_STATE_OVERRIDE changed charter bytes compared with the same absolute state directory" + assert_grep ">> '$state_override/relative-state.status'" "$brief" \ + "relative FM_STATE_OVERRIDE did not render an absolute secondmate status path" + + brief="$data_override/relative-data/brief.md" + FM_HOME="$home" FM_DATA_OVERRIDE="$data_override" FM_SECONDMATE_CHARTER=x \ + "$ROOT/bin/fm-brief.sh" relative-data --secondmate --no-projects >/dev/null 2>&1 + baseline="$root/absolute-data-charter" + cp "$brief" "$baseline" + rm -f "$brief" + ( + cd "$root" || exit 1 + CDPATH="$root/cdpath" FM_HOME="$home" FM_DATA_OVERRIDE=data-override FM_SECONDMATE_CHARTER=x \ + "$ROOT/bin/fm-brief.sh" relative-data --secondmate --no-projects >/dev/null 2>&1 + ) + cmp -s "$baseline" "$brief" \ + || fail "relative FM_DATA_OVERRIDE changed charter bytes compared with the same absolute data directory" + assert_grep ">> '$home/state/relative-data.status'" "$brief" \ + "relative FM_DATA_OVERRIDE changed the absolute default status path" + + err="$root/unresolved.err" + ( + cd "$root" || exit 1 + FM_HOME=missing-home FM_SECONDMATE_CHARTER=x \ + "$ROOT/bin/fm-brief.sh" unresolved-home --secondmate --no-projects >/dev/null 2>"$err" + ); status=$? + expect_code 1 "$status" "an unresolved relative FM_HOME must fail" + assert_grep "FM_HOME directory cannot be resolved: missing-home" "$err" \ + "unresolved relative FM_HOME did not fail loudly" + + ( + cd "$root" || exit 1 + FM_HOME="$home" FM_STATE_OVERRIDE=missing-state FM_SECONDMATE_CHARTER=x \ + "$ROOT/bin/fm-brief.sh" unresolved-state --secondmate --no-projects >/dev/null 2>"$err" + ); status=$? + expect_code 1 "$status" "an unresolved relative FM_STATE_OVERRIDE must fail" + assert_grep "FM_STATE_OVERRIDE directory cannot be resolved: missing-state" "$err" \ + "unresolved relative FM_STATE_OVERRIDE did not fail loudly" + + ( + cd "$root" || exit 1 + FM_HOME="$home" FM_DATA_OVERRIDE=missing-data FM_SECONDMATE_CHARTER=x \ + "$ROOT/bin/fm-brief.sh" unresolved-data --secondmate --no-projects >/dev/null 2>"$err" + ); status=$? + expect_code 1 "$status" "an unresolved relative FM_DATA_OVERRIDE must fail" + assert_grep "FM_DATA_OVERRIDE directory cannot be resolved: missing-data" "$err" \ + "unresolved relative FM_DATA_OVERRIDE did not fail loudly" + + pass "fm-brief.sh: relative directory inputs ignore CDPATH, render stable absolute charter paths, or fail loudly" +} + test_herdr_lab_contract_applies_to_scouts_but_not_secondmates() { local home brief status=0 home="$TMP_ROOT/herdr-kind-home" @@ -540,6 +631,7 @@ test_herdr_lab_omission_is_loud_for_ship_and_scout test_herdr_lab_contract_applies_to_scouts_but_not_secondmates test_secondmate_no_projects_charter test_secondmate_marked_request_reporting_contract +test_secondmate_directory_paths_are_absolute_and_output_is_stable test_pause_verb_override_renders_all_brief_scaffolds test_scout_and_secondmate_load_decision_hold_policy test_scout_and_secondmate_scaffold diff --git a/tests/fm-secondmate-harness.test.sh b/tests/fm-secondmate-harness.test.sh index b9efe54033..ae41c79351 100755 --- a/tests/fm-secondmate-harness.test.sh +++ b/tests/fm-secondmate-harness.test.sh @@ -188,6 +188,57 @@ SH pass "pi-signed identity: authoritative launch selection distinguishes shared wrapper ancestry" } +test_dash_leading_process_names_are_basename_operands() { + local dir fakebin got err status + dir="$TMP_ROOT/dash-leading-process-names" + fakebin=$(fm_fakebin "$dir") + cat > "$fakebin/ps" <<'SH' +#!/usr/bin/env bash +set -u +field= pid= +while [ "$#" -gt 0 ]; do + case "$1" in + -o) field=$2; shift 2 ;; + -p) pid=$2; shift 2 ;; + *) shift ;; + esac +done +case "$pid:$field" in + 4242:comm=) printf '%s\n' '/opt/test/bin/codex' ;; + 4242:args=) printf '%s\n' 'codex' ;; + 4242:ppid=) printf '%s\n' 1 ;; + 5252:comm=) printf '%s\n' '-codex' ;; + 5252:args=) printf '%s\n' '-codex' ;; + 5252:ppid=) printf '%s\n' 1 ;; + *:comm=) printf '%s\n' '-zsh' ;; + *:args=) printf '%s\n' '-zsh' ;; + *:ppid=) printf '%s\n' 4242 ;; +esac +SH + chmod +x "$fakebin/ps" + + err="$dir/fm-harness.err" + got=$(env -u CLAUDECODE -u PI_CODING_AGENT -u GROK_AGENT \ + PATH="$fakebin:$BASE_PATH" "$ROOT/bin/fm-harness.sh" 2>"$err") + [ "$got" = codex ] || fail "dash-leading shell ancestry resolved '$got', expected codex" + [ ! -s "$err" ] || fail "fm-harness wrote basename option noise for literal -zsh: $(cat "$err")" + + err="$dir/fm-session-lock-ancestry.err" + got=$(PATH="$fakebin:$BASE_PATH" bash -c \ + '. "$0/bin/fm-session-lock-lib.sh"; fm_harness_ancestry_pid' "$ROOT" 2>"$err") + [ "$got" = 4242 ] || fail "session-lock dash-leading ancestry selected '$got', expected pid 4242" + [ ! -s "$err" ] || fail "session-lock ancestry wrote basename option noise for literal -zsh: $(cat "$err")" + + err="$dir/fm-session-lock-alive.err" + PATH="$fakebin:$BASE_PATH" bash -c \ + '. "$0/bin/fm-session-lock-lib.sh"; kill() { return 0; }; fm_harness_pid_alive 5252' \ + "$ROOT" 2>"$err"; status=$? + expect_code 0 "$status" "session-lock liveness should accept literal -codex as a harness process name" + [ ! -s "$err" ] || fail "session-lock liveness wrote basename option noise for literal -codex: $(cat "$err")" + + pass "harness identity: dash-leading ps command names are basename operands, not options" +} + # =========================================================================== # B) propagate_inheritable_config unit behavior # =========================================================================== @@ -2246,6 +2297,7 @@ SH test_harness_resolution test_secondmate_model_effort_tokens test_pi_signed_detection_and_session_lock_identity +test_dash_leading_process_names_are_basename_operands test_propagate_lib test_spawn_split_and_inherit test_spawn_backward_compat_crew_fallback diff --git a/tests/fm-spawn-dispatch-profile.test.sh b/tests/fm-spawn-dispatch-profile.test.sh index 4f0695e7e5..e5f017608d 100755 --- a/tests/fm-spawn-dispatch-profile.test.sh +++ b/tests/fm-spawn-dispatch-profile.test.sh @@ -128,6 +128,153 @@ test_no_profile_keeps_claude_profile_defaults() { pass "no --model/--effort records defaults and types the claude launch instructions" } +test_relative_home_overrides_launch_with_absolute_cross_process_paths() { + local rec id out status launch home_real + id=profile-relative-paths-z1b + rec=$(make_spawn_case profile-relative-paths pi "$id") + read_case_record "$rec" + home_real=$(cd "$HOME_DIR" && pwd -P) + mkdir -p "$CASE_DIR/cdpath/home/state" "$CASE_DIR/cdpath/home/data" + : > "$LAUNCH_LOG" + + out=$( + cd "$CASE_DIR" || exit 1 + CDPATH="$CASE_DIR/cdpath" 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="$WT_DIR" TMUX="fake,1,0" \ + CLAUDE_CONFIG_DIR='' FM_FAKE_LAUNCH_LOG="$LAUNCH_LOG" \ + GROK_HOME=home/grok-home PATH="$FAKEBIN_DIR:$PATH" \ + "$SPAWN" "$id" "$PROJ_DIR" 2>&1 + ) + status=$? + expect_code 0 "$status" "spawn with relative home overrides should succeed" + launch=$(cat "$LAUNCH_LOG") + assert_contains "$launch" "-e '$home_real/state/$id.pi-ext.ts'" \ + "relative FM_STATE_OVERRIDE leaked into Pi's cross-process extension path" + assert_contains "$launch" "< '$home_real/data/$id/brief.md'" \ + "relative FM_DATA_OVERRIDE leaked into the cross-process brief path" + pass "relative home overrides ignore CDPATH and become absolute before spawn launch construction" +} + +test_home_defaults_preserve_absolute_or_resolve_relative_paths() { + local rec relative_id absolute_id out status launch home_real linked_home + relative_id=profile-relative-home-defaults-z1c + absolute_id=profile-absolute-home-defaults-z1d + rec=$(make_spawn_case profile-home-defaults pi "$relative_id" "$absolute_id") + read_case_record "$rec" + home_real=$(cd "$HOME_DIR" && pwd -P) + + : > "$LAUNCH_LOG" + out=$( + cd "$CASE_DIR" || exit 1 + FM_ROOT_OVERRIDE='' FM_HOME=home \ + FM_STATE_OVERRIDE='' FM_DATA_OVERRIDE='' \ + FM_PROJECTS_OVERRIDE=home/projects FM_CONFIG_OVERRIDE=home/config \ + FM_SPAWN_NO_GUARD=1 FM_FAKE_PANE_PATH="$WT_DIR" TMUX="fake,1,0" \ + CLAUDE_CONFIG_DIR='' FM_FAKE_LAUNCH_LOG="$LAUNCH_LOG" \ + GROK_HOME=home/grok-home PATH="$FAKEBIN_DIR:$PATH" \ + "$SPAWN" "$relative_id" "$PROJ_DIR" 2>&1 + ) + status=$? + expect_code 0 "$status" "spawn with relative FM_HOME defaults should succeed" + launch=$(cat "$LAUNCH_LOG") + assert_contains "$launch" "-e '$home_real/state/$relative_id.pi-ext.ts'" \ + "relative FM_HOME leaked into Pi's default cross-process extension path" + assert_contains "$launch" "< '$home_real/data/$relative_id/brief.md'" \ + "relative FM_HOME leaked into the default cross-process brief path" + + linked_home="$CASE_DIR/home-link" + ln -s "$HOME_DIR" "$linked_home" + : > "$LAUNCH_LOG" + out=$( + FM_ROOT_OVERRIDE='' FM_HOME="$linked_home" \ + FM_STATE_OVERRIDE='' FM_DATA_OVERRIDE='' \ + FM_PROJECTS_OVERRIDE="$linked_home/projects" FM_CONFIG_OVERRIDE="$linked_home/config" \ + FM_SPAWN_NO_GUARD=1 FM_FAKE_PANE_PATH="$WT_DIR" TMUX="fake,1,0" \ + CLAUDE_CONFIG_DIR='' FM_FAKE_LAUNCH_LOG="$LAUNCH_LOG" \ + GROK_HOME="$linked_home/grok-home" PATH="$FAKEBIN_DIR:$PATH" \ + "$SPAWN" "$absolute_id" "$PROJ_DIR" 2>&1 + ) + status=$? + expect_code 0 "$status" "spawn with absolute symlink-spelled FM_HOME defaults should succeed" + launch=$(cat "$LAUNCH_LOG") + assert_contains "$launch" "-e '$linked_home/state/$absolute_id.pi-ext.ts'" \ + "absolute FM_HOME spelling changed in Pi's default cross-process extension path" + assert_contains "$launch" "< '$linked_home/data/$absolute_id/brief.md'" \ + "absolute FM_HOME spelling changed in the default cross-process brief path" + pass "FM_HOME defaults resolve relative paths and preserve absolute spellings" +} + +test_absolute_override_spelling_is_preserved_in_launch_paths() { + local rec id out status launch linked_home + id=profile-absolute-paths-z1c + rec=$(make_spawn_case profile-absolute-paths pi "$id") + read_case_record "$rec" + linked_home="$CASE_DIR/home-link" + ln -s "$HOME_DIR" "$linked_home" + : > "$LAUNCH_LOG" + + out=$( + FM_ROOT_OVERRIDE='' FM_HOME="$linked_home" \ + FM_STATE_OVERRIDE="$linked_home/state" FM_DATA_OVERRIDE="$linked_home/data" \ + FM_PROJECTS_OVERRIDE="$linked_home/projects" FM_CONFIG_OVERRIDE="$linked_home/config" \ + FM_SPAWN_NO_GUARD=1 FM_FAKE_PANE_PATH="$WT_DIR" TMUX="fake,1,0" \ + CLAUDE_CONFIG_DIR='' FM_FAKE_LAUNCH_LOG="$LAUNCH_LOG" \ + GROK_HOME="$linked_home/grok-home" PATH="$FAKEBIN_DIR:$PATH" \ + "$SPAWN" "$id" "$PROJ_DIR" 2>&1 + ) + status=$? + expect_code 0 "$status" "spawn with absolute symlink-spelled overrides should succeed" + launch=$(cat "$LAUNCH_LOG") + assert_contains "$launch" "-e '$linked_home/state/$id.pi-ext.ts'" \ + "absolute FM_STATE_OVERRIDE spelling changed in Pi's cross-process extension path" + assert_contains "$launch" "< '$linked_home/data/$id/brief.md'" \ + "absolute FM_DATA_OVERRIDE spelling changed in the cross-process brief path" + pass "absolute override spellings are preserved in spawn launch paths" +} + +test_unresolvable_relative_overrides_fail_loudly() { + local rec id out status + id=profile-unresolvable-paths-z1d + rec=$(make_spawn_case profile-unresolvable-paths pi "$id") + read_case_record "$rec" + + out=$( + cd "$CASE_DIR" || exit 1 + FM_ROOT_OVERRIDE='' FM_HOME=missing-home \ + FM_STATE_OVERRIDE='' FM_DATA_OVERRIDE='' \ + "$SPAWN" "$id" "$PROJ_DIR" 2>&1 + ) + status=$? + expect_code 1 "$status" "spawn with an unresolvable relative home should fail" + assert_contains "$out" "FM_HOME directory cannot be resolved: missing-home" \ + "spawn did not name the unresolvable FM_HOME" + + out=$( + cd "$CASE_DIR" || exit 1 + FM_ROOT_OVERRIDE='' FM_HOME=home \ + FM_STATE_OVERRIDE=missing-state FM_DATA_OVERRIDE=home/data \ + "$SPAWN" "$id" "$PROJ_DIR" 2>&1 + ) + status=$? + expect_code 1 "$status" "spawn with an unresolvable relative state override should fail" + assert_contains "$out" "FM_STATE_OVERRIDE directory cannot be resolved: missing-state" \ + "spawn did not name the unresolvable FM_STATE_OVERRIDE" + + out=$( + cd "$CASE_DIR" || exit 1 + FM_ROOT_OVERRIDE='' FM_HOME=home \ + FM_STATE_OVERRIDE=home/state FM_DATA_OVERRIDE=missing-data \ + "$SPAWN" "$id" "$PROJ_DIR" 2>&1 + ) + status=$? + expect_code 1 "$status" "spawn with an unresolvable relative data override should fail" + assert_contains "$out" "FM_DATA_OVERRIDE directory cannot be resolved: missing-data" \ + "spawn did not name the unresolvable FM_DATA_OVERRIDE" + pass "unresolvable relative spawn overrides fail with named diagnostics" +} + test_active_dispatch_profile_requires_explicit_harness_for_ship() { local rec id out status id=profile-required-ship-z11 @@ -509,6 +656,10 @@ test_active_dispatch_profile_does_not_block_secondmate_launch() { } test_no_profile_keeps_claude_profile_defaults +test_relative_home_overrides_launch_with_absolute_cross_process_paths +test_home_defaults_preserve_absolute_or_resolve_relative_paths +test_absolute_override_spelling_is_preserved_in_launch_paths +test_unresolvable_relative_overrides_fail_loudly test_active_dispatch_profile_requires_explicit_harness_for_ship test_active_dispatch_profile_requires_explicit_harness_for_scout test_active_dispatch_profile_allows_explicit_harness diff --git a/tests/fm-x-mode.test.sh b/tests/fm-x-mode.test.sh index 505860689d..baed0b28d4 100755 --- a/tests/fm-x-mode.test.sh +++ b/tests/fm-x-mode.test.sh @@ -700,6 +700,23 @@ test_bootstrap_activates_on_env_token() { pass "bootstrap activates X mode from an .env token, idempotently" } +test_bootstrap_relative_home_writes_absolute_poll_shim() { + local root home out quoted_home + root="$TMP_ROOT/boot-relative-home" + mkdir -p "$root/home" "$root/cdpath/home" + home=$(cd "$root/home" && pwd -P) + printf 'FMX_PAIRING_TOKEN=tok-relative\n' > "$home/.env" + out=$( + cd "$root" || exit 1 + CDPATH="$root/cdpath" FM_HOME=home "$ROOT/bin/fm-bootstrap.sh" 2>/dev/null + ) + assert_contains "$out" "FMX: X mode on" "relative-home bootstrap must announce X mode" + quoted_home=$(printf '%q' "$home") + assert_grep "export FM_HOME=$quoted_home" "$home/state/x-watch.check.sh" \ + "relative FM_HOME leaked into the durable X-mode poll shim" + pass "bootstrap ignores CDPATH when writing absolute FM_HOME into the durable X-mode poll shim" +} + test_bootstrap_reports_missing_x_dependency() { local home fakebin out tool tool_path home="$TMP_ROOT/boot-missing-x"; mkdir -p "$home" @@ -2862,6 +2879,7 @@ test_followup_post_dry_run_increments_counter_keeps_link test_followup_post_dry_run_final_clears_link test_followup_usage_errors test_bootstrap_activates_on_env_token +test_bootstrap_relative_home_writes_absolute_poll_shim test_bootstrap_reports_missing_x_dependency test_bootstrap_does_not_announce_when_arm_fails test_bootstrap_does_not_follow_x_artifact_symlinks From c21bf547511cf983628eb07d5fb57f931baee21f Mon Sep 17 00:00:00 2001 From: deeto15 <92119640+deeto15@users.noreply.github.com> Date: Wed, 29 Jul 2026 18:58:34 -0400 Subject: [PATCH 12/70] refactor(skills): make Bearings chat-only by default (#1136) * Add internal status skill * no-mistakes(document): register /status skill in documentation-audiences inventory * no-mistakes(lint): replace grep|wc -l with grep -c in status skill test * test: silence literal status skill patterns * Refactor bearings default to chat-only --------- Co-authored-by: Kun Chen <3233006+kunchenguid@users.noreply.github.com> --- .agents/skills/bearings/SKILL.md | 90 ++++++++++++-------- README.md | 9 +- tests/fm-bearings-skill.test.sh | 129 +++++++++++++++++++++++++++++ tests/fm-bearings-snapshot.test.sh | 8 +- 4 files changed, 198 insertions(+), 38 deletions(-) create mode 100755 tests/fm-bearings-skill.test.sh diff --git a/.agents/skills/bearings/SKILL.md b/.agents/skills/bearings/SKILL.md index b2804c7290..42990edd04 100644 --- a/.agents/skills/bearings/SKILL.md +++ b/.agents/skills/bearings/SKILL.md @@ -1,6 +1,9 @@ --- name: bearings -description: Generate a "pick up where I left off" status report from firstmate's live fleet state. Use when the captain invokes /bearings or asks for a bearings report, morning brief, status report, catch-up, "where did I leave off", or "what's in the works". Reads bounded local fleet state cheaply, optionally checks open PRs when requested, composes a scannable dated report to data/status-report-.md, and surfaces a concise version in chat; it is read-mostly and must not tear down, merge, or mutate task state as a side effect of producing the brief. +description: >- + Generate a "pick up where I left off" fleet digest from firstmate's live fleet state. + Use when the captain invokes /bearings or asks for a bearings report, morning brief, status report, catch-up, "where did I leave off", or "what's in the works". + Plain /bearings is chat-only by default, while /bearings file explicitly writes the dated data/status-report-.md artifact; live PR enrichment remains opt-in and composes with file mode. user-invocable: true metadata: internal: true @@ -8,42 +11,58 @@ metadata: # bearings -Generate a complete standalone snapshot from the fleet's current state, so the captain can resume in one read after a break, a night, or a context reset. -The deliverable is a dated markdown file plus a concise chat summary that each stand on the current snapshot rather than an earlier report. -This skill is read-mostly. -It reads fleet state and writes exactly one report file. -It never tears down a task, merges a PR, dispatches new work, or mutates any task state as a side effect of producing the brief - those belong to the captain's explicit word and the normal task lifecycle. +Generate a complete current snapshot from the fleet's current state, so the captain can resume in one read after a break, a night, or a context reset. +Plain `/bearings` returns only the concise four-section chat digest. +Only `/bearings file` writes the dated markdown report artifact and then returns the concise four-section chat digest linked to that report. +This skill is operationally read-only in both modes. +It never tears down a task, merges a PR, dispatches new work, steers a worker, answers a decision, cleans up work, mutates backlog or task state, or writes any file except the single dated report in explicit file mode. + +## Invocation modes + +- Plain `/bearings` gathers a fresh bounded snapshot and renders the four-section chat digest without creating, deleting, reading, or replacing `data/status-report-.md`. +- `/bearings file` gathers a fresh bounded snapshot, replaces today's `data/status-report-.md` from scratch, and renders the four-section chat digest with a link or path to that report. +- Treat `file` only as an explicit invocation option in the slash command. +- Do not treat natural-language requests such as "write a report", "save this", "persist it", or "make a file" as file mode unless the invocation explicitly includes the standalone `file` option. +- When the captain asks to include PRs, pass the snapshot command's live-PR opt-in. +- `/bearings include PRs` remains chat-only and makes the live-PR opt-in. +- `/bearings file include PRs` writes the dated report and makes the live-PR opt-in. ## What it does 1. **Gather live fleet state with one deterministic command.** - Run `bin/fm-bearings-snapshot.sh` and read its compact output. - It is the single bounded, deterministic source for this report and renders TOON by default. - Do not hand-probe the snapshot schema and do not make ad-hoc `gh-axi`/`gh` calls to assemble fleet facts; this command already assembles them. + Run `bin/fm-bearings-snapshot.sh` at invocation time and read its compact output. + It is the single bounded, deterministic fleet-state source for Bearings and renders TOON by default. + Do not create or consult a second fleet-state reader, parser contract, status-event-tail interpretation, visible-session recap, ad-hoc project probe, or ad-hoc `gh-axi`/`gh` query. The command's header and `--help` output own its exact fields, bounds, opt-ins, and output contract. - When the captain asks to include PRs, use the command's live-PR opt-in; otherwise keep the default local-only read. - If the command is unavailable, fall back to `bin/fm-fleet-snapshot.sh --json` and `bin/fm-crew-state.sh `; never infer current state from a raw `tail` of `state/.status`, which is append-only wake-event history whose last line goes stale. - For registered secondmates, use the snapshot's structured-home classification and provenance; a parent event or bounded terminal contradiction is fallback evidence, never authority over readable structured home state. - Structured captain-held decisions come from `decision-hold-lifecycle` and appear under `decisions_open`; do not scrape reports or visual-review artifacts to supplement them. - A queued item under `gates` only becomes "next work" when its blocker is gone and its time/date gate has arrived; until then it stays queued with the reason. - The `(main-inventory)` gate is an action-free integrity warning rather than queued work: render it under Charted Next with the related `omitted` disclosure, never invent an Underway row from backlog-only state, and never move it into Captain's Call. - -2. **Compose the detailed report file around the four-section spine, adding the richer detail the chat leaves out.** - The gather step is deterministic; your judgment is scoped to the last mile only - ranking the command's facts by what matters right now and writing the scannable prose. + Keep the default local-only read unless the captain asks to include PRs. + For registered secondmates, use the snapshot's structured-home classification and provenance. + A parent event or bounded terminal contradiction is fallback evidence, never authority over readable structured home state. + Structured captain-held decisions come from `decision-hold-lifecycle` and appear under `decisions_open`. + Do not scrape reports, visual-review artifacts, raw status-event tails, or visible conversation history to supplement current state. + A queued item under `gates` only becomes "next work" when its blocker is gone and its time/date gate has arrived. + Until then it stays queued with the reason. + The `(main-inventory)` gate is an action-free integrity warning rather than queued work. + Render it under Charted Next with the related `omitted` disclosure, never invent an Underway row from backlog-only state, and never move it into Captain's Call. + +2. **Compose the four-section chat digest from the fresh snapshot.** + The gather step is deterministic; your judgment is scoped to ranking the command's facts by what matters right now and writing scannable captain-facing prose. + The chat response uses the four complete sections in the chat-response contract below, in the same order, each always present. + Plain mode stops here and writes no report artifact. + +3. **In explicit file mode only, compose and replace the detailed report file.** + The report uses the same four complete sections as the chat, in the same order, and adds the detail the chat omits. Never read an earlier `data/status-report-*.md` to decide what to omit, include, describe as changed, or call current. - The report uses the same four complete sections as the chat (see the chat-response contract below), in the same order, each always present, and adds the detail the chat omits: + Write the full report to `data/status-report-.md` using today's date. + If today's file already exists, delete it first, then create a new file from scratch. + This is the only write allowed by the skill. + The detailed report includes: - **Title** - `# Bearings - ` (use "Morning status" only when the captain specifically asks for a morning brief), followed by two or three sentences framing where things stand. - **Captain's Call** - every open decision summarized with its options from the structured decision record, plus each PR ready to merge and each needed credential or login, every PR with the full `https://...` URL, never a bare `#number`. - **Recently Landed** - the bounded current recent-completions baseline from structured state across the main fleet and every registered secondmate home, rendered in full on every run. - - **Underway** - each live direct report making progress, with its current state, and the plans / main pickup pointers worth reopening (`data//report.md` files, `.lavish/*.html` boards). + - **Underway** - each live direct report making progress, with its current state, and the plans or main pickup pointers worth reopening (`data//report.md` files, `.lavish/*.html` boards). - **Charted Next** - queued or gated work, including any main-inventory integrity warning, with each item's blocker, date, or integrity reason. - -3. **Write the dated report file so it persists, then surface the mandatory four-section digest in chat.** - - Write the full report to `data/status-report-.md` using today's date. - This is the required artifact; it lives in gitignored `data/`. - If today's file already exists, delete it first, then create a new file from scratch. - - The chat response is the concise four-section digest defined by the contract below: materially shorter than the report file, complete as a current snapshot, internally consistent with the file, and linked to that file for the full picture. - - For a richer review surface, optionally offer a Lavish board with `lavish-axi` when the report has enough structure to deserve one, but the markdown file is the required artifact and the four-section chat digest is the required minimum. + After writing the file, return the concise four-section chat digest and include the report path or link without adding a fifth section. + For a richer review surface, optionally offer a Lavish board with `lavish-axi` when the report has enough structure to deserve one, but only after the required digest is ready. ## Chat-response contract @@ -62,22 +81,27 @@ Every `/bearings` chat response renders EXACTLY these four sections, in THIS ord Rules that keep the contract unambiguous: - Every section ALWAYS renders, even when empty, with its short empty-state sentence; never omit a section. -- Every report and chat digest is a complete current snapshot, never a delta against a prior report. +- Every chat digest and file-mode report is a complete current snapshot, never a delta against a prior report. - Recently Landed always renders the bounded current baseline, even when the same completions appeared in an earlier report. - The four buckets are mutually exclusive, so every item is forced into exactly one: needs-your-action is Captain's Call, done is Recently Landed, self-progressing is Underway, and not-yet-started work or an action-free fleet-integrity warning is Charted Next. - The strict boundary keeps action-free items OUT of Captain's Call: a working or validating task, a queued item blocked on another task or a date, landed work, a completed scout's report pointer, a declared `paused:` external wait, and a bare recorded PR with no merge-ready signal each belong to one of the other three sections, never Captain's Call. - A secondmate's own row appears Underway only for `active_child_work`; `externally_held` belongs in Charted Next, and `unknown` belongs there as an unavailable-state gate unless its reason requires the captain's action. - Do not suppress separately projected decisions, landed records, or gates from a `partial-structured` home merely because that secondmate's own row is `unknown`. -- The chat follows `AGENTS.md` section 9 and carries one scannable line per item, each PR as the full `https://...` URL; detailed decisions, plans, full gate reasons, and evidence live only in the report file, which the chat links to, so the chat stays materially shorter than that file. +- Include the required direct address to the captain inside one item or empty-state sentence. +- Every PR appears as the full `https://...` URL; a shorthand `#number` is fine only as a back-reference after the full URL has already appeared in the same digest. +- The chat follows `AGENTS.md` section 9 and carries one scannable line per item. +- Detailed decisions, plans, full gate reasons, and evidence belong in the file only when file mode is explicit, so plain chat stays concise and file-mode chat stays materially shorter than that file. +- In file mode, include the report path or link inside the four-section digest without adding another heading. ## Tone and content rules -- This report is a private, captain-facing internal artifact that lives in gitignored `data/`, so unlike normal captain chat it MAY reference task ids, PR URLs, and repo names - the captain works with these directly and needs them to resume; keep it organized and scannable, not a raw dump. -- Every PR reference is a full `https://...` URL, never a bare `#number`; a shorthand `#number` is fine only as a back-reference after the full URL has already appeared in the same report. +- The optional file-mode report is a private, captain-facing internal artifact that lives in gitignored `data/`, so unlike normal captain chat it MAY reference task ids, PR URLs, and repo names. +- The captain works with those directly and needs them to resume; keep the report organized and scannable, not a raw dump. +- Every PR reference is a full `https://...` URL, never a bare `#number`. - Never include PHI or secret values; the report is an operational artifact, but it is still subject to the same security and compliance rules that govern everything else in this fleet. ## Supervision discipline -This skill is read-mostly and changes no fleet state. -Do not tear down a task, merge a PR, dispatch queued work, or mutate any `state/` or `data/` file other than the single report file as a side effect of generating the brief. +This skill changes no fleet state. +Do not tear down a task, merge a PR, dispatch queued work, steer a worker, answer a queued decision, clean up work, or mutate any `state/` or `data/` file other than the single report file in explicit file mode. If the state you read suggests an action - a PR ready to merge, a queued item whose gate has arrived, or a needs-decision finding - name it in its section and leave the action to the normal lifecycle and configured authority rather than taking it from inside this skill. diff --git a/README.md b/README.md index a7f69e39c2..6647ba2c80 100644 --- a/README.md +++ b/README.md @@ -171,10 +171,17 @@ Claude and grok use the slash form shown here; codex uses the same names with `$ | ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------- | | `/afk` | Enter away-mode supervision: the sub-supervisor self-handles routine notifications in bash, escalates captain-relevant events and bounded declared-external-wait rechecks as batched digests, and actively alerts if delivery gets stuck while you step away | | `/ahoy` | Recap visible session events since the prior real captain message plus visibly unanswered captain decisions, falling back to Bearings when invoked as the session's first real captain message | -| `/bearings` | Generate a standalone current-status report from bounded local fleet and registered-secondmate state, with live PR enrichment only when requested, written to a dated file in `data/` and surfaced concisely in chat; read-mostly, mutates no task state | +| `/bearings` | Generate a concise four-section chat digest from bounded local fleet and registered-secondmate state; use `/bearings file` to also replace today's dated report in `data/`, and add `include PRs` when live PR enrichment is wanted | | `/updatefirstmate` | Self-update the running firstmate and its secondmates to the latest from origin with fast-forward-only pulls, then re-read instructions and nudge secondmates | | `/stow` | Sweep the session for uncaptured durable knowledge, route each finding to its disk home per AGENTS.md, file undone next steps to the backlog, and report what is now safe to reset | +Bearings invocation examples: + +- `/bearings` returns the fresh four-section digest in chat only. +- `/bearings include PRs` keeps chat-only mode and opts into live PR enrichment. +- `/bearings file` replaces today's `data/status-report-.md` from scratch and links it from the four-section chat digest. +- `/bearings file include PRs` combines the dated report with live PR enrichment. + Agent-only reference skills live under `.agents/skills/` and are loaded by firstmate at the trigger points named in [`AGENTS.md`](AGENTS.md). ### Two-tier skill layout diff --git a/tests/fm-bearings-skill.test.sh b/tests/fm-bearings-skill.test.sh new file mode 100755 index 0000000000..4fd9d485d3 --- /dev/null +++ b/tests/fm-bearings-skill.test.sh @@ -0,0 +1,129 @@ +#!/usr/bin/env bash +# Static regression tests for the internal /bearings skill contract. +# shellcheck disable=SC2016 +set -u + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +BEARINGS_SKILL="$ROOT/.agents/skills/bearings/SKILL.md" +README="$ROOT/README.md" +AUDIENCES="$ROOT/docs/documentation-audiences.json" + +skill_body() { + awk 'BEGIN { seen = 0 } /^---$/ { seen += 1; next } seen >= 2 { print }' "$BEARINGS_SKILL" +} + +chat_contract() { + awk '/^## Chat-response contract$/{capture=1; next} capture && /^## /{exit} capture' "$BEARINGS_SKILL" +} + +test_status_skill_is_absent() { + assert_absent "$ROOT/.agents/skills/status" "internal status skill directory must be removed" + assert_absent "$ROOT/.agents/skills/status/SKILL.md" "internal status skill file must be removed" + assert_absent "$ROOT/skills/status" "public status skill directory must not exist" + assert_no_grep '.agents/skills/status/SKILL.md' "$AUDIENCES" "documentation audience inventory still lists status" + assert_no_grep '| `/status`' "$README" "README still lists /status" + pass "/status is absent from the skill and documentation surfaces" +} + +test_plain_bearings_is_chat_only_by_default() { + assert_grep 'Plain `/bearings` returns only the concise four-section chat digest.' "$BEARINGS_SKILL" \ + "plain bearings default is not chat-only" + assert_grep 'Plain `/bearings` gathers a fresh bounded snapshot and renders the four-section chat digest without creating, deleting, reading, or replacing `data/status-report-.md`.' "$BEARINGS_SKILL" \ + "plain bearings does not forbid dated report writes" + assert_grep 'Plain mode stops here and writes no report artifact.' "$BEARINGS_SKILL" \ + "plain bearings can continue into report writing" + pass "plain /bearings is chat-only and forbids report artifacts" +} + +test_file_mode_owns_prior_report_artifact_behavior() { + assert_grep 'Only `/bearings file` writes the dated markdown report artifact and then returns the concise four-section chat digest linked to that report.' "$BEARINGS_SKILL" \ + "file mode is not the only report-writing mode" + assert_grep 'Write the full report to `data/status-report-.md` using today'"'"'s date.' "$BEARINGS_SKILL" \ + "file mode does not write the dated report" + assert_grep 'If today'"'"'s file already exists, delete it first, then create a new file from scratch.' "$BEARINGS_SKILL" \ + "file mode does not replace today's report from scratch" + assert_grep 'This is the only write allowed by the skill.' "$BEARINGS_SKILL" \ + "file mode write boundary is missing" + assert_grep 'After writing the file, return the concise four-section chat digest and include the report path or link without adding a fifth section.' "$BEARINGS_SKILL" \ + "file mode does not return the linked four-section digest" + pass "/bearings file owns the prior dated-report behavior" +} + +test_file_option_is_explicit_and_prs_compose() { + assert_grep 'Treat `file` only as an explicit invocation option in the slash command.' "$BEARINGS_SKILL" \ + "file is not pinned to an explicit slash option" + assert_grep 'Do not treat natural-language requests such as "write a report", "save this", "persist it", or "make a file" as file mode unless the invocation explicitly includes the standalone `file` option.' "$BEARINGS_SKILL" \ + "file mode can be triggered fuzzily" + assert_grep 'When the captain asks to include PRs, pass the snapshot command'"'"'s live-PR opt-in.' "$BEARINGS_SKILL" \ + "live PR opt-in is missing" + assert_grep '`/bearings include PRs` remains chat-only and makes the live-PR opt-in.' "$BEARINGS_SKILL" \ + "include PRs does not compose with chat-only mode" + assert_grep '`/bearings file include PRs` writes the dated report and makes the live-PR opt-in.' "$BEARINGS_SKILL" \ + "include PRs does not compose with file mode" + pass "file is explicit and live PR enrichment composes with both modes" +} + +test_single_fresh_snapshot_source_and_authoritative_provenance() { + local body count + body=$(skill_body) + count=$(grep -cF 'bin/fm-bearings-snapshot.sh' "$BEARINGS_SKILL") + [ "$count" = 1 ] || fail "bearings should reference the snapshot owner exactly once, found $count" + assert_contains "$body" 'Run `bin/fm-bearings-snapshot.sh` at invocation time and read its compact output.' \ + "bearings does not gather a fresh snapshot at invocation time" + assert_contains "$body" 'It is the single bounded, deterministic fleet-state source for Bearings and renders TOON by default.' \ + "bearings does not name the single bounded source" + assert_contains "$body" 'Do not create or consult a second fleet-state reader, parser contract, status-event-tail interpretation, visible-session recap, ad-hoc project probe, or ad-hoc `gh-axi`/`gh` query.' \ + "bearings allows a second reader or ad-hoc probe" + assert_contains "$body" 'For registered secondmates, use the snapshot'"'"'s structured-home classification and provenance.' \ + "bearings does not use structured secondmate provenance" + assert_contains "$body" 'Structured captain-held decisions come from `decision-hold-lifecycle` and appear under `decisions_open`.' \ + "bearings does not preserve structured captain-held decisions" + assert_not_contains "$body" 'fm-fleet-snapshot.sh' "bearings creates a second canonical snapshot path" + assert_not_contains "$body" 'fm-crew-state.sh' "bearings creates an extra current-state reader" + pass "bearings keeps the fresh structured snapshot as the single source" +} + +test_chat_contract_four_sections_for_both_modes() { + local body headings expected report_headings + body=$(chat_contract) + headings=$(printf '%s\n' "$body" | sed -nE "s/^[0-9]+\. \*\*([^*]+)\*\*.*/\1/p") + expected=$(printf '%s\n' "Captain's Call" "Recently Landed" "Underway" "Charted Next") + [ "$headings" = "$expected" ] || fail "chat contract must contain exactly four numbered sections in fixed order, got: $headings" + assert_contains "$body" "Nothing needs your action right now" "Captain's Call empty-state sentence" + assert_contains "$body" "No recent completions are in the current baseline" "Recently Landed empty-state sentence" + assert_contains "$body" "Nothing is underway" "Underway empty-state sentence" + assert_contains "$body" "Nothing is queued" "Charted Next empty-state sentence" + report_headings=$(sed -nE 's/^ - \*\*(Captain.s Call|Recently Landed|Underway|Charted Next)\*\*.*/\1/p' "$BEARINGS_SKILL") + [ "$report_headings" = "$expected" ] || fail "detailed report contract must contain the same four complete sections, got: $report_headings" + assert_contains "$body" "no At Anchor section" "the At Anchor exclusion must be documented" + assert_contains "$body" "Every chat digest and file-mode report is a complete current snapshot" "both modes must be complete current snapshots" + assert_contains "$body" "Detailed decisions, plans, full gate reasons, and evidence belong in the file only when file mode is explicit" \ + "plain chat must not depend on a detailed report file" + assert_contains "$body" "In file mode, include the report path or link inside the four-section digest without adding another heading." \ + "file mode must link the report without a fifth section" + pass "both Bearings modes keep the exact four-section chat contract" +} + +test_readme_describes_bearings_modes() { + assert_grep '| `/bearings` | Generate a concise four-section chat digest from bounded local fleet and registered-secondmate state; use `/bearings file` to also replace today'"'"'s dated report in `data/`, and add `include PRs` when live PR enrichment is wanted |' "$README" \ + "README skill table does not describe chat-only default and file option" + assert_grep '- `/bearings` returns the fresh four-section digest in chat only.' "$README" \ + "README lacks plain bearings example" + assert_grep '- `/bearings include PRs` keeps chat-only mode and opts into live PR enrichment.' "$README" \ + "README lacks chat-only live PR example" + assert_grep '- `/bearings file` replaces today'"'"'s `data/status-report-.md` from scratch and links it from the four-section chat digest.' "$README" \ + "README lacks file mode example" + assert_grep '- `/bearings file include PRs` combines the dated report with live PR enrichment.' "$README" \ + "README lacks file mode live PR example" + pass "README documents Bearings default and file mode" +} + +test_status_skill_is_absent +test_plain_bearings_is_chat_only_by_default +test_file_mode_owns_prior_report_artifact_behavior +test_file_option_is_explicit_and_prs_compose +test_single_fresh_snapshot_source_and_authoritative_provenance +test_chat_contract_four_sections_for_both_modes +test_readme_describes_bearings_modes diff --git a/tests/fm-bearings-snapshot.test.sh b/tests/fm-bearings-snapshot.test.sh index f8afefa255..32c1e1b1e9 100755 --- a/tests/fm-bearings-snapshot.test.sh +++ b/tests/fm-bearings-snapshot.test.sh @@ -1863,8 +1863,8 @@ EOF # The /bearings skill is the one owner of the four-section chat-response contract. # Assert it states exactly the four fixed sections in order, each with its explicit -# empty-state sentence, documents the At Anchor exclusion, and mandates a chat that is -# materially shorter than and links to the report file. +# empty-state sentence, documents the At Anchor exclusion, and keeps file-mode links +# inside the four-section digest. test_chat_contract_four_sections() { local skill body headings report_headings expected skill="$ROOT/.agents/skills/bearings/SKILL.md" @@ -1885,8 +1885,8 @@ test_chat_contract_four_sections() { assert_contains "$(cat "$skill")" 'Never read an earlier `data/status-report-*.md`' "prior reports must not influence current output" assert_contains "$(cat "$skill")" "bounded current recent-completions baseline" "Recently Landed must be a current baseline" assert_contains "$body" "no At Anchor section" "the At Anchor exclusion must be documented" - assert_contains "$body" "materially shorter" "the chat must be materially shorter than the report file" - assert_contains "$body" "links to" "the chat must link to the report file" + assert_contains "$body" "materially shorter" "the file-mode chat must be materially shorter than the report file" + assert_contains "$body" "report path or link" "file mode must link the report from inside the digest" pass "the /bearings skill states the four-section chat contract in order, with empty-states and the At Anchor exclusion" } From 96e027e6a312d021cc0129f93e0d4c497d6a3975 Mon Sep 17 00:00:00 2001 From: Kun Chen <3233006+kunchenguid@users.noreply.github.com> Date: Wed, 29 Jul 2026 17:06:04 -0700 Subject: [PATCH 13/70] Clarify follow-up routing during validation (#1277) --- AGENTS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/AGENTS.md b/AGENTS.md index 82bcbe8e55..a558ad3bf3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -293,6 +293,7 @@ After an autonomous merge, give the captain a one-line full-URL or local-main ou 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. +Once validation starts, prefer routing new requirements to follow-up work rather than expanding the current task, unless a new requirement completely invalidates the work being validated; corrections required to satisfy already accepted intent are not new requirements. 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. From a24eac112f3ec9e0b87c13b0b2ef3d09faa1b631 Mon Sep 17 00:00:00 2001 From: Kun Chen <3233006+kunchenguid@users.noreply.github.com> Date: Wed, 29 Jul 2026 17:33:12 -0700 Subject: [PATCH 14/70] fix: honor concrete approval for project operations (#1272) * docs: add captain-approved project operation exception to hard rule 1 Firstmate stays read-only over projects by default, but when the captain clearly approves a concrete project operation and scope in the moment, firstmate may perform exactly that approved operation with its own tools. The approval is never inferred, broadened, or standing, and it does not relax the existing force, discard, unlanded-work, or merge-authority boundaries. * no-mistakes(review): Clarify captain-approved project operation boundaries * no-mistakes(document): Clarify captain-approved project operation scope * docs: cover directories and preserve the operation-or-scope alternative Widen the captain-approved project operation exception in AGENTS.md to files or directories, and restore the explicit operation-or-scope alternative that a prior pipeline auto-fix had collapsed into "and". Rework project-management SKILL.md's Remove section, which previously told firstmate to refuse project removal until a guarded helper existed; that helper was never built, so the text directly contradicted the new instruction-only exception. It now points at the exception plus the existing removal preflight it still requires unchanged. Update the one instruction-owners test assertion that hard-coded the sentence removed above, so the suite tracks current, not obsolete, text. * docs: add captain-approved project operation exception to hard rule 1 Firstmate stays read-only over projects by default, but when the captain clearly approves a concrete project operation and scope in the moment, firstmate may perform exactly that approved operation with its own tools. The approval is never inferred, broadened, or standing, and it does not relax the existing force, discard, unlanded-work, or merge-authority boundaries. * no-mistakes(review): Clarify captain-approved project operation boundaries * no-mistakes(document): Clarify captain-approved project operation scope * docs: cover directories and preserve the operation-or-scope alternative Widen the captain-approved project operation exception in AGENTS.md to files or directories, and restore the explicit operation-or-scope alternative that a prior pipeline auto-fix had collapsed into "and". Rework project-management SKILL.md's Remove section, which previously told firstmate to refuse project removal until a guarded helper existed; that helper was never built, so the text directly contradicted the new instruction-only exception. It now points at the exception plus the existing removal preflight it still requires unchanged. Update the one instruction-owners test assertion that hard-coded the sentence removed above, so the suite tracks current, not obsolete, text. * no-mistakes(review): Align project removal preflight with approved exception * no-mistakes(document): Align project removal documentation with approved exception * fix: restore removal test byte-for-byte and preserve the default sentence tests/fm-instruction-owners.test.sh had been changed to assert different text; restore it byte-for-byte to origin/main. project-management SKILL.md's Remove section now keeps the exact default "Never issue a raw removal command from Firstmate." sentence that test still asserts, immediately followed by the already-approved captain-operation-or-scope exception, so the default and the exception both stay explicit and consistent. * no-mistakes(document): Align project-write boundary documentation --- .agents/skills/project-management/SKILL.md | 10 +++++----- AGENTS.md | 15 ++++++++------- README.md | 2 +- docs/configuration.md | 2 +- 4 files changed, 15 insertions(+), 14 deletions(-) diff --git a/.agents/skills/project-management/SKILL.md b/.agents/skills/project-management/SKILL.md index af35d469ee..b1e610dd44 100644 --- a/.agents/skills/project-management/SKILL.md +++ b/.agents/skills/project-management/SKILL.md @@ -70,9 +70,9 @@ If doctor reports an environment, authentication, or daemon problem, resolve tha ## Remove -Project removal is destructive and is not one of Firstmate's current direct-write exceptions under `projects/`. -Never issue a raw removal command from Firstmate. +Project removal is destructive. First obtain the captain's explicit removal decision, then inspect the current digest and authoritative repositories for in-flight or queued work, registered secondmate clones, linked worktrees, dirty files, unpushed commits, and any other unlanded work. -If any dependency or unlanded work exists, stop and report it before changing the registry. -Until a guarded removal helper and corresponding prime-directive exception exist, report that implementation gap instead of bypassing the project-write boundary. -When a clone has already been removed through an approved guarded path, or the registry is provably stale because no clone exists, remove its registry line so navigation matches reality. +If any dependency or unlanded work exists, stop and report it before changing anything. +Never issue a raw removal command from Firstmate. +Once that preflight confirms none of the above and the captain's approval is concrete, AGENTS.md hard rule 1's captain-approved project operation exception authorizes firstmate to remove the clone directly and update its registry entry to match. +When a clone has already been removed through an approved removal, or the registry is provably stale because no clone exists, remove its registry line so navigation matches reality. diff --git a/AGENTS.md b/AGENTS.md index a558ad3bf3..26dffecd5d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,16 +14,17 @@ 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. +Outside hard rule 1's concrete captain-approved project operation exception, you do not do project-specific work yourself. +For all other project-specific work, 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. 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 inherited local-material propagation, self-update, and approved `local-only` merge paths owned by their referenced skills and scripts. + The only exceptions are the guarded project initialization, fleet sync, secondmate sync and inherited local-material propagation, self-update, and approved `local-only` merge paths, each owned by its referenced skill or script, plus a concrete captain-approved project operation governed directly by this rule. Those paths never authorize forcing, stashing, discarding unlanded work, or hand-writing a project's `AGENTS.md`. + Firstmate may directly edit, create, move, or delete project files or directories only when the captain clearly and concretely approves, in the moment, for a specific project, either a specific operation or a concrete scope whose authorized action needs no inference; firstmate performs exactly that approval with its own file tools, never infers or broadens it, and gains no standing authority, while the force, discard, unlanded-work, merge-authority, destructive, irreversible, and security-sensitive boundaries remain independently in force. 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; section 7 owns its exceptions and preserves the stronger destructive, irreversible, and security-sensitive captain boundaries. 3. **Never tear down unlanded work.** @@ -50,7 +51,7 @@ Never add an agent name as a commit co-author. 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. -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. +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 except under hard rule 1's concrete captain-approved project operation exception. ``` AGENTS.md this file (CLAUDE.md is a symlink to it) @@ -82,7 +83,7 @@ data/ personal fleet records; LOCAL, gitignored as a whole 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 +projects/ cloned repos; gitignored; read-only except under hard rule 1's concrete captain-approved project operation exception state/ volatile runtime signals; gitignored .status appended by crewmates: ": " wake-event lines, not current-state truth .turn-ended touched by turn-end hooks @@ -196,8 +197,8 @@ A restart must be a non-event because durable state and live backend inventory, ## 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. +That skill owns registry syntax, delivery-mode selection, outward-facing consent, clone and initialization procedure, safe rollback, and removal preflight. +Project creation never authorizes an unmentioned remote, and project removal never bypasses that preflight or unlanded-work checks; hard rule 1's concrete captain-approved project operation exception remains available when its exact conditions are met. Load `secondmate-provisioning` before creating, seeding, validating, launching, handing backlog to, recovering, pushing inherited local material 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. diff --git a/README.md b/README.md index 6647ba2c80..5a03c4b9b1 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ Launching a supported harness inside it instantiates your first mate - and makes - **Optional secondmates** - opt in to persistent second mates that run from isolated firstmate homes with their own `FM_HOME`, state, projects, and session lock, supervising project clones or a project-less firstmate-repo domain, kept on the primary firstmate version by guarded local fast-forwards and checked for live agent processes at session start. - **Event-driven, zero-token supervision** - a bash watcher sleeps on the fleet and wakes the first mate only when something needs you; verified primary harnesses also get a turn-end backstop that blocks or follows up on a blind stop when work is under way and supervision is not live. - **Optional X mode** - opt in with one local `.env` token so firstmate can answer your public `@myfirstmate` mentions, act on normal reversible mention requests through the same lifecycle as chat requests, acknowledge spawned work, and post up to three public-safe completion follow-ups within seven days for genuine milestones and the final outcome without changing non-X behavior; dry-run preview records would-be replies and dismissals locally before go-live. -- **Guarded by construction** - the first mate is read-only over your projects except for the guarded paths authorized by [hard rule 1](AGENTS.md#1-identity-and-prime-directives), with fleet sync's safe branch pruning remaining part of the fleet-sync exception; crewmates make every project change behind the configured merge authority. +- **Strict project boundary** - the first mate is read-only over your projects except for the narrow guarded and captain-approved operations authorized by [hard rule 1](AGENTS.md#1-identity-and-prime-directives), including fleet sync's guarded safe branch pruning; crewmates make every other project change behind the configured merge authority. - **Restart-proof** - all state lives on disk and in the active session backend (tmux by hard default, herdr or cmux when selected or auto-detected, zellij/orca when explicitly selected); kill the session anytime and the next one reconciles, including confirmed-dead secondmate agents, and carries on. Full detail on every feature lives in [docs/architecture.md](docs/architecture.md). diff --git a/docs/configuration.md b/docs/configuration.md index adf215b408..6bf78b7900 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -12,7 +12,7 @@ This section is the single owner of the top-level operational-home layout; produ The tracked code root contains the shared instruction, skill, documentation, workflow, and `bin/` surfaces, while each effective `FM_HOME` contains private operational directories. `data/` holds durable private fleet records such as the project and secondmate registries, captain preferences, optional shared captain preferences, learnings, backlog, briefs, and scout reports. `state/` holds volatile runtime records such as task metadata, append-only status events, endpoint signals, watcher and wake-queue coordination, away-mode state, generated X-mode artifacts, private secondmate config-reread generations with their retry and quarantine state, and parent-owned secondmate pending-reply records under `state/pending-replies/` (`bin/fm-pending-reply-lib.sh`). -`config/` holds local gitignored operating choices, and `projects/` holds the local project clones that Firstmate reads but changes only through the guarded exceptions in `AGENTS.md`. +`config/` holds local gitignored operating choices, and `projects/` holds the local project clones that Firstmate reads but changes only through the narrow guarded and concrete captain-approved exceptions in `AGENTS.md`. `bin/fm-spawn.sh` owns the base task-metadata fields it emits, while the runtime-backend section below owns backend-specific fields and selector interpretation. The producing PR and X helpers own the fields they append, `bin/fm-classify-lib.sh` owns status-event vocabulary, and `bin/fm-crew-state.sh` owns current-state reconciliation. From 0bbb27b53767cdb17306f0e190804c10acafb284 Mon Sep 17 00:00:00 2001 From: Kun Chen <3233006+kunchenguid@users.noreply.github.com> Date: Wed, 29 Jul 2026 17:58:01 -0700 Subject: [PATCH 15/70] fix(skills): route new project intake through secondmate scopes (#1275) * Route project intake through secondmate scopes * no-mistakes(test): Guard all main-home project registry mutations * no-mistakes(document): Consolidate secondmate routing documentation * no-mistakes: apply CI fixes * Restore new-project routing scope * no-mistakes(document): Clarify secondmate routing for new-project intake * no-mistakes: apply CI fixes --- .agents/skills/project-management/SKILL.md | 7 +++++++ AGENTS.md | 2 ++ 2 files changed, 9 insertions(+) diff --git a/.agents/skills/project-management/SKILL.md b/.agents/skills/project-management/SKILL.md index b1e610dd44..f4c62a5790 100644 --- a/.agents/skills/project-management/SKILL.md +++ b/.agents/skills/project-management/SKILL.md @@ -3,6 +3,7 @@ name: project-management description: >- Agent-only procedure for Firstmate project management. Use before adding, creating, removing, or initializing a project. + Cloning or registering a project is add intake and uses the same trigger. Owns project add, create, clone, remove, initialization, registry, delivery-mode, autonomy, and outward-consent decisions. user-invocable: false metadata: @@ -12,6 +13,7 @@ metadata: # project-management Use this procedure before adding, creating, removing, or initializing a project. +Cloning or registering a project is add intake and uses the same trigger. This skill is the single owner of Firstmate's project-management procedure. It does not replace `secondmate-provisioning`, which owns project clones inside persistent secondmate homes. @@ -22,6 +24,11 @@ Use the registry format and parser contract owned by the header of `bin/fm-proje Keep each registry description useful for identifying the project, but keep delivery posture, captain-private state, and detailed project knowledge in their existing designated homes. Do not turn the registry into project documentation. +Before adding, cloning, creating, or registering any project in the main home, inspect the authoritative `data/secondmates.md` routing table and judge every existing natural-language `scope:` against the proposed project or domain. +Apply `AGENTS.md` section 7's authoritative secondmate routing rules; if an existing scope owns that domain, route the new-project operation or work there instead of creating or registering a duplicate main-home clone. +Absence from the main `data/projects.md` registry is never evidence that no second mate owns the domain. +If the owning second mate cannot accept the route, report that concrete blocker or obtain an explicit captain redirection rather than silently duplicating the project in the main home. + Resolve the project name, destination, delivery mode, and autonomy posture before changing local or remote state. Keep a newly added clone and its registry entry consistent, and roll back only artifacts created by the incomplete operation when a later initialization step fails and that rollback is safe. Do not overwrite or repurpose an existing path. diff --git a/AGENTS.md b/AGENTS.md index 26dffecd5d..f61336a5eb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -197,6 +197,7 @@ A restart must be a non-event because durable state and live backend inventory, ## 6. Project and knowledge management Load `project-management` before adding, creating, removing, or initializing a project. +Cloning or registering a project is add intake and uses the same trigger. That skill owns registry syntax, delivery-mode selection, outward-facing consent, clone and initialization procedure, safe rollback, and removal preflight. Project creation never authorizes an unmentioned remote, and project removal never bypasses that preflight or unlanded-work checks; hard rule 1's concrete captain-approved project operation exception remains available when its exact conditions are met. @@ -479,6 +480,7 @@ These skills are not captain-invocable; load them only at their precise triggers - `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. - `project-management` - load before adding, creating, removing, or initializing a project. + Cloning or registering a project is add intake and uses the same trigger. - `stuck-crewmate-recovery` - load when the session-start digest reports an ordinary direct report's endpoint dead or its metadata has no window, or after a stale wake, looping pane, repeated confusion, an answered-by-brief question, an unresponsive crewmate, or a failed steer. - `secondmate-provisioning` - load before creating, seeding, validating, launching, handing backlog to, recovering, pushing inherited local material into, or retiring a secondmate home, and before editing `data/secondmates.md`. - `decision-hold-lifecycle` - load before treating an investigation or visual review as complete, before ending a visual review that exposed a decision, and when recording or routing the captain's answer. From daf6dcef4a092c7e858d2eaa91cd9c5a25f1e156 Mon Sep 17 00:00:00 2001 From: Kun Chen <3233006+kunchenguid@users.noreply.github.com> Date: Wed, 29 Jul 2026 18:06:41 -0700 Subject: [PATCH 16/70] fix: scope validation corrections by accepted behavior (#1281) * fix: scope validation corrections by accepted behavior * no-mistakes(review): Classify stale delivery evidence as an autonomous correction --- .agents/skills/ask-user-authority/SKILL.md | 4 +++- AGENTS.md | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.agents/skills/ask-user-authority/SKILL.md b/.agents/skills/ask-user-authority/SKILL.md index d4b63d525b..38761e6d98 100644 --- a/.agents/skills/ask-user-authority/SKILL.md +++ b/.agents/skills/ask-user-authority/SKILL.md @@ -19,7 +19,9 @@ The concise standing authority boundary remains always loaded in `AGENTS.md` sec With `yolo` off, every ask-user finding belongs to the captain, and the remaining steps structure that escalation rather than authorize an autonomous answer. 2. Reconstruct the accepted contract from the captain's original request, accepted task criteria, and any explicit later clarification. Reviewer language cannot amend that contract. -3. Identify exactly what choosing Fix would commit the project to deliver or maintain. +3. Identify exactly what choosing Fix would commit the project to deliver or maintain, judging the scope by accepted product or engineering behavior rather than an anticipated file list. + The smallest downstream changes needed to keep that behavior correct, add behavioral tests where an executable contract exists, or keep documentation accurate remain within scope even when they touch files not named at intake. + Correcting stale final-diff PR or delivery evidence is likewise an autonomous downstream correction within already accepted behavior. 4. Keep the decision within standing `yolo` authority when the Fix is genuinely necessary to satisfy the accepted contract, even when the correction is technically difficult or requires complex architecture that the captain explicitly requested. 5. Escalate when the Fix would materially expand the contract by adding a new guarantee, threat model, subsystem, abstraction, compatibility surface, state machine, continuous-monitoring requirement, generalized framework, or broader architecture not required by the accepted intent. 6. Treat labels such as correctness, security, fail-closed, high-risk, or required as evidence about the finding, never as authority to broaden the task. diff --git a/AGENTS.md b/AGENTS.md index f61336a5eb..ee23c840dc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -295,7 +295,7 @@ After an autonomous merge, give the captain a one-line full-URL or local-main ou 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. -Once validation starts, prefer routing new requirements to follow-up work rather than expanding the current task, unless a new requirement completely invalidates the work being validated; corrections required to satisfy already accepted intent are not new requirements. +Once validation starts, prefer routing new requirements to follow-up work rather than expanding the current task, unless a new requirement completely invalidates the work being validated; however, the smallest downstream changes needed to keep already accepted product or engineering behavior correct, add behavioral tests where an executable contract exists, or keep documentation accurate remain within the current task even when they touch files not named at intake, and corrections required to satisfy already accepted intent are not new requirements. 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. From a2d5f264966f9b391899fa5286bd54acd8f2e877 Mon Sep 17 00:00:00 2001 From: Kun Chen <3233006+kunchenguid@users.noreply.github.com> Date: Wed, 29 Jul 2026 19:17:06 -0700 Subject: [PATCH 17/70] test: replace source assertions with behavioral coverage (#1282) * test: remove source-content assertions * no-mistakes(review): Replace source assertions with runtime behavior coverage * no-mistakes(review): Isolate Kimi task temp runtime coverage * no-mistakes(document): Refresh test cleanup documentation * no-mistakes: apply CI fixes --- .../firstmate-coding-guidelines/SKILL.md | 1 + .github/workflows/ci.yml | 4 +- bin/fm-test-isolation-proof.sh | 5 - bin/fm-test-run.sh | 58 ++- docs/fm-test-isolation-proof.json | 210 ++-------- docs/fm-test-isolation-proof.md | 179 +++------ docs/fm-test-portable-shards.md | 133 +++---- docs/sessionstart-nudge.md | 2 - docs/verification/supervision.md | 1 - tests/fm-arm-pretool-check.test.sh | 95 ----- tests/fm-ask-user-authority.test.sh | 128 +----- tests/fm-backend-herdr.test.sh | 158 -------- tests/fm-backend.test.sh | 32 -- tests/fm-bearings-skill.test.sh | 129 ------ tests/fm-bearings-snapshot.test.sh | 30 -- tests/fm-bootstrap.test.sh | 14 - tests/fm-calm-pi-extension.test.sh | 62 --- tests/fm-captain-translation-contract.test.sh | 293 -------------- tests/fm-cd-pretool-check.test.sh | 67 ---- tests/fm-claude-stop-autoarm.test.sh | 23 -- tests/fm-documentation-audiences.test.sh | 19 - tests/fm-gate-refuse.test.sh | 31 -- tests/fm-gotmp.test.sh | 44 +-- tests/fm-install-herdr.test.sh | 106 ----- tests/fm-instruction-owners.test.sh | 305 --------------- tests/fm-kimi-harness.test.sh | 58 +-- tests/fm-lint.test.sh | 84 +--- tests/fm-nm-test-contract.test.sh | 127 ------ tests/fm-no-mistakes-ownership.test.sh | 39 -- tests/fm-pi-watch-extension.test.sh | 81 ---- tests/fm-pr-check-security.test.sh | 5 - tests/fm-quota-array-dispatch.test.sh | 369 ------------------ tests/fm-secondmate-sync.test.sh | 10 - ...m-send-secondmate-marker-herdr-e2e.test.sh | 44 +-- tests/fm-sessionstart-nudge.test.sh | 39 -- tests/fm-stow-contract.test.sh | 37 -- tests/fm-subagent-pretool-check.test.sh | 35 +- tests/fm-test-isolation-proof.test.sh | 177 +-------- tests/fm-test-run.test.sh | 124 +----- tests/fm-turnend-guard.test.sh | 89 ----- tests/no-mistakes-required-workflow.test.sh | 96 ----- 41 files changed, 216 insertions(+), 3327 deletions(-) delete mode 100755 tests/fm-bearings-skill.test.sh delete mode 100755 tests/fm-captain-translation-contract.test.sh delete mode 100755 tests/fm-install-herdr.test.sh delete mode 100755 tests/fm-instruction-owners.test.sh delete mode 100755 tests/fm-nm-test-contract.test.sh delete mode 100755 tests/fm-no-mistakes-ownership.test.sh delete mode 100755 tests/fm-quota-array-dispatch.test.sh delete mode 100755 tests/fm-stow-contract.test.sh delete mode 100755 tests/no-mistakes-required-workflow.test.sh diff --git a/.agents/skills/firstmate-coding-guidelines/SKILL.md b/.agents/skills/firstmate-coding-guidelines/SKILL.md index c7126ff358..8bbb275dae 100644 --- a/.agents/skills/firstmate-coding-guidelines/SKILL.md +++ b/.agents/skills/firstmate-coding-guidelines/SKILL.md @@ -97,6 +97,7 @@ Run `bin/fm-doc-audience-check.sh`; it enforces classification, README setup rou - `bin/*.sh` and `bin/backends/*.sh` must pass `shellcheck`. - Run `bin/fm-lint.sh` before treating a script change as done; it is the single owner of the lint definition (file set, config, and pinned shellcheck version) that CI and the no-mistakes pre-push gate both invoke, and it refuses to run under any other shellcheck version. - Colocate tests with the existing pattern in `tests/`, name them `.test.sh`, and extend an existing script rather than inventing a new runner. +- Tests must exercise behavior through an executable or public interface and must never assert implementation-source bytes, including through parsers, regexes, snapshots, or indirect wrappers. - A maintainer-verification record under `docs/verification/` records active empirical facts, not assumptions or task chronology. - Include the date, version, exact commands run, and exact output needed to support the current guarantee. - Keep incident chronology and delivery evidence in private task reports or PR evidence unless a concise rationale is required to maintain a current safety boundary. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 407a29c88f..8530c5f9b2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -342,8 +342,8 @@ jobs: bearings_output=$(/bin/bash tests/fm-bearings-snapshot.test.sh) printf '%s\n' "$bearings_output" bearings_count=$(printf '%s\n' "$bearings_output" | grep -c '^ok - ') - [ "$bearings_count" -eq 42 ] || { - echo "::error::expected 42 Bearings tests, got $bearings_count" + [ "$bearings_count" -eq 41 ] || { + echo "::error::expected 41 Bearings tests, got $bearings_count" exit 1 } diff --git a/bin/fm-test-isolation-proof.sh b/bin/fm-test-isolation-proof.sh index 0a14a48022..f84f8ed09e 100755 --- a/bin/fm-test-isolation-proof.sh +++ b/bin/fm-test-isolation-proof.sh @@ -152,7 +152,6 @@ list_parallel_candidates() { tests/fm-arm-pretool-check.test.sh tests/fm-backend-herdr.test.sh tests/fm-brief.test.sh -tests/fm-captain-translation-contract.test.sh tests/fm-cd-pretool-check.test.sh tests/fm-composer-ghost.test.sh tests/fm-composer-lib.test.sh @@ -161,10 +160,7 @@ tests/fm-decision-hold-lifecycle.test.sh tests/fm-ensure-agents-md.test.sh tests/fm-grok-harness.test.sh tests/fm-herdr-lab.test.sh -tests/fm-instruction-owners.test.sh tests/fm-lint.test.sh -tests/fm-nm-test-contract.test.sh -tests/fm-no-mistakes-ownership.test.sh tests/fm-pi-primary-types.test.sh tests/fm-pr-merge.test.sh tests/fm-review-diff.test.sh @@ -172,7 +168,6 @@ tests/fm-send-popup-settle.test.sh tests/fm-send-settle.test.sh tests/fm-send-strict.test.sh tests/fm-spawn-batch.test.sh -tests/fm-stow-contract.test.sh tests/fm-supervision-instructions.test.sh tests/fm-test-run.test.sh tests/fm-tmux-submit-busy.test.sh diff --git a/bin/fm-test-run.sh b/bin/fm-test-run.sh index 255c1cdc31..314232ae88 100755 --- a/bin/fm-test-run.sh +++ b/bin/fm-test-run.sh @@ -38,7 +38,7 @@ # silently pass as a gate skip. # --jobs N run the selected scripts with up to N concurrent workers. # Default is 1 (serial). N>1 is allowed only when every -# selected script is in the Phase 2 proven-isolated set +# selected script is in the proven-isolated set # (bin/fm-test-isolation-proof.sh --list). Cap is 8. Stateful # families never schedule under --jobs. # -h, --help print this header @@ -118,14 +118,13 @@ now_ms() { family_for_basename() { case "$1" in fm-arm-pretool-check.test.sh|fm-ask-user-authority.test.sh|fm-brief.test.sh|\ - fm-calm-pi-extension.test.sh|fm-captain-translation-contract.test.sh|fm-cd-pretool-check.test.sh|\ + fm-calm-pi-extension.test.sh|fm-cd-pretool-check.test.sh|\ fm-composer-ghost.test.sh|fm-composer-lib.test.sh|\ fm-crew-state.test.sh|fm-decision-hold-lifecycle.test.sh|\ fm-documentation-audiences.test.sh|fm-ensure-agents-md.test.sh|fm-grok-harness.test.sh|\ - fm-kimi-harness.test.sh|fm-herdr-lab.test.sh|fm-instruction-owners.test.sh|fm-lint.test.sh|\ - fm-install-herdr.test.sh|fm-nm-test-contract.test.sh|fm-no-mistakes-ownership.test.sh|\ + fm-kimi-harness.test.sh|fm-herdr-lab.test.sh|fm-lint.test.sh|\ fm-operational-input.test.sh|fm-pi-primary-types.test.sh|\ - fm-send-popup-settle.test.sh|fm-send-settle.test.sh|fm-stow-contract.test.sh|\ + fm-send-popup-settle.test.sh|fm-send-settle.test.sh|\ fm-subagent-pretool-check.test.sh|\ fm-supervision-instructions.test.sh|fm-tmux-submit-busy.test.sh|fm-transition-lib.test.sh|\ fm-test-run.test.sh|fm-test-isolation-proof.test.sh) @@ -229,7 +228,7 @@ real-herdr-gated EOF } -# Exact Phase 2 proven-isolated candidate set (same paths as +# Exact proven-isolated candidate set (same paths as # bin/fm-test-isolation-proof.sh --list). Do not expand without a new concurrent # isolation proof archive. list_proven_isolated() { @@ -237,7 +236,6 @@ list_proven_isolated() { tests/fm-arm-pretool-check.test.sh tests/fm-backend-herdr.test.sh tests/fm-brief.test.sh -tests/fm-captain-translation-contract.test.sh tests/fm-cd-pretool-check.test.sh tests/fm-composer-ghost.test.sh tests/fm-composer-lib.test.sh @@ -246,10 +244,7 @@ tests/fm-decision-hold-lifecycle.test.sh tests/fm-ensure-agents-md.test.sh tests/fm-grok-harness.test.sh tests/fm-herdr-lab.test.sh -tests/fm-instruction-owners.test.sh tests/fm-lint.test.sh -tests/fm-nm-test-contract.test.sh -tests/fm-no-mistakes-ownership.test.sh tests/fm-pi-primary-types.test.sh tests/fm-pr-merge.test.sh tests/fm-review-diff.test.sh @@ -257,7 +252,6 @@ tests/fm-send-popup-settle.test.sh tests/fm-send-settle.test.sh tests/fm-send-strict.test.sh tests/fm-spawn-batch.test.sh -tests/fm-stow-contract.test.sh tests/fm-supervision-instructions.test.sh tests/fm-test-run.test.sh tests/fm-tmux-submit-busy.test.sh @@ -266,47 +260,41 @@ tests/fm-x-mode.test.sh EOF } -# Portable parallel shard 1: LPT balance of the proven-isolated set using -# Phase 1 serial duration averages from CI timing artifacts on main after -# #825/#832/#834 (docs/fm-test-portable-shards.md). Execution order is longest -# first so wall-clock stays near the balanced sum. +# Portable parallel shard 1: LPT balance of the proven-isolated set using the +# current concurrent-proof durations in docs/fm-test-isolation-proof.json. +# Execution order is longest first so wall-clock stays near the balanced sum. list_portable_parallel_1() { cat <<'EOF' -tests/fm-arm-pretool-check.test.sh +tests/fm-x-mode.test.sh tests/fm-cd-pretool-check.test.sh -tests/fm-backend-herdr.test.sh -tests/fm-pr-merge.test.sh +tests/fm-decision-hold-lifecycle.test.sh tests/fm-test-run.test.sh -tests/fm-send-popup-settle.test.sh +tests/fm-composer-ghost.test.sh +tests/fm-grok-harness.test.sh +tests/fm-lint.test.sh +tests/fm-pi-primary-types.test.sh tests/fm-review-diff.test.sh tests/fm-brief.test.sh -tests/fm-ensure-agents-md.test.sh -tests/fm-instruction-owners.test.sh -tests/fm-pi-primary-types.test.sh tests/fm-transition-lib.test.sh -tests/fm-composer-lib.test.sh -tests/fm-stow-contract.test.sh EOF } # Portable parallel shard 2: the complementary LPT half of the proven set. list_portable_parallel_2() { cat <<'EOF' -tests/fm-decision-hold-lifecycle.test.sh -tests/fm-x-mode.test.sh -tests/fm-herdr-lab.test.sh +tests/fm-backend-herdr.test.sh +tests/fm-arm-pretool-check.test.sh tests/fm-crew-state.test.sh -tests/fm-grok-harness.test.sh -tests/fm-spawn-batch.test.sh -tests/fm-send-strict.test.sh +tests/fm-herdr-lab.test.sh +tests/fm-pr-merge.test.sh +tests/fm-send-popup-settle.test.sh tests/fm-tmux-submit-busy.test.sh -tests/fm-composer-ghost.test.sh tests/fm-send-settle.test.sh +tests/fm-send-strict.test.sh +tests/fm-spawn-batch.test.sh tests/fm-supervision-instructions.test.sh -tests/fm-lint.test.sh -tests/fm-nm-test-contract.test.sh -tests/fm-captain-translation-contract.test.sh -tests/fm-no-mistakes-ownership.test.sh +tests/fm-ensure-agents-md.test.sh +tests/fm-composer-lib.test.sh EOF } diff --git a/docs/fm-test-isolation-proof.json b/docs/fm-test-isolation-proof.json index 92e227c075..ec605bf10f 100644 --- a/docs/fm-test-isolation-proof.json +++ b/docs/fm-test-isolation-proof.json @@ -1,190 +1,36 @@ { "concurrency": 4, - "finished_at": "2026-07-25T08:44:54Z", + "finished_at": "2026-07-29T23:21:46Z", "fm_test_run_jobs_enabled": false, "kind": "isolation-proof", "production_sharding_enabled": false, - "run_id": "fm-isolation-1784968984050-13742", + "run_id": "fm-isolation-1785367157179-18165", "scripts": [ - { - "duration_ms": 26535, - "exit": 0, - "path": "tests/fm-arm-pretool-check.test.sh", - "worker": 1 - }, - { - "duration_ms": 29446, - "exit": 0, - "path": "tests/fm-backend-herdr.test.sh", - "worker": 2 - }, - { - "duration_ms": 973, - "exit": 0, - "path": "tests/fm-brief.test.sh", - "worker": 3 - }, - { - "duration_ms": 181, - "exit": 0, - "path": "tests/fm-captain-translation-contract.test.sh", - "worker": 4 - }, - { - "duration_ms": 17218, - "exit": 0, - "path": "tests/fm-cd-pretool-check.test.sh", - "worker": 5 - }, - { - "duration_ms": 1810, - "exit": 0, - "path": "tests/fm-composer-ghost.test.sh", - "worker": 6 - }, - { - "duration_ms": 66, - "exit": 0, - "path": "tests/fm-composer-lib.test.sh", - "worker": 7 - }, - { - "duration_ms": 15250, - "exit": 0, - "path": "tests/fm-crew-state.test.sh", - "worker": 8 - }, - { - "duration_ms": 18509, - "exit": 0, - "path": "tests/fm-decision-hold-lifecycle.test.sh", - "worker": 9 - }, - { - "duration_ms": 358, - "exit": 0, - "path": "tests/fm-ensure-agents-md.test.sh", - "worker": 10 - }, - { - "duration_ms": 5276, - "exit": 0, - "path": "tests/fm-grok-harness.test.sh", - "worker": 11 - }, - { - "duration_ms": 11199, - "exit": 0, - "path": "tests/fm-herdr-lab.test.sh", - "worker": 12 - }, - { - "duration_ms": 297, - "exit": 0, - "path": "tests/fm-instruction-owners.test.sh", - "worker": 13 - }, - { - "duration_ms": 4882, - "exit": 0, - "path": "tests/fm-lint.test.sh", - "worker": 14 - }, - { - "duration_ms": 180, - "exit": 0, - "path": "tests/fm-nm-test-contract.test.sh", - "worker": 15 - }, - { - "duration_ms": 35, - "exit": 0, - "path": "tests/fm-no-mistakes-ownership.test.sh", - "worker": 16 - }, - { - "duration_ms": 1842, - "exit": 0, - "path": "tests/fm-pi-primary-types.test.sh", - "worker": 17 - }, - { - "duration_ms": 6630, - "exit": 0, - "path": "tests/fm-pr-merge.test.sh", - "worker": 18 - }, - { - "duration_ms": 2410, - "exit": 0, - "path": "tests/fm-review-diff.test.sh", - "worker": 19 - }, - { - "duration_ms": 4496, - "exit": 0, - "path": "tests/fm-send-popup-settle.test.sh", - "worker": 20 - }, - { - "duration_ms": 2179, - "exit": 0, - "path": "tests/fm-send-settle.test.sh", - "worker": 21 - }, - { - "duration_ms": 1390, - "exit": 0, - "path": "tests/fm-send-strict.test.sh", - "worker": 22 - }, - { - "duration_ms": 626, - "exit": 0, - "path": "tests/fm-spawn-batch.test.sh", - "worker": 23 - }, - { - "duration_ms": 52, - "exit": 0, - "path": "tests/fm-stow-contract.test.sh", - "worker": 24 - }, - { - "duration_ms": 336, - "exit": 0, - "path": "tests/fm-supervision-instructions.test.sh", - "worker": 25 - }, - { - "duration_ms": 8900, - "exit": 0, - "path": "tests/fm-test-run.test.sh", - "worker": 26 - }, - { - "duration_ms": 1845, - "exit": 0, - "path": "tests/fm-tmux-submit-busy.test.sh", - "worker": 27 - }, - { - "duration_ms": 96, - "exit": 0, - "path": "tests/fm-transition-lib.test.sh", - "worker": 28 - }, - { - "duration_ms": 34920, - "exit": 0, - "path": "tests/fm-x-mode.test.sh", - "worker": 29 - } + {"duration_ms": 46788, "exit": 0, "path": "tests/fm-arm-pretool-check.test.sh", "worker": 1}, + {"duration_ms": 48294, "exit": 0, "path": "tests/fm-backend-herdr.test.sh", "worker": 2}, + {"duration_ms": 2224, "exit": 0, "path": "tests/fm-brief.test.sh", "worker": 3}, + {"duration_ms": 34207, "exit": 0, "path": "tests/fm-cd-pretool-check.test.sh", "worker": 4}, + {"duration_ms": 9065, "exit": 0, "path": "tests/fm-composer-ghost.test.sh", "worker": 5}, + {"duration_ms": 64, "exit": 0, "path": "tests/fm-composer-lib.test.sh", "worker": 6}, + {"duration_ms": 25365, "exit": 0, "path": "tests/fm-crew-state.test.sh", "worker": 7}, + {"duration_ms": 30771, "exit": 0, "path": "tests/fm-decision-hold-lifecycle.test.sh", "worker": 8}, + {"duration_ms": 581, "exit": 0, "path": "tests/fm-ensure-agents-md.test.sh", "worker": 9}, + {"duration_ms": 6251, "exit": 0, "path": "tests/fm-grok-harness.test.sh", "worker": 10}, + {"duration_ms": 15422, "exit": 0, "path": "tests/fm-herdr-lab.test.sh", "worker": 11}, + {"duration_ms": 5237, "exit": 0, "path": "tests/fm-lint.test.sh", "worker": 12}, + {"duration_ms": 2945, "exit": 0, "path": "tests/fm-pi-primary-types.test.sh", "worker": 13}, + {"duration_ms": 8564, "exit": 0, "path": "tests/fm-pr-merge.test.sh", "worker": 14}, + {"duration_ms": 2875, "exit": 0, "path": "tests/fm-review-diff.test.sh", "worker": 15}, + {"duration_ms": 5644, "exit": 0, "path": "tests/fm-send-popup-settle.test.sh", "worker": 16}, + {"duration_ms": 2911, "exit": 0, "path": "tests/fm-send-settle.test.sh", "worker": 17}, + {"duration_ms": 2747, "exit": 0, "path": "tests/fm-send-strict.test.sh", "worker": 18}, + {"duration_ms": 855, "exit": 0, "path": "tests/fm-spawn-batch.test.sh", "worker": 19}, + {"duration_ms": 703, "exit": 0, "path": "tests/fm-supervision-instructions.test.sh", "worker": 20}, + {"duration_ms": 15674, "exit": 0, "path": "tests/fm-test-run.test.sh", "worker": 21}, + {"duration_ms": 4816, "exit": 0, "path": "tests/fm-tmux-submit-busy.test.sh", "worker": 22}, + {"duration_ms": 248, "exit": 0, "path": "tests/fm-transition-lib.test.sh", "worker": 23}, + {"duration_ms": 52939, "exit": 0, "path": "tests/fm-x-mode.test.sh", "worker": 24} ], - "started_at": "2026-07-25T08:43:04Z", - "summary": { - "duration_ms": 110623, - "failed": 0, - "total": 29 - } + "started_at": "2026-07-29T23:19:17Z", + "summary": {"duration_ms": 149010, "failed": 0, "total": 24} } diff --git a/docs/fm-test-isolation-proof.md b/docs/fm-test-isolation-proof.md index 19e4b6a516..716dca73a5 100644 --- a/docs/fm-test-isolation-proof.md +++ b/docs/fm-test-isolation-proof.md @@ -1,48 +1,30 @@ -# Firstmate test isolation proof (Phase 2) +# Firstmate test isolation proof -This document is the archived concurrent isolation proof for the portable parallel candidate set. -It is the human-readable companion to `bin/fm-test-isolation-proof.sh`. -Phase 4 production portable shards and bounded local `fm-test-run.sh --jobs` for this exact set are owned by `bin/fm-test-run.sh` and documented in [fm-test-portable-shards.md](fm-test-portable-shards.md). -The archived proof JSON below still records the Phase 2 proof-time flags (`production_sharding_enabled` / `fm_test_run_jobs_enabled` false at proof time). +This record is the concurrent isolation proof for the portable parallel candidate set. +`bin/fm-test-isolation-proof.sh` is the authoritative harness and `docs/fm-test-isolation-proof.json` is the machine-readable result. +`bin/fm-test-run.sh` owns the production lane partition. -## Owner +## Verification -- Harness: `bin/fm-test-isolation-proof.sh` -- Contract tests: `tests/fm-test-isolation-proof.test.sh` -- Family labels (Phase 1): `bin/fm-test-run.sh` -- Timing evidence used for planning: CI artifact `fm-test-timing` from Phase 1 PR #825 - -## Proof posture +- Date: 2026-07-29 +- Command: `bin/fm-test-isolation-proof.sh --jobs 4 --json /tmp/fm-source-content-test-cleanup-r1-isolation.json` +- Result: `FM_ISOLATION_SUMMARY total=24 failed=0 concurrency=4 duration_ms=149010` | Field | Value | |---|---| -| `run_id` | `fm-isolation-1784968984050-13742` | -| `started_at` | `2026-07-25T08:43:04Z` | -| `finished_at` | `2026-07-25T08:44:54Z` | -| concurrency | **4** | -| candidates | **29** | -| failed | **0** | -| wall duration_ms | **110623** (~110.6s) | -| `production_sharding_enabled` | `False` | -| `fm_test_run_jobs_enabled` | `False` | -| host proof date | 2026-07-25 (UTC day of archive write) | - -Isolation checks that passed with this run: - -- Distinct mode-`0700` temporary roots per worker under a proof-owned parent -- Per-worker `TMPDIR`/`TMP` so `mktemp` / `fm_test_tmproot` stay private -- Ambient `FM_HOME` / `FM_*_OVERRIDE` cleared for each worker -- `git config --global` snapshot unchanged before/after the matrix -- Aggregate failure reporting (any non-zero candidate fails the harness; no retry-until-green) +| `run_id` | `fm-isolation-1785367157179-18165` | +| `started_at` | `2026-07-29T23:19:17Z` | +| `finished_at` | `2026-07-29T23:21:46Z` | +| concurrency | 4 | +| candidates | 24 | +| failed | 0 | +| wall duration | 149010 ms | -## Exact candidate set - -Sorted paths as selected by `bin/fm-test-isolation-proof.sh --list` at proof time: +## Candidate set - `tests/fm-arm-pretool-check.test.sh` - `tests/fm-backend-herdr.test.sh` - `tests/fm-brief.test.sh` -- `tests/fm-captain-translation-contract.test.sh` - `tests/fm-cd-pretool-check.test.sh` - `tests/fm-composer-ghost.test.sh` - `tests/fm-composer-lib.test.sh` @@ -51,10 +33,7 @@ Sorted paths as selected by `bin/fm-test-isolation-proof.sh --list` at proof tim - `tests/fm-ensure-agents-md.test.sh` - `tests/fm-grok-harness.test.sh` - `tests/fm-herdr-lab.test.sh` -- `tests/fm-instruction-owners.test.sh` - `tests/fm-lint.test.sh` -- `tests/fm-nm-test-contract.test.sh` -- `tests/fm-no-mistakes-ownership.test.sh` - `tests/fm-pi-primary-types.test.sh` - `tests/fm-pr-merge.test.sh` - `tests/fm-review-diff.test.sh` @@ -62,109 +41,51 @@ Sorted paths as selected by `bin/fm-test-isolation-proof.sh --list` at proof tim - `tests/fm-send-settle.test.sh` - `tests/fm-send-strict.test.sh` - `tests/fm-spawn-batch.test.sh` -- `tests/fm-stow-contract.test.sh` - `tests/fm-supervision-instructions.test.sh` - `tests/fm-test-run.test.sh` - `tests/fm-tmux-submit-busy.test.sh` - `tests/fm-transition-lib.test.sh` - `tests/fm-x-mode.test.sh` -## Per-candidate durations (concurrent run) +## Durations | duration_ms | exit | worker | script | |---:|---:|---:|---| -| 34920 | 0 | 29 | `tests/fm-x-mode.test.sh` | -| 29446 | 0 | 2 | `tests/fm-backend-herdr.test.sh` | -| 26535 | 0 | 1 | `tests/fm-arm-pretool-check.test.sh` | -| 18509 | 0 | 9 | `tests/fm-decision-hold-lifecycle.test.sh` | -| 17218 | 0 | 5 | `tests/fm-cd-pretool-check.test.sh` | -| 15250 | 0 | 8 | `tests/fm-crew-state.test.sh` | -| 11199 | 0 | 12 | `tests/fm-herdr-lab.test.sh` | -| 8900 | 0 | 26 | `tests/fm-test-run.test.sh` | -| 6630 | 0 | 18 | `tests/fm-pr-merge.test.sh` | -| 5276 | 0 | 11 | `tests/fm-grok-harness.test.sh` | -| 4882 | 0 | 14 | `tests/fm-lint.test.sh` | -| 4496 | 0 | 20 | `tests/fm-send-popup-settle.test.sh` | -| 2410 | 0 | 19 | `tests/fm-review-diff.test.sh` | -| 2179 | 0 | 21 | `tests/fm-send-settle.test.sh` | -| 1845 | 0 | 27 | `tests/fm-tmux-submit-busy.test.sh` | -| 1842 | 0 | 17 | `tests/fm-pi-primary-types.test.sh` | -| 1810 | 0 | 6 | `tests/fm-composer-ghost.test.sh` | -| 1390 | 0 | 22 | `tests/fm-send-strict.test.sh` | -| 973 | 0 | 3 | `tests/fm-brief.test.sh` | -| 626 | 0 | 23 | `tests/fm-spawn-batch.test.sh` | -| 358 | 0 | 10 | `tests/fm-ensure-agents-md.test.sh` | -| 336 | 0 | 25 | `tests/fm-supervision-instructions.test.sh` | -| 297 | 0 | 13 | `tests/fm-instruction-owners.test.sh` | -| 181 | 0 | 4 | `tests/fm-captain-translation-contract.test.sh` | -| 180 | 0 | 15 | `tests/fm-nm-test-contract.test.sh` | -| 96 | 0 | 28 | `tests/fm-transition-lib.test.sh` | -| 66 | 0 | 7 | `tests/fm-composer-lib.test.sh` | -| 52 | 0 | 24 | `tests/fm-stow-contract.test.sh` | -| 35 | 0 | 16 | `tests/fm-no-mistakes-ownership.test.sh` | - -## Audit notes (why this set) - -Source families from the Phase 1 manifest and scout report §3.1: - -1. **pure-contract-unit** candidates audited from the Phase 1 family manifest, minus deliberate serial exclusions -2. **Extra hermetic candidates** after static audit: fake backend, private git fixtures, stubbed network - -The harness pins this exact archived set and does not automatically admit later family additions. -A candidate-set change requires a new audit and concurrent proof archive. - -### Included extras (beyond pure-contract-unit) - -| Script | Why included | -|---|---| -| `tests/fm-backend-herdr.test.sh` | Fake Herdr CLI + private temps; no real Herdr binary | -| `tests/fm-send-strict.test.sh` | Fake tmux PATH shim; private `FM_HOME` | -| `tests/fm-spawn-batch.test.sh` | Argument routing only; no real windows/worktrees | -| `tests/fm-pr-merge.test.sh` | Fake `gh`/`gh-axi`; private state | -| `tests/fm-review-diff.test.sh` | Local git fixtures via `fm_git_*`; no live forge | -| `tests/fm-x-mode.test.sh` | Fake `curl`; inert without token | - -### Deliberately serial (kept out of this pool) - -Run `bin/fm-test-isolation-proof.sh --list-exclusions` for the machine-readable list. -High-signal classes: - -| Class | Examples | Reason | -|---|---|---| -| Watcher / wake / locks | `fm-watcher-lock`, `fm-wake-queue`, ... | Intentional process locks and daemon races | -| AFK | `fm-afk-inject-e2e`, ... | Daemon lifecycle and inject path | -| Real Herdr | `fm-backend-herdr-smoke`, presentation e2e, ... | Named labs, session-global locks; Herdr lane is Phase 3+ | -| Real tmux smoke | `fm-backend-tmux-smoke` | Real multiplexer server (even on private socket) | -| Live harness opt-in | `fm-*-live-e2e` | Real interactive agents | -| GUI backends | cmux smoke | Shared GUI app | -| Gray-zone git/spawn | `fm-backend`, spawn settle/profile, teardown | Heavier worktree or lock-race matrices | -| Watcher-adjacent forge security | `fm-pr-check-security` | `.watch.lock` / poll security surface | -| Self | `fm-test-isolation-proof.test.sh` | Must not re-enter the concurrent matrix | - -### Small isolation fix landed with this phase - -`tests/fm-arm-pretool-check.test.sh` no longer writes Claude deny stderr to a fixed `/tmp/fm-arm-pretool-check-claude-stderr.$$` path. -It uses `mktemp` under `TMPDIR` so concurrent workers cannot collide on a global temp name pattern. - -## Failures - -None. -Every candidate exited 0 under concurrency=4. - -Policy: a script that fails only under concurrency is **removed** from the candidate set and investigated. -It is never retried into green, skipped more broadly, or weakened in assertions. - -## What this phase did not do (Phase 2 scope) - -- Did not land production CI Behavior matrix / shard jobs (Phase 4) -- Did not add general `bin/fm-test-run.sh --jobs` (Phase 4 enables it only for this proven set) -- Did not land the Herdr install lane (Phase 3) -- Did not re-run the complete local suite as part of this proof (focused matrix only) - -## How to re-run +| 52939 | 0 | 24 | `tests/fm-x-mode.test.sh` | +| 48294 | 0 | 2 | `tests/fm-backend-herdr.test.sh` | +| 46788 | 0 | 1 | `tests/fm-arm-pretool-check.test.sh` | +| 34207 | 0 | 4 | `tests/fm-cd-pretool-check.test.sh` | +| 30771 | 0 | 8 | `tests/fm-decision-hold-lifecycle.test.sh` | +| 25365 | 0 | 7 | `tests/fm-crew-state.test.sh` | +| 15674 | 0 | 21 | `tests/fm-test-run.test.sh` | +| 15422 | 0 | 11 | `tests/fm-herdr-lab.test.sh` | +| 9065 | 0 | 5 | `tests/fm-composer-ghost.test.sh` | +| 8564 | 0 | 14 | `tests/fm-pr-merge.test.sh` | +| 6251 | 0 | 10 | `tests/fm-grok-harness.test.sh` | +| 5644 | 0 | 16 | `tests/fm-send-popup-settle.test.sh` | +| 5237 | 0 | 12 | `tests/fm-lint.test.sh` | +| 4816 | 0 | 22 | `tests/fm-tmux-submit-busy.test.sh` | +| 2945 | 0 | 13 | `tests/fm-pi-primary-types.test.sh` | +| 2911 | 0 | 17 | `tests/fm-send-settle.test.sh` | +| 2875 | 0 | 15 | `tests/fm-review-diff.test.sh` | +| 2747 | 0 | 18 | `tests/fm-send-strict.test.sh` | +| 2224 | 0 | 3 | `tests/fm-brief.test.sh` | +| 855 | 0 | 19 | `tests/fm-spawn-batch.test.sh` | +| 703 | 0 | 20 | `tests/fm-supervision-instructions.test.sh` | +| 581 | 0 | 9 | `tests/fm-ensure-agents-md.test.sh` | +| 248 | 0 | 23 | `tests/fm-transition-lib.test.sh` | +| 64 | 0 | 6 | `tests/fm-composer-lib.test.sh` | + +## Scope + +Each worker used a separate mode-`0700` temporary root and private `TMPDIR` and `TMP`. +The harness cleared ambient `FM_HOME` and `FM_*_OVERRIDE` values for every worker and verified that global Git configuration was unchanged. +A candidate failure fails the aggregate run and requires investigation rather than a retry. + +## Re-run ```sh bin/fm-test-isolation-proof.sh --list bin/fm-test-isolation-proof.sh --jobs 4 --json /tmp/fm-isolation-proof.json -bash tests/fm-test-isolation-proof.test.sh +bin/fm-test-run.sh --check-coverage ``` diff --git a/docs/fm-test-portable-shards.md b/docs/fm-test-portable-shards.md index ce153cbd74..0bfa5e6bee 100644 --- a/docs/fm-test-portable-shards.md +++ b/docs/fm-test-portable-shards.md @@ -1,89 +1,67 @@ -# Firstmate portable test shards (Phase 4) +# Firstmate portable test shards -This document records how the two portable parallel CI shards were balanced from measured evidence. -Composition and execution are owned by `bin/fm-test-run.sh` (`--lane portable-parallel-1` / `portable-parallel-2` / `portable-serial`). -The proven-isolated candidate set remains owned by `bin/fm-test-isolation-proof.sh`. +`bin/fm-test-run.sh` owns portable lane composition and execution. +`bin/fm-test-isolation-proof.sh` owns the proven-isolated candidate set. -## Inputs +## Verification inputs -| Input | Owner / source | -|---|---| -| Proven-isolated set (29 scripts) | `bin/fm-test-isolation-proof.sh --list` and `docs/fm-test-isolation-proof.md` | -| Phase 1 serial durations | CI timing artifacts `fm-test-timing` from main after #825 / #832 / #834 | -| Real-Herdr family | `bin/fm-test-run.sh --family real-herdr-gated` (dedicated required CI lane) | +The current candidate timings came from the 2026-07-29 concurrent proof recorded in [fm-test-isolation-proof.md](fm-test-isolation-proof.md). +The proof ran 24 candidates with four workers and no failures. -Phase 1 averages used for balance (mean of available serial `duration_ms` across those artifacts): - -| duration_ms (avg) | script | +| duration_ms | script | |---:|---| -| 29639 | `tests/fm-arm-pretool-check.test.sh` | -| 25402 | `tests/fm-decision-hold-lifecycle.test.sh` | -| 19428 | `tests/fm-x-mode.test.sh` | -| 14979 | `tests/fm-cd-pretool-check.test.sh` | -| 9339 | `tests/fm-backend-herdr.test.sh` | -| 6885 | `tests/fm-herdr-lab.test.sh` | -| 5127 | `tests/fm-crew-state.test.sh` | -| 4044 | `tests/fm-pr-merge.test.sh` | -| 3922 | `tests/fm-grok-harness.test.sh` | -| 2492 | `tests/fm-test-run.test.sh` | -| 1901 | `tests/fm-send-popup-settle.test.sh` | -| 1234 | `tests/fm-spawn-batch.test.sh` | -| 851 | `tests/fm-send-strict.test.sh` | -| 791 | `tests/fm-review-diff.test.sh` | -| 627 | `tests/fm-tmux-submit-busy.test.sh` | -| 525 | `tests/fm-brief.test.sh` | -| 321 | `tests/fm-composer-ghost.test.sh` | -| 276 | `tests/fm-send-settle.test.sh` | -| 189 | `tests/fm-ensure-agents-md.test.sh` | -| 175 | `tests/fm-supervision-instructions.test.sh` | -| 138 | `tests/fm-instruction-owners.test.sh` | -| 133 | `tests/fm-lint.test.sh` | -| 108 | `tests/fm-pi-primary-types.test.sh` | -| 106 | `tests/fm-nm-test-contract.test.sh` | -| 67 | `tests/fm-transition-lib.test.sh` | -| 64 | `tests/fm-captain-translation-contract.test.sh` | -| 48 | `tests/fm-composer-lib.test.sh` | -| 36 | `tests/fm-stow-contract.test.sh` | -| 28 | `tests/fm-no-mistakes-ownership.test.sh` | - -## Balancing history - -The original 30-script set used longest-processing-time (LPT) assignment onto two workers with the Phase 1 averages above. -The current 29-script lanes retain that assignment after one 283 ms candidate was removed from `portable-parallel-1`. -The current totals are therefore intentionally not a fresh LPT balance of the 29-script set. -Do not rebalance alphabetically or by family intuition. -Shard execution order remains longest-first within each retained lane. - -| Lane | Script count | Sum of Phase 1 averages | +| 52939 | `tests/fm-x-mode.test.sh` | +| 48294 | `tests/fm-backend-herdr.test.sh` | +| 46788 | `tests/fm-arm-pretool-check.test.sh` | +| 34207 | `tests/fm-cd-pretool-check.test.sh` | +| 30771 | `tests/fm-decision-hold-lifecycle.test.sh` | +| 25365 | `tests/fm-crew-state.test.sh` | +| 15674 | `tests/fm-test-run.test.sh` | +| 15422 | `tests/fm-herdr-lab.test.sh` | +| 9065 | `tests/fm-composer-ghost.test.sh` | +| 8564 | `tests/fm-pr-merge.test.sh` | +| 6251 | `tests/fm-grok-harness.test.sh` | +| 5644 | `tests/fm-send-popup-settle.test.sh` | +| 5237 | `tests/fm-lint.test.sh` | +| 4816 | `tests/fm-tmux-submit-busy.test.sh` | +| 2945 | `tests/fm-pi-primary-types.test.sh` | +| 2911 | `tests/fm-send-settle.test.sh` | +| 2875 | `tests/fm-review-diff.test.sh` | +| 2747 | `tests/fm-send-strict.test.sh` | +| 2224 | `tests/fm-brief.test.sh` | +| 855 | `tests/fm-spawn-batch.test.sh` | +| 703 | `tests/fm-supervision-instructions.test.sh` | +| 581 | `tests/fm-ensure-agents-md.test.sh` | +| 248 | `tests/fm-transition-lib.test.sh` | +| 64 | `tests/fm-composer-lib.test.sh` | + +## Parallel lanes + +The two parallel lanes use longest-processing-time assignment from those measured durations. + +| Lane | Script count | Estimated duration | |---|---:|---:| -| `portable-parallel-1` | 14 | 64296 ms (~64.3 s) | -| `portable-parallel-2` | 15 | 64579 ms (~64.6 s) | -| imbalance | | 283 ms | +| `portable-parallel-1` | 11 | 162436 ms (~162.4 s) | +| `portable-parallel-2` | 13 | 162754 ms (~162.8 s) | +| imbalance | | 318 ms | -Exact ordered membership is the heredoc lists in `bin/fm-test-run.sh` (`list_portable_parallel_1` / `list_portable_parallel_2`). +`bin/fm-test-run.sh` contains the exact ordered memberships in `list_portable_parallel_1` and `list_portable_parallel_2`. ## Portable serial remainder -`portable-serial` is every `tests/*.test.sh` that is neither proven-isolated nor `real-herdr-gated`. -That keeps watcher, lock, AFK, real tmux, daemon, secondmate lifecycle, bootstrap, live-harness opt-in (default skip), GUI backends, and other stateful or unproven work serial. -Measured serial remainder wall (from the same Phase 1 artifacts, excluding Herdr) is about **13 minutes**. +`portable-serial` includes every `tests/*.test.sh` that is neither proven-isolated nor `real-herdr-gated`. +It keeps watcher, lock, AFK, real tmux, daemon, secondmate lifecycle, bootstrap, live-harness opt-in, GUI-backend, and other unproven work serial. ## Coverage guard -`bin/fm-test-run.sh --check-coverage` proves: - -1. The two portable parallel shards are a partition of the proven-isolated set. -2. Proven-isolated embeds match `bin/fm-test-isolation-proof.sh --list`. -3. Union of portable parallel shards + portable serial + real-Herdr family equals the complete `tests/*.test.sh` inventory. -4. Those four partitions are pairwise disjoint (no missing scripts, no duplicates). - -CI runs that guard as a required job (`test-coverage`). +`bin/fm-test-run.sh --check-coverage` verifies that both parallel lanes partition the proven-isolated set. +It also verifies that the parallel lanes, portable serial lane, and real-Herdr family are disjoint and cover every `tests/*.test.sh` script. ## Timing artifacts -Every portable shard, the portable serial lane, and the Herdr lane upload their runner-generated timing JSON even when the behavior run reports failures. -The dependent aggregate job runs after all four lanes, combines every available lane JSON through `bin/fm-test-run.sh --aggregate-json`, and uploads one summary artifact for critical-path review. -The workflow in `.github/workflows/ci.yml` owns the exact artifact names and aggregation wiring. +Portable shards, the portable serial lane, and the Herdr lane upload runner-generated timing JSON. +`bin/fm-test-run.sh --aggregate-json` creates the combined summary artifact. +`.github/workflows/ci.yml` owns the exact artifact names and aggregation wiring. ## Local entry points @@ -94,15 +72,8 @@ The workflow in `.github/workflows/ci.yml` owns the exact artifact names and agg | Job | timeout-minutes | Rationale | |---|---:|---| -| portable parallel 1/2 | 10 | Measured shard sum ~1 min; hang tripwire with margin | -| portable serial | 20 | Measured ~13 min remainder; reduced from interim 25m full-portable slack after sharding | -| Herdr | 40 | Unchanged hang tripwire for the real-Herdr lane | - -Timeouts remain hang tripwires, not expected healthy ends of green suites. -Do not raise them as a substitute for green results, retries, or weaker assertions. - -## What this phase does not do +| portable parallel 1/2 | 10 | The measured shard sums are about three minutes and the timeout is a hang tripwire. | +| portable serial | 20 | The serial remainder needs a larger hang tripwire. | +| Herdr | 40 | The real-Herdr lane keeps its dedicated timeout. | -- Does not expand the proven-isolated set without a new concurrent isolation proof. -- Does not parallelize watcher, AFK, real Herdr, real tmux, or other stateful families. -- Does not start rollout verification; that waits until this PR is green and merged. +Timeouts are hang tripwires rather than expected healthy durations. diff --git a/docs/sessionstart-nudge.md b/docs/sessionstart-nudge.md index 7830dfcb3b..c39c814925 100644 --- a/docs/sessionstart-nudge.md +++ b/docs/sessionstart-nudge.md @@ -36,8 +36,6 @@ That alternative expands trust and writes outside this repository, so Firstmate `tests/fm-sessionstart-nudge.test.sh` proves wrapper silence for both gate signals, an unmarked linked worktree, a missing state directory, and an already-owned lock. It proves exact U+2063 `FIRSTMATE_OP:`-prefixed, `session-start`-typed one-line output for a plain primary and a marked linked secondmate primary. -It also verifies every tracked transport registration listed above. -`tests/fm-captain-translation-contract.test.sh` proves Ahoy's current marker rule, narrow legacy compatibility exclusions, genuine captain-message near misses, and the shared marker on supported user-role operational injections. `tests/fm-pi-primary-live-e2e.test.sh` and `tests/fm-opencode-primary-live-e2e.test.sh` exercise native startup paths with first-message and later-message Ahoy regressions. `tests/fm-turnend-guard.test.sh`, `tests/fm-pi-watch-extension.test.sh`, and `tests/fm-daemon.test.sh` cover marked guard, monitoring, and away-mode delivery. diff --git a/docs/verification/supervision.md b/docs/verification/supervision.md index a364f8db04..20f415e456 100644 --- a/docs/verification/supervision.md +++ b/docs/verification/supervision.md @@ -52,7 +52,6 @@ Current deterministic and live entry points: ```sh tests/fm-sessionstart-nudge.test.sh -tests/fm-captain-translation-contract.test.sh FM_PI_LIVE_E2E=1 tests/fm-pi-primary-live-e2e.test.sh FM_OPENCODE_LIVE_E2E=1 tests/fm-opencode-primary-live-e2e.test.sh ``` diff --git a/tests/fm-arm-pretool-check.test.sh b/tests/fm-arm-pretool-check.test.sh index 0bc6cfac28..5ba750aea0 100755 --- a/tests/fm-arm-pretool-check.test.sh +++ b/tests/fm-arm-pretool-check.test.sh @@ -439,95 +439,6 @@ test_allow_is_silent_both_modes() { # --- harness wiring: each adapter invokes the shared checker ----------------- -test_grok_pretool_hook_wired() { - local settings command - settings="$ROOT/.grok/hooks/fm-primary-pretool-check.json" - [ -f "$settings" ] || fail "tracked grok primary PreToolUse hook config is missing" - command=$(jq -r '.hooks.PreToolUse[0].hooks[0].command // empty' "$settings") - [ -n "$command" ] || fail "PreToolUse hook command is missing from grok primary hook config" - assert_contains "$command" 'GROK_WORKSPACE_ROOT' "grok pretool hook must anchor from GROK_WORKSPACE_ROOT" - assert_contains "$command" 'fm-arm-pretool-check.sh' "grok pretool hook must invoke the shared checker" - assert_contains "$command" 'exec "${GROK_WORKSPACE_ROOT:-}/bin/fm-arm-pretool-check.sh"' "grok pretool hook must forward its stdin payload unchanged to the checker" - # shellcheck disable=SC2016 # single quotes are deliberate: a literal needle string, not an expansion - assert_not_contains "$command" 'root=${GROK_WORKSPACE_ROOT' "grok pretool hook must not assign a bare \$root var (breaks grok's own \${VAR} pre-substitution; see docs/arm-pretool-check.md)" - local matcher - matcher=$(jq -r '.hooks.PreToolUse[0].matcher // empty' "$settings") - [ "$matcher" = "Bash" ] || fail "grok pretool hook must matcher-scope to Bash, got: $matcher" - pass ".grok primary hook: PreToolUse hook invokes the shared checker" -} - -test_grok_turnend_hook_uses_safe_var_pattern() { - local settings command - settings="$ROOT/.grok/hooks/fm-primary-turnend-guard.json" - [ -f "$settings" ] || fail "tracked grok primary Stop hook config is missing" - command=$(jq -r '.hooks.Stop[0].hooks[0].command // empty' "$settings") - # shellcheck disable=SC2016 # single quotes are deliberate: literal needle strings, not expansions - assert_not_contains "$command" 'root=${GROK_WORKSPACE_ROOT' "grok Stop hook must not assign a bare \$root var either (regression fixed 2026-07-09, docs/arm-pretool-check.md)" - # shellcheck disable=SC2016 - assert_contains "$command" '${GROK_WORKSPACE_ROOT:-}' "grok Stop hook must reference GROK_WORKSPACE_ROOT with an inline default every time" - pass ".grok primary hook: Stop hook uses the \${VAR:-} pattern throughout (no bare \$root)" -} - -test_claude_settings_pretool_hook_wired() { - local settings command - settings="$ROOT/.claude/settings.json" - [ -f "$settings" ] || fail "tracked claude primary settings are missing" - command=$(jq -r '.hooks.PreToolUse[0].hooks[0].command // empty' "$settings") - [ -n "$command" ] || fail "PreToolUse hook command is missing from claude primary settings" - assert_contains "$command" 'CLAUDE_PROJECT_DIR' "claude pretool hook must anchor via CLAUDE_PROJECT_DIR" - assert_contains "$command" 'fm-arm-pretool-check.sh' "claude pretool hook must invoke the shared checker" - assert_contains "$command" '--claude' "claude pretool hook must pass --claude so stdout stays empty on deny" - [ "$command" = '"$CLAUDE_PROJECT_DIR"/bin/fm-arm-pretool-check.sh --claude' ] \ - || fail "claude pretool hook must forward stdin directly with only --claude, got: $command" - local matcher - matcher=$(jq -r '.hooks.PreToolUse[0].matcher // empty' "$settings") - [ "$matcher" = "Bash" ] || fail "claude pretool hook must matcher-scope to Bash, got: $matcher" - pass ".claude/settings.json: PreToolUse hook invokes the shared checker with --claude" -} - -test_codex_hooks_pretool_wired() { - local settings command - settings="$ROOT/.codex/hooks.json" - [ -f "$settings" ] || fail "tracked codex primary hooks are missing" - command=$(jq -r '.hooks.PreToolUse[0].hooks[0].command // empty' "$settings") - [ -n "$command" ] || fail "PreToolUse hook command is missing from codex primary hooks" - assert_contains "$command" 'fm-arm-pretool-check.sh' "codex pretool hook must invoke the shared checker" - assert_contains "$command" 'pwd -P' "codex pretool hook must anchor to the hook process root like the Stop hook does" - assert_contains "$command" 'printf "%s" "$payload" | "$root/bin/fm-arm-pretool-check.sh"' "codex pretool hook must forward the exact captured payload to the checker" - local matcher - matcher=$(jq -r '.hooks.PreToolUse[0].matcher // empty' "$settings") - [ "$matcher" = "Bash" ] || fail "codex pretool hook must matcher-scope to Bash, got: $matcher" - pass ".codex/hooks.json: PreToolUse hook invokes the shared checker" -} - -test_opencode_pretool_plugin_wired() { - local plugin content - plugin="$ROOT/.opencode/plugins/fm-primary-pretool-check.js" - [ -f "$plugin" ] || fail "tracked opencode primary pretool plugin is missing" - content=$(cat "$plugin") - assert_contains "$content" 'tool.execute.before' "opencode pretool plugin must hook tool.execute.before" - assert_contains "$content" 'fm-arm-pretool-check.sh' "opencode pretool plugin must invoke the shared checker" - assert_contains "$content" 'const command = output?.args?.command;' "opencode must extract output.args.command exactly" - assert_contains "$content" '["--command", command]' "opencode must forward the exact command as one CLI argument" - assert_contains "$content" 'if (result.code !== 2) return;' "opencode must throw only for checker exit 2" - assert_contains "$content" 'throw new Error' "opencode pretool plugin must throw to block the tool call" - pass ".opencode primary plugin: tool.execute.before invokes the shared checker and blocks by throwing" -} - -test_pi_extension_carries_pretool_check() { - local ext content - ext="$ROOT/.pi/extensions/fm-primary-turnend-guard.ts" - [ -f "$ext" ] || fail "tracked pi primary extension is missing" - content=$(cat "$ext") - assert_contains "$content" 'tool_call' "pi extension must hook tool_call for the pretool seatbelt" - assert_contains "$content" 'fm-arm-pretool-check.sh' "pi extension must invoke the shared checker" - assert_contains "$content" 'String((event.input as { command?: unknown })?.command ?? "")' "pi must extract and string-coerce event.input.command exactly" - assert_contains "$content" 'const result = await runPretoolCheck(command);' "pi must forward the exact command to the checker" - assert_contains "$content" 'if (result.code !== 2) return {};' "pi must block only for checker exit 2" - assert_contains "$content" 'block: true' "pi extension must return block:true to deny" - pass ".pi primary extension: tool_call handler invokes the shared checker and can block" -} - # --- shellcheck (belt-and-suspenders; CI/CONTRIBUTING.md also runs this) ----- test_shellcheck_clean() { @@ -553,10 +464,4 @@ test_failopen_missing_node test_claude_mode_stdout_empty_on_deny test_default_mode_stdout_has_grok_json_on_deny test_allow_is_silent_both_modes -test_grok_pretool_hook_wired -test_grok_turnend_hook_uses_safe_var_pattern -test_claude_settings_pretool_hook_wired -test_codex_hooks_pretool_wired -test_opencode_pretool_plugin_wired -test_pi_extension_carries_pretool_check test_shellcheck_clean diff --git a/tests/fm-ask-user-authority.test.sh b/tests/fm-ask-user-authority.test.sh index c05d84946f..89ec517fa1 100644 --- a/tests/fm-ask-user-authority.test.sh +++ b/tests/fm-ask-user-authority.test.sh @@ -1,124 +1,13 @@ #!/usr/bin/env bash -# Scenario regressions for ask-user authority. -# -# Hi Bit PR 148 is motivating evidence only: yolo approved 31 ask-user finding -# groups, and a later audit classified 14 of 32 rounds as over-engineered after -# checkpoint-based gameplay verification expanded into continuous adversarial -# 60 Hz browser proof. -# The tests below enforce the general contract boundary without naming that -# project in the runtime policy. -# shellcheck disable=SC2016 +# Behavioral regressions for ask-user authority instructions generated by fm-brief. set -u # shellcheck source=tests/lib.sh . "$(dirname "${BASH_SOURCE[0]}")/lib.sh" -AGENTS="$ROOT/AGENTS.md" -OWNER="$ROOT/.agents/skills/ask-user-authority/SKILL.md" BRIEF="$ROOT/bin/fm-brief.sh" -SECONDMATE="$ROOT/.agents/skills/secondmate-provisioning/SKILL.md" TMP_ROOT=$(fm_test_tmproot fm-ask-user-authority) -approval_contract() { - awk ' - /^### Selected delivery path and approval authority$/ { found = 1; next } - found && /^### Validate$/ { exit } - found { print } - ' "$AGENTS" -} - -test_owner_and_always_loaded_boundary() { - local contract trigger_count - contract=$(approval_contract) - - assert_contains "$contract" "only within the captain's original request and accepted task criteria" \ - "standing authority lost the accepted-contract boundary" - assert_contains "$contract" 'never approves an ask-user Fix that would materially expand that product or engineering contract' \ - "standing authority lost the contract-expansion exception" - assert_contains "$contract" 'destructive, irreversible, and security-sensitive choices remain stronger captain boundaries' \ - "contract expansion weakened stronger captain boundaries" - assert_contains "$contract" 'Complexity alone is not expansion' \ - "standing authority incorrectly treats complexity as expansion" - assert_contains "$contract" 'load `ask-user-authority`' \ - "standing authority lost the detailed-procedure trigger" - assert_contains "$contract" 'implementation worker never answers its own finding' \ - "implementation worker can answer its own finding" - - assert_present "$OWNER" "ask-user authority owner is missing" - assert_grep 'name: ask-user-authority' "$OWNER" "ask-user authority skill has the wrong name" - assert_grep 'user-invocable: false' "$OWNER" "ask-user authority skill must be agent-only" - assert_grep 'single owner of the decision procedure for ask-user findings' "$OWNER" \ - "ask-user authority skill does not declare ownership" - assert_grep 'With `yolo` off, every ask-user finding belongs to the captain' "$OWNER" \ - "detailed procedure permits autonomous ask-user decisions with yolo off" - trigger_count=$(grep -Fc -- '- `ask-user-authority` -' "$AGENTS") - [ "$trigger_count" -eq 1 ] || fail "ask-user-authority must have exactly one section 13 trigger, found $trigger_count" - assert_no_grep 'Hi Bit' "$AGENTS" "AGENTS.md encoded an incident-specific authority rule" - assert_no_grep 'Hi Bit' "$OWNER" "authority owner encoded an incident-specific rule" - pass "ask-user authority has one conditional owner and a concise always-loaded boundary" -} - -test_concrete_required_defect_stays_autonomous() { - assert_grep 'genuinely necessary to satisfy the accepted contract' "$OWNER" \ - "required concrete corrections no longer stay within standing authority" - assert_grep 'Fixing a concrete defect that violates an original acceptance criterion stays within `yolo` authority' "$OWNER" \ - "concrete acceptance-criterion defect scenario is missing" - pass "required concrete defect correction stays within yolo authority" -} - -test_continuous_monitoring_expansion_escalates() { - assert_grep 'continuous-monitoring requirement' "$OWNER" \ - "continuous monitoring is not classified as a possible contract expansion" - assert_grep 'continuous frame-by-frame monitoring when the accepted criterion requested checkpoint proof expands the contract' "$OWNER" \ - "checkpoint-to-continuous-monitoring escalation scenario is missing" - pass "continuous frame-by-frame proof escalates when only checkpoints were requested" -} - -test_repeated_same_theme_escalates_before_another_round() { - assert_grep 'Repeated same-theme findings require escalation before another Fix' "$OWNER" \ - "same-theme findings do not stop another autonomous fix round" - assert_grep 'preserving a questionable abstraction rather than closing independent defects' "$OWNER" \ - "same-theme escalation lost its causal distinction" - pass "repeated abstraction-preserving findings escalate before another fix round" -} - -test_stronger_security_boundary_survives() { - assert_grep 'genuinely security-sensitive choices always escalate' "$OWNER" \ - "security-sensitive choices no longer use the stronger captain boundary" - assert_grep 'genuinely security-sensitive action requires the captain under the stronger existing boundary' "$OWNER" \ - "security-sensitive scenario is missing" - pass "genuinely security-sensitive action still escalates" -} - -test_explicit_complex_architecture_stays_in_scope() { - assert_grep 'complex architecture that the captain explicitly requested' "$OWNER" \ - "explicitly requested complex architecture is not protected from complexity-only escalation" - assert_grep 'does not escalate merely because it is complex' "$OWNER" \ - "complexity alone still triggers escalation" - pass "explicitly requested complex architecture stays autonomous" -} - -test_reviewer_labels_are_evidence_not_authority() { - for label in correctness security fail-closed high-risk required; do - assert_grep "$label" "$OWNER" "reviewer-label evidence rule is missing '$label'" - done - assert_grep 'never as authority to broaden the task' "$OWNER" \ - "reviewer labels can still broaden the accepted contract" - pass "reviewer risk labels remain evidence rather than expansion authority" -} - -test_captain_escalation_is_decision_ready() { - for phrase in \ - 'original requirement or accepted task criterion' \ - 'proposed product or engineering contract expansion' \ - 'smallest alternative that complies with the accepted contract' \ - 'consequences of accepting and declining the expansion' \ - 'recommendation with the reason'; do - assert_grep "$phrase" "$OWNER" "captain-facing escalation lost '$phrase'" - done - pass "contract-expansion escalation carries all five decision elements" -} - test_primary_and_secondmate_instruction_generation() { local home ship charter home="$TMP_ROOT/home" @@ -139,23 +28,12 @@ test_primary_and_secondmate_instruction_generation() { FM_HOME="$home" FM_ROOT_OVERRIDE="$ROOT" FM_SECONDMATE_CHARTER='Handle sample work.' \ "$BRIEF" authority-mate --secondmate --no-projects >/dev/null 2>&1 charter="$home/data/authority-mate/brief.md" + # shellcheck disable=SC2016 # Backticks are literal generated Markdown. assert_grep 'The local `AGENTS.md` is your job description' "$charter" \ "generated secondmate charter does not load the tracked authority boundary" - assert_grep 'purely local fast-forward of tracked files' "$SECONDMATE" \ - "secondmate update owner no longer carries tracked instructions into homes" - assert_grep 'AGENTS.md re-read' "$SECONDMATE" \ - "running secondmates are not told to re-read updated tracked authority" assert_no_grep 'continuous frame-by-frame monitoring' "$charter" \ "generated secondmate charter duplicated the detailed authority procedure" - pass "primary workers and secondmates receive the authority rule through their normal instruction owners" + pass "primary workers and secondmates receive the authority rule through generated instructions" } -test_owner_and_always_loaded_boundary -test_concrete_required_defect_stays_autonomous -test_continuous_monitoring_expansion_escalates -test_repeated_same_theme_escalates_before_another_round -test_stronger_security_boundary_survives -test_explicit_complex_architecture_stays_in_scope -test_reviewer_labels_are_evidence_not_authority -test_captain_escalation_is_decision_ready test_primary_and_secondmate_instruction_generation diff --git a/tests/fm-backend-herdr.test.sh b/tests/fm-backend-herdr.test.sh index b2e980d964..92ecbdcb92 100755 --- a/tests/fm-backend-herdr.test.sh +++ b/tests/fm-backend-herdr.test.sh @@ -1217,30 +1217,6 @@ test_presentation_session_lock_path_rejects_malformed_socket() { pass "herdr presentation lock: null and missing socket paths fail closed" } -test_presentation_lock_malformed_socket_falls_back() { - local dir log resp fb out status lock_source - dir="$TMP_ROOT/presentation-malformed-socket-fallback"; mkdir -p "$dir/responses" - log="$dir/log"; resp="$dir/responses"; : > "$log" - printf '%s\n' '{"sessions":[{"name":"fmtest","running":true,"socket_path":null}]}' > "$resp/1.out" - fb=$(make_herdr_fakebin "$dir") - lock_source=$(sed -n '/^spawn_herdr_presentation_order_lock_acquire()/,/^spawn_herdr_presentation_order_lock_release()/p' "$ROOT/bin/fm-spawn.sh" | sed '$d') - out=$(PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ - LOCK_SOURCE="$lock_source" \ - bash -c ' - . "$0/bin/backends/herdr.sh" - eval "$LOCK_SOURCE" - if spawn_herdr_presentation_order_lock_acquire fmtest; then - printf "%s" acquired - else - printf "%s" flat - fi - ' "$ROOT" 2>&1) - status=$? - [ "$status" -eq 0 ] || fail "malformed socket fallback must not fail the spawn path: $out" - [ "$out" = flat ] || fail "malformed socket_path must fall back flat, got '$out'" - pass "herdr presentation lock: malformed socket metadata degrades to flat" -} - test_projection_order_rejects_malformed_socket() { local dir log resp fb mover out status dir="$TMP_ROOT/projection-order-malformed-socket"; mkdir -p "$dir/responses" @@ -1267,117 +1243,6 @@ SH pass "herdr presentation ordering: malformed socket metadata is warning-only and read-only" } -test_presentation_lock_insecure_namespace_falls_back() { - local dir log resp fb bad out status lock_source - dir="$TMP_ROOT/presentation-insecure-lock"; mkdir -p "$dir/responses" "$dir/sockdir" - log="$dir/log"; resp="$dir/responses"; : > "$log" - : > "$dir/sockdir/fmtest.sock" - bad="$dir/insecure"; mkdir -m 755 "$bad" - printf '%s\n' "{\"sessions\":[{\"name\":\"fmtest\",\"running\":true,\"socket_path\":\"$dir/sockdir/fmtest.sock\"}]}" > "$resp/1.out" - fb=$(make_herdr_fakebin "$dir") - lock_source=$(sed -n '/^spawn_herdr_presentation_order_lock_acquire()/,/^spawn_herdr_presentation_order_lock_release()/p' "$ROOT/bin/fm-spawn.sh" | sed '$d') - out=$(PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ - BAD_NAMESPACE="$bad" LOCK_SOURCE="$lock_source" \ - bash -c ' - . "$0/bin/backends/herdr.sh" - eval "$LOCK_SOURCE" - fm_backend_herdr_presentation_lock_namespace() { printf "%s" "$BAD_NAMESPACE"; } - if spawn_herdr_presentation_order_lock_acquire fmtest; then - printf "%s" acquired - else - printf "%s" flat - fi - ' "$ROOT" 2>&1) - status=$? - [ "$status" -eq 0 ] || fail "an insecure lock namespace must not fail the spawn path: $out" - [ "$out" = flat ] || fail "an insecure lock namespace must fall back flat, got '$out'" - pass "herdr presentation lock: insecure shared namespace refuses acquisition for flat fallback" -} - -test_spawn_task_lock_covers_all_backend_creation_and_metadata_publication() { - local source wake_source acquire_pattern backend_pattern meta_pattern acquire_line backend_line meta_line - source=$(cat "$ROOT/bin/fm-spawn.sh") - wake_source=". \"\$SCRIPT_DIR/fm-wake-lib.sh\"" - acquire_pattern="fm_lock_try_acquire \"\$SPAWN_TASK_LOCK\"" - backend_pattern="^case \"\$BACKEND\" in" - meta_pattern="} > \"\$STATE/\$ID.meta\"" - assert_contains "$source" "$wake_source" \ - "fm-spawn does not load the shared lock implementation" - acquire_line=$(grep -n "$acquire_pattern" "$ROOT/bin/fm-spawn.sh" | head -1 | cut -d: -f1) - backend_line=$(grep -n "$backend_pattern" "$ROOT/bin/fm-spawn.sh" | tail -1 | cut -d: -f1) - meta_line=$(grep -n "$meta_pattern" "$ROOT/bin/fm-spawn.sh" | tail -1 | cut -d: -f1) - [ -n "$acquire_line" ] && [ -n "$backend_line" ] && [ -n "$meta_line" ] \ - || fail "could not locate the spawn lock, backend creation, and metadata publication" - [ "$acquire_line" -lt "$backend_line" ] && [ "$backend_line" -lt "$meta_line" ] \ - || fail "the task lock does not span backend creation through metadata publication" - pass "fm-spawn: one task lock spans every backend creation path through metadata publication" -} - -test_projected_spawn_disarms_cleanup_before_ambiguous_launch_submission() { - local literal_pattern disarm_pattern release_pattern enter_pattern literal_line disarm_line release_line enter_line - # These are literal source patterns for grep, so shell expansion would invalidate the assertion. - # shellcheck disable=SC2016 - literal_pattern='spawn_send_literal "$T" "$LAUNCH"' - # shellcheck disable=SC2016 - disarm_pattern='HERDR_PROJECTION_ABORT_CLEANUP=0' - release_pattern='spawn_herdr_presentation_order_lock_release' - # shellcheck disable=SC2016 - enter_pattern='spawn_send_key "$T" Enter' - literal_line=$(grep -nF "$literal_pattern" "$ROOT/bin/fm-spawn.sh" | tail -1 | cut -d: -f1) - disarm_line=$(grep -nF "$disarm_pattern" "$ROOT/bin/fm-spawn.sh" | tail -1 | cut -d: -f1) - release_line=$(grep -nF "$release_pattern" "$ROOT/bin/fm-spawn.sh" | tail -1 | cut -d: -f1) - enter_line=$(grep -nF "$enter_pattern" "$ROOT/bin/fm-spawn.sh" | tail -1 | cut -d: -f1) - [ -n "$literal_line" ] && [ -n "$disarm_line" ] && [ -n "$release_line" ] && [ -n "$enter_line" ] \ - || fail "could not locate the projected launch cleanup boundary" - [ "$literal_line" -lt "$disarm_line" ] \ - && [ "$disarm_line" -lt "$release_line" ] \ - && [ "$release_line" -lt "$enter_line" ] \ - || fail "projected spawn must disarm cleanup before releasing its lock and submitting ambiguous Enter" - pass "fm-spawn: projected cleanup disarms before lock release and ambiguous launch submission" -} - -test_projected_abort_cleanup_holds_presentation_lock() { - local dir lock started proceed function_source owner_pid status - dir="$TMP_ROOT/projection-abort-lock"; mkdir -p "$dir" - lock="$dir/presentation.lock" - started="$dir/cleanup-started" - proceed="$dir/cleanup-proceed" - function_source=$(sed -n '/^spawn_abort_cleanup()/,/^trap spawn_abort_cleanup EXIT/p' "$ROOT/bin/fm-spawn.sh" | sed '$d') - ROOT="$ROOT" LOCK="$lock" STARTED="$started" PROCEED="$proceed" FUNCTION_SOURCE="$function_source" bash -c ' - . "$ROOT/bin/fm-wake-lib.sh" - eval "$FUNCTION_SOURCE" - fm_backend_herdr_projection_cleanup_exact() { - : > "$STARTED" - while [ ! -e "$PROCEED" ]; do sleep 0.01; done - } - fm_lock_try_acquire "$LOCK" || exit 1 - HERDR_PRESENTATION_ORDER_LOCK_HELD=1 - HERDR_PRESENTATION_ORDER_LOCK=$LOCK - HERDR_PROJECTION_ABORT_CLEANUP=1 - HERDR_PROJECTION_ABORT_SESSION=fmtest - HERDR_PROJECTION_ABORT_TASK_PANE=w9:p2 - HERDR_PROJECTION_ABORT_SEEDED_PANE=w9:p1 - ORCA_ABORT_CLEANUP=0 - SPAWN_TASK_LOCK_HELD=0 - spawn_abort_cleanup - ' & - owner_pid=$! - while [ ! -e "$started" ] && kill -0 "$owner_pid" 2>/dev/null; do sleep 0.01; done - [ -e "$started" ] || fail "projected abort cleanup did not start" - if LOCK="$lock" ROOT="$ROOT" bash -c '. "$ROOT/bin/fm-wake-lib.sh"; fm_lock_try_acquire "$LOCK"'; then - : > "$proceed" - wait "$owner_pid" || true - fail "concurrent presentation work acquired the lock during abort cleanup" - fi - : > "$proceed" - wait "$owner_pid" - status=$? - [ "$status" -eq 0 ] || fail "projected abort cleanup owner failed" - LOCK="$lock" ROOT="$ROOT" bash -c '. "$ROOT/bin/fm-wake-lib.sh"; fm_lock_try_acquire "$LOCK"' \ - || fail "presentation lock remained held after abort cleanup" - pass "fm-spawn: projected abort cleanup remains serialized by the presentation lock" -} - test_projection_reclaim_refusal_matrix_is_non_mutating() { local dir state home other_home home_real journal legacy token label out mutation_log dir="$TMP_ROOT/projection-reclaim-refusals"; state="$dir/state"; home="$dir/home"; other_home="$dir/other-home" @@ -2647,23 +2512,6 @@ EOF pass "fm_backend_herdr_workspace_prune_seeded_default_tab: refuses to close the seeded default tab when its pane reports a working agent (defense in depth)" } -# test_no_jq_reserved_keyword_arg_names: regression guard for the -# workspace-leak root cause (a jq `--arg`/`--argjson` named after a jq -# reserved keyword, e.g. `label`, is a compile error on jq <= 1.6; this -# adapter discards jq's stderr, so the error silently becomes an empty -# result instead of a visible failure). Greps every bin/ script for the -# pattern so a future filter reintroducing it fails loudly here instead of -# silently misbehaving on an older jq. -test_no_jq_reserved_keyword_arg_names() { - local reserved='and|as|catch|def|elif|else|end|foreach|if|import|include|label|module|or|reduce|then|try' - local hits - hits=$(grep -rnE -- "--arg(json)?[[:space:]]+($reserved)\b" "$ROOT/bin" 2>/dev/null) - if [ -n "$hits" ]; then - fail "a jq --arg/--argjson variable is named after a jq reserved keyword (compile error on jq <= 1.6, silently swallowed by 2>/dev/null):"$'\n'"$hits" - fi - pass "no bin/ jq filter names a --arg/--argjson variable after a jq reserved keyword" -} - # --- native event push: normalize / policy-routing / dedupe / wait ---------- # # These exercise the herdr subscriber (fm_backend_herdr_wait_transition and its @@ -2991,7 +2839,6 @@ test_repeated_cycles_reuse_one_workspace_no_orphans test_adopted_workspace_never_prunes_default_tab test_label_collision_startup_workspace_leaves_live_tab_alone test_prune_refuses_a_working_agent_pane_defense_in_depth -test_no_jq_reserved_keyword_arg_names test_create_task_refuses_duplicate_label test_create_task_refuses_duplicate_label_when_agent_live test_create_task_refuses_when_any_duplicate_label_is_live @@ -3025,12 +2872,7 @@ test_projection_order_foreign_new_child_before_parent_is_read_only test_projection_order_missing_parent_is_read_only test_presentation_session_lock_path_is_shared_across_homes test_presentation_session_lock_path_rejects_malformed_socket -test_presentation_lock_malformed_socket_falls_back test_projection_order_rejects_malformed_socket -test_presentation_lock_insecure_namespace_falls_back -test_spawn_task_lock_covers_all_backend_creation_and_metadata_publication -test_projected_spawn_disarms_cleanup_before_ambiguous_launch_submission -test_projected_abort_cleanup_holds_presentation_lock test_projection_reclaim_refusal_matrix_is_non_mutating test_projection_reclaim_replaces_only_exact_husk_and_advances_binding test_projection_recovery_is_read_only_and_refuses_live_duplicate_risk diff --git a/tests/fm-backend.test.sh b/tests/fm-backend.test.sh index 622bb12839..7ac873f087 100755 --- a/tests/fm-backend.test.sh +++ b/tests/fm-backend.test.sh @@ -959,37 +959,6 @@ run_teardown_case() { "$script" "$id" } -test_permissive_tmux_kill_ref_stays_historical() { - local ref body_hist body_head head - head=$(git -C "$ROOT" rev-parse HEAD) - ref=$(resolve_permissive_tmux_kill_ref) \ - || fail "unable to locate a historical bin/backends/tmux.sh with permissive kill-window selectors" - body_hist=$(git -C "$ROOT" show "$ref:bin/backends/tmux.sh") \ - || fail "could not read historical tmux adapter at $ref" - body_head=$(cat "$ROOT/bin/backends/tmux.sh") - - # shellcheck disable=SC2016 - case "$body_hist" in - *'tmux kill-window -t "=$session:=$window"'*) - fail "resolve_permissive_tmux_kill_ref returned exact selectors at $ref" - ;; - esac - # shellcheck disable=SC2016 - case "$body_hist" in - *'tmux kill-window -t "$1"'*|*'tmux kill-window -t "$target"'*) ;; - *) fail "historical tmux adapter at $ref lacks a permissive kill-window target" ;; - esac - # shellcheck disable=SC2016 - case "$body_head" in - *'tmux kill-window -t "=$session:=$window"'*) ;; - *) fail "current tmux adapter lost exact kill-window selectors" ;; - esac - [ "$ref" != "$head" ] \ - || fail "permissive tmux baseline collapsed to HEAD; fixture is no longer historical" - - pass "historical permissive tmux kill baseline stays distinct from current exact selectors" -} - test_teardown_conformance_old_vs_new() { local old_bin fb proj wt id old_tmux_ref saved_base_ref local state_old state_new config_old config_new data log_old log_new out_old out_new rc_old rc_new @@ -1185,7 +1154,6 @@ test_backend_of_selector_matches_explicit_target_meta test_send_conformance_old_vs_new test_peek_conformance_old_vs_new test_spawn_symlinked_project_prefix_avoids_false_refusal -test_permissive_tmux_kill_ref_stays_historical test_teardown_conformance_old_vs_new test_spawn_refuses_unknown_backend_flag test_spawn_refuses_codex_app_backend_flag diff --git a/tests/fm-bearings-skill.test.sh b/tests/fm-bearings-skill.test.sh deleted file mode 100755 index 4fd9d485d3..0000000000 --- a/tests/fm-bearings-skill.test.sh +++ /dev/null @@ -1,129 +0,0 @@ -#!/usr/bin/env bash -# Static regression tests for the internal /bearings skill contract. -# shellcheck disable=SC2016 -set -u - -# shellcheck source=tests/lib.sh -. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" - -BEARINGS_SKILL="$ROOT/.agents/skills/bearings/SKILL.md" -README="$ROOT/README.md" -AUDIENCES="$ROOT/docs/documentation-audiences.json" - -skill_body() { - awk 'BEGIN { seen = 0 } /^---$/ { seen += 1; next } seen >= 2 { print }' "$BEARINGS_SKILL" -} - -chat_contract() { - awk '/^## Chat-response contract$/{capture=1; next} capture && /^## /{exit} capture' "$BEARINGS_SKILL" -} - -test_status_skill_is_absent() { - assert_absent "$ROOT/.agents/skills/status" "internal status skill directory must be removed" - assert_absent "$ROOT/.agents/skills/status/SKILL.md" "internal status skill file must be removed" - assert_absent "$ROOT/skills/status" "public status skill directory must not exist" - assert_no_grep '.agents/skills/status/SKILL.md' "$AUDIENCES" "documentation audience inventory still lists status" - assert_no_grep '| `/status`' "$README" "README still lists /status" - pass "/status is absent from the skill and documentation surfaces" -} - -test_plain_bearings_is_chat_only_by_default() { - assert_grep 'Plain `/bearings` returns only the concise four-section chat digest.' "$BEARINGS_SKILL" \ - "plain bearings default is not chat-only" - assert_grep 'Plain `/bearings` gathers a fresh bounded snapshot and renders the four-section chat digest without creating, deleting, reading, or replacing `data/status-report-.md`.' "$BEARINGS_SKILL" \ - "plain bearings does not forbid dated report writes" - assert_grep 'Plain mode stops here and writes no report artifact.' "$BEARINGS_SKILL" \ - "plain bearings can continue into report writing" - pass "plain /bearings is chat-only and forbids report artifacts" -} - -test_file_mode_owns_prior_report_artifact_behavior() { - assert_grep 'Only `/bearings file` writes the dated markdown report artifact and then returns the concise four-section chat digest linked to that report.' "$BEARINGS_SKILL" \ - "file mode is not the only report-writing mode" - assert_grep 'Write the full report to `data/status-report-.md` using today'"'"'s date.' "$BEARINGS_SKILL" \ - "file mode does not write the dated report" - assert_grep 'If today'"'"'s file already exists, delete it first, then create a new file from scratch.' "$BEARINGS_SKILL" \ - "file mode does not replace today's report from scratch" - assert_grep 'This is the only write allowed by the skill.' "$BEARINGS_SKILL" \ - "file mode write boundary is missing" - assert_grep 'After writing the file, return the concise four-section chat digest and include the report path or link without adding a fifth section.' "$BEARINGS_SKILL" \ - "file mode does not return the linked four-section digest" - pass "/bearings file owns the prior dated-report behavior" -} - -test_file_option_is_explicit_and_prs_compose() { - assert_grep 'Treat `file` only as an explicit invocation option in the slash command.' "$BEARINGS_SKILL" \ - "file is not pinned to an explicit slash option" - assert_grep 'Do not treat natural-language requests such as "write a report", "save this", "persist it", or "make a file" as file mode unless the invocation explicitly includes the standalone `file` option.' "$BEARINGS_SKILL" \ - "file mode can be triggered fuzzily" - assert_grep 'When the captain asks to include PRs, pass the snapshot command'"'"'s live-PR opt-in.' "$BEARINGS_SKILL" \ - "live PR opt-in is missing" - assert_grep '`/bearings include PRs` remains chat-only and makes the live-PR opt-in.' "$BEARINGS_SKILL" \ - "include PRs does not compose with chat-only mode" - assert_grep '`/bearings file include PRs` writes the dated report and makes the live-PR opt-in.' "$BEARINGS_SKILL" \ - "include PRs does not compose with file mode" - pass "file is explicit and live PR enrichment composes with both modes" -} - -test_single_fresh_snapshot_source_and_authoritative_provenance() { - local body count - body=$(skill_body) - count=$(grep -cF 'bin/fm-bearings-snapshot.sh' "$BEARINGS_SKILL") - [ "$count" = 1 ] || fail "bearings should reference the snapshot owner exactly once, found $count" - assert_contains "$body" 'Run `bin/fm-bearings-snapshot.sh` at invocation time and read its compact output.' \ - "bearings does not gather a fresh snapshot at invocation time" - assert_contains "$body" 'It is the single bounded, deterministic fleet-state source for Bearings and renders TOON by default.' \ - "bearings does not name the single bounded source" - assert_contains "$body" 'Do not create or consult a second fleet-state reader, parser contract, status-event-tail interpretation, visible-session recap, ad-hoc project probe, or ad-hoc `gh-axi`/`gh` query.' \ - "bearings allows a second reader or ad-hoc probe" - assert_contains "$body" 'For registered secondmates, use the snapshot'"'"'s structured-home classification and provenance.' \ - "bearings does not use structured secondmate provenance" - assert_contains "$body" 'Structured captain-held decisions come from `decision-hold-lifecycle` and appear under `decisions_open`.' \ - "bearings does not preserve structured captain-held decisions" - assert_not_contains "$body" 'fm-fleet-snapshot.sh' "bearings creates a second canonical snapshot path" - assert_not_contains "$body" 'fm-crew-state.sh' "bearings creates an extra current-state reader" - pass "bearings keeps the fresh structured snapshot as the single source" -} - -test_chat_contract_four_sections_for_both_modes() { - local body headings expected report_headings - body=$(chat_contract) - headings=$(printf '%s\n' "$body" | sed -nE "s/^[0-9]+\. \*\*([^*]+)\*\*.*/\1/p") - expected=$(printf '%s\n' "Captain's Call" "Recently Landed" "Underway" "Charted Next") - [ "$headings" = "$expected" ] || fail "chat contract must contain exactly four numbered sections in fixed order, got: $headings" - assert_contains "$body" "Nothing needs your action right now" "Captain's Call empty-state sentence" - assert_contains "$body" "No recent completions are in the current baseline" "Recently Landed empty-state sentence" - assert_contains "$body" "Nothing is underway" "Underway empty-state sentence" - assert_contains "$body" "Nothing is queued" "Charted Next empty-state sentence" - report_headings=$(sed -nE 's/^ - \*\*(Captain.s Call|Recently Landed|Underway|Charted Next)\*\*.*/\1/p' "$BEARINGS_SKILL") - [ "$report_headings" = "$expected" ] || fail "detailed report contract must contain the same four complete sections, got: $report_headings" - assert_contains "$body" "no At Anchor section" "the At Anchor exclusion must be documented" - assert_contains "$body" "Every chat digest and file-mode report is a complete current snapshot" "both modes must be complete current snapshots" - assert_contains "$body" "Detailed decisions, plans, full gate reasons, and evidence belong in the file only when file mode is explicit" \ - "plain chat must not depend on a detailed report file" - assert_contains "$body" "In file mode, include the report path or link inside the four-section digest without adding another heading." \ - "file mode must link the report without a fifth section" - pass "both Bearings modes keep the exact four-section chat contract" -} - -test_readme_describes_bearings_modes() { - assert_grep '| `/bearings` | Generate a concise four-section chat digest from bounded local fleet and registered-secondmate state; use `/bearings file` to also replace today'"'"'s dated report in `data/`, and add `include PRs` when live PR enrichment is wanted |' "$README" \ - "README skill table does not describe chat-only default and file option" - assert_grep '- `/bearings` returns the fresh four-section digest in chat only.' "$README" \ - "README lacks plain bearings example" - assert_grep '- `/bearings include PRs` keeps chat-only mode and opts into live PR enrichment.' "$README" \ - "README lacks chat-only live PR example" - assert_grep '- `/bearings file` replaces today'"'"'s `data/status-report-.md` from scratch and links it from the four-section chat digest.' "$README" \ - "README lacks file mode example" - assert_grep '- `/bearings file include PRs` combines the dated report with live PR enrichment.' "$README" \ - "README lacks file mode live PR example" - pass "README documents Bearings default and file mode" -} - -test_status_skill_is_absent -test_plain_bearings_is_chat_only_by_default -test_file_mode_owns_prior_report_artifact_behavior -test_file_option_is_explicit_and_prs_compose -test_single_fresh_snapshot_source_and_authoritative_provenance -test_chat_contract_four_sections_for_both_modes -test_readme_describes_bearings_modes diff --git a/tests/fm-bearings-snapshot.test.sh b/tests/fm-bearings-snapshot.test.sh index 32c1e1b1e9..31c27a677a 100755 --- a/tests/fm-bearings-snapshot.test.sh +++ b/tests/fm-bearings-snapshot.test.sh @@ -1861,35 +1861,6 @@ EOF pass "main and secondmate captain actionability use the same blocker readiness" } -# The /bearings skill is the one owner of the four-section chat-response contract. -# Assert it states exactly the four fixed sections in order, each with its explicit -# empty-state sentence, documents the At Anchor exclusion, and keeps file-mode links -# inside the four-section digest. -test_chat_contract_four_sections() { - local skill body headings report_headings expected - skill="$ROOT/.agents/skills/bearings/SKILL.md" - [ -f "$skill" ] || fail "bearings SKILL.md missing at $skill" - body=$(awk '/^## Chat-response contract$/{capture=1; next} capture && /^## /{exit} capture' "$skill") - headings=$(printf '%s\n' "$body" | sed -nE "s/^[0-9]+\. \*\*([^*]+)\*\*.*/\1/p") - expected=$(printf '%s\n' "Captain's Call" "Recently Landed" "Underway" "Charted Next") - [ "$headings" = "$expected" ] || fail "chat contract must contain exactly four numbered sections in fixed order, got: $headings" - assert_contains "$body" "Nothing needs your action right now" "Captain's Call empty-state sentence" - assert_contains "$body" "No recent completions are in the current baseline" "Recently Landed empty-state sentence" - assert_contains "$body" "Nothing is underway" "Underway empty-state sentence" - assert_contains "$body" "Nothing is queued" "Charted Next empty-state sentence" - report_headings=$(sed -nE 's/^ - \*\*(Captain.s Call|Recently Landed|Underway|Charted Next)\*\*.*/\1/p' "$skill") - [ "$report_headings" = "$expected" ] || fail "detailed report contract must contain the same four complete sections, got: $report_headings" - grep -Eq 'since the (prior|last) report|Nothing has landed since|unchanged delta' "$skill" \ - && fail "bearings contract still contains prior-report delta wording" - # shellcheck disable=SC2016 # Backticks are literal Markdown in the expected text. - assert_contains "$(cat "$skill")" 'Never read an earlier `data/status-report-*.md`' "prior reports must not influence current output" - assert_contains "$(cat "$skill")" "bounded current recent-completions baseline" "Recently Landed must be a current baseline" - assert_contains "$body" "no At Anchor section" "the At Anchor exclusion must be documented" - assert_contains "$body" "materially shorter" "the file-mode chat must be materially shorter than the report file" - assert_contains "$body" "report path or link" "file mode must link the report from inside the digest" - pass "the /bearings skill states the four-section chat contract in order, with empty-states and the At Anchor exclusion" -} - test_domain_alpha_stale_parent_event_does_not_become_current_work test_gnu_stat_uses_file_formats_without_bsd_fallback_pollution test_parent_activity_evidence_is_bounded_and_disclosed @@ -1920,7 +1891,6 @@ test_main_unstructured_current_is_disclosed_with_structured_sibling test_main_orphan_counterfactual_meta_clears_inventory_warning test_mixed_secondmate_roles_partial_state_and_captain_readiness test_main_captain_readiness_matches_secondmate_projection -test_chat_contract_four_sections test_completed_scout_report_not_pending test_open_decision_surfaces_end_to_end test_report_pointers_surface diff --git a/tests/fm-bootstrap.test.sh b/tests/fm-bootstrap.test.sh index 48cd09a093..219d74de58 100755 --- a/tests/fm-bootstrap.test.sh +++ b/tests/fm-bootstrap.test.sh @@ -718,19 +718,6 @@ test_routine_bootstrap_contract_runs_under_system_bash() { pass "bootstrap routine contract runs under system /bin/bash" } -test_bootstrap_info_is_no_load_and_actionable_lines_trigger() { - local trigger - # shellcheck disable=SC2016 # The backtick-delimited skill names are literal Markdown. - trigger=$(sed -n '/- `bootstrap-diagnostics`/,/- `diagnostic-reasoning`/p' "$ROOT/AGENTS.md") - assert_contains "$trigger" "actionable diagnostic line" "bootstrap-diagnostics trigger should be action-scoped" - assert_contains "$trigger" "BOOTSTRAP_INFO:" "bootstrap-diagnostics trigger should classify BOOTSTRAP_INFO as no-load" - assert_not_contains "$trigger" "TASKS_AXI:" "tasks-axi availability must not trigger diagnostics loading" - assert_not_contains "$trigger" "CREW_HARNESS_OVERRIDE:" "harness override confirmation must not trigger diagnostics loading" - assert_not_contains "$trigger" "CREW_DISPATCH: active" "active dispatch confirmation must not trigger diagnostics loading" - assert_not_contains "$trigger" "already-live" "already-live secondmate liveness must not trigger diagnostics loading" - pass "bootstrap diagnostics trigger excludes benign lines and keeps actionable prefixes" -} - test_crew_dispatch_active_rules_are_verbose_bootstrap_info() { local case_dir fakebin out expect case_dir="$TMP_ROOT/dispatch-active" @@ -821,6 +808,5 @@ test_fleet_sync_timeout_empty_override_uses_default test_fleet_sync_timeout_is_computed_before_launch test_routine_bootstrap_confirmations_are_silent test_routine_bootstrap_contract_runs_under_system_bash -test_bootstrap_info_is_no_load_and_actionable_lines_trigger test_crew_dispatch_active_rules_are_verbose_bootstrap_info test_crew_dispatch_validation diff --git a/tests/fm-calm-pi-extension.test.sh b/tests/fm-calm-pi-extension.test.sh index 46b945e23f..a8e09be145 100755 --- a/tests/fm-calm-pi-extension.test.sh +++ b/tests/fm-calm-pi-extension.test.sh @@ -67,67 +67,6 @@ find_chrome() { return 1 } -test_static_contract() { - local text assistant_layout operational_user_layout visibility watch operational - assert_present "$EXT" "tracked Pi calm extension is missing" - assert_present "$ASSISTANT_LAYOUT" "tracked Pi Calm assistant-layout adapter is missing" - assert_present "$OPERATIONAL_USER_LAYOUT" "tracked Pi Calm operational-user layout adapter is missing" - assert_present "$VISIBILITY" "tracked Pi calm visibility policy is missing" - text=$(cat "$EXT") - assistant_layout=$(cat "$ASSISTANT_LAYOUT") - operational_user_layout=$(cat "$OPERATIONAL_USER_LAYOUT") - visibility=$(cat "$VISIBILITY") - watch=$(cat "$WATCH_EXT") - operational=$(cat "$PI_OPERATIONAL_INPUT") - assert_contains "$text" 'pi.registerCommand("calm"' "Pi calm extension does not register /calm" - assert_contains "$text" 'pi.on("session_start"' "Pi calm extension does not restore presentation on every session start" - assert_contains "$text" 'loadCalmPreference()' "Pi calm extension does not restore the home-persistent toggle choice" - assert_contains "$text" 'persistCalmPreference(active)' "Pi calm extension does not persist the captain's toggle choice" - assert_not_contains "$text" 'setCalmPresentation(false)' "Pi calm extension still resets the toggle on session start" - assert_contains "$text" 'ctx.ui.setToolsExpanded(!expanded)' "Pi calm extension does not redraw existing custom entries" - assert_contains "$text" 'ctx.ui.setToolsExpanded(expanded)' "Pi calm extension does not restore Ctrl+O state after redraw" - assert_not_contains "$text" 'ctx.navigateTree' "Pi calm extension reconstructs the transcript and drops transient diagnostics" - assert_not_contains "$visibility" 'deliverFirstmateSyntheticInput' "Pi calm visibility policy can still replace operational input semantics" - assert_not_contains "$visibility" 'classifyFirstmateSyntheticInput' "Pi calm visibility policy still classifies operational input for interception" - assert_contains "$text" 'ctx.ui.setWorkingVisible(true)' "Pi calm extension does not preserve Pi's live working row" - assert_not_contains "$text" 'ctx.ui.setWorkingVisible(!active)' "Pi calm extension still hides Pi's live working row" - assert_contains "$text" 'ctx.ui.setHiddenThinkingLabel(active ? "" : undefined)' "Pi calm extension does not hide collapsed thinking labels" - assert_contains "$text" 'installCalmPresentationAdapter("collapsed-thinking", installCalmAssistantLayout)' "Pi Calm extension does not install its zero-height assistant layout" - assert_contains "$text" 'installCalmPresentationAdapter("operational-user-row", installCalmOperationalUserLayout)' "Pi Calm extension does not install its operational-user layout" - assert_contains "$text" 'function installCalmPresentationAdapter' "Pi Calm extension does not degrade a missing presentation adapter independently with a diagnostic" - assert_contains "$assistant_layout" 'import * as PiCodingAgent' "Pi Calm assistant layout still requires its optional runtime class as a named import" - assert_contains "$assistant_layout" 'AssistantMessageComponent.prototype.updateContent' "Pi Calm assistant layout does not control the exported component presentation path" - assert_contains "$assistant_layout" 'block.type !== "thinking"' "Pi Calm assistant layout does not remove thinking from its presentation copy" - assert_contains "$operational_user_layout" 'import * as PiCodingAgent' "Pi Calm operational-user layout still requires its optional runtime class as a named import" - assert_contains "$operational_user_layout" 'InteractiveMode.prototype' "Pi Calm operational-user layout does not control the transcript owner" - assert_contains "$operational_user_layout" 'classifyFirstmateCurrentOperationalText(text)' "Pi Calm operational-user layout bypasses canonical current classification" - assert_contains "$operational_user_layout" 'text.includes("\u2063")' "Pi Calm operational-user layout spawns its classifier for ordinary captain rows" - assert_contains "$operational_user_layout" '"\u2063Supervisor escalate ("' "Pi Calm operational-user layout lost the narrow legacy marker" - assert_contains "$operational_user_layout" 'hidesOperationalInput()' "Pi Calm operational-user row does not use presentation-only hiding" - assert_not_contains "$operational_user_layout" 'FIRSTMATE_OP: ' "Pi Calm operational-user layout duplicates the canonical marker grammar" - assert_not_contains "$text" 'calm transcript' "Pi calm extension still adds a persistent Calm status row" - assert_not_contains "$text" 'pi.on("input"' "Pi calm extension still intercepts semantic input" - assert_not_contains "$text" 'sendMessage' "Pi calm extension still replaces user-role input with custom context" - assert_contains "$text" 'ctx.ui.onTerminalInput' "Pi calm extension does not scope export rendering to terminal submissions" - assert_contains "$text" 'getKeybindings().matches(data, "tui.input.submit")' "Pi calm export boundary ignores the active submit keybinding" - assert_contains "$text" 'input !== "/share"' "Pi calm export boundary does not cover /share" - assert_not_contains "$text" 'FIRSTMATE_PI_LAUNCH_BRIEF_ENV' "Pi calm presentation still depends on launch-input provenance" - assert_contains "$text" 'renderShell: "self"' "Pi calm extension cannot remove complete built-in tool shells" - assert_contains "$visibility" 'CALM_VISIBLE_CLASSES' "Pi calm policy does not centralize its visibility allowlist" - assert_contains "$operational" 'fm-operational-input.sh' "Pi adapter does not delegate to the canonical cross-language owner" - assert_not_contains "$visibility" 'FIRSTMATE WATCHER WAKE:' "current Calm classification still matches watcher payload prose" - assert_not_contains "$visibility" 'TURN WOULD END BLIND' "current Calm classification still matches turn-end payload prose" - # shellcheck disable=SC2016 # Backticks are literal prompt markup. - assert_not_contains "$visibility" 'Run `bin/fm-session-start.sh`' "current Calm classification still matches session-start payload prose" - assert_not_contains "$visibility" 'FIRSTMATE_OP: ' "current Calm classification duplicates the canonical marker grammar" - assert_contains "$watch" 'calmHides("assistant-tool-call")' "Firstmate watcher tool does not participate in Calm presentation" - assert_contains "$watch" 'renderShell: "self"' "Firstmate watcher tool cannot remove its complete shell" - for name in Read Bash Edit Write Grep Find Ls; do - assert_contains "$text" "create${name}ToolDefinition" "Pi calm extension does not wrap the $name built-in" - done - pass "Pi calm extension is presentation-only with one persisted visibility choice, no Calm status row, native working visibility, supported redraw controls, and the Firstmate watcher-tool integration" -} - test_home_resolution() { local fixture out status version if ! command -v node >/dev/null 2>&1 || ! command -v npm >/dev/null 2>&1; then @@ -2146,7 +2085,6 @@ JS pass "Pi calm native E2E keeps Working and captain turns visible, hides exact operational user rows without changing persistence, restores them Calm-off, survives restart, and preserves export plus Ctrl+O behavior" } -test_static_contract test_home_resolution test_pi_compat_no_upper_bound test_pi_compat_degraded_adapter diff --git a/tests/fm-captain-translation-contract.test.sh b/tests/fm-captain-translation-contract.test.sh deleted file mode 100755 index cc7c75311e..0000000000 --- a/tests/fm-captain-translation-contract.test.sh +++ /dev/null @@ -1,293 +0,0 @@ -#!/usr/bin/env bash -# Static regression tests for the captain-facing plain-English translation -# contract owned by AGENTS.md section 9. -# shellcheck disable=SC2016 -set -u - -# shellcheck source=tests/lib.sh -. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" - -AGENTS="$ROOT/AGENTS.md" -BOOTSTRAP="$ROOT/.agents/skills/bootstrap-diagnostics/SKILL.md" -AFK="$ROOT/.agents/skills/afk/SKILL.md" -DECISION="$ROOT/.agents/skills/decision-hold-lifecycle/SKILL.md" -RECOVERY="$ROOT/.agents/skills/stuck-crewmate-recovery/SKILL.md" -HARNESS="$ROOT/.agents/skills/harness-adapters/SKILL.md" -CODEXAPP="$ROOT/.agents/skills/firstmate-codexapp/SKILL.md" -FMX="$ROOT/.agents/skills/fmx-respond/SKILL.md" -UPDATE="$ROOT/.agents/skills/updatefirstmate/SKILL.md" -AHOY="$ROOT/.agents/skills/ahoy/SKILL.md" -README="$ROOT/README.md" - -section_9() { - awk ' - /^## 9\. Escalation and captain etiquette$/ { found = 1 } - found && /^## 10\. / { exit } - found { print } - ' "$AGENTS" -} - -test_section_9_owns_positive_translation_contract() { - local contract - contract=$(section_9) - assert_contains "$contract" "Every captain-facing message must translate internal state into the project outcome, consequence, and next decision." \ - "section 9 does not own the positive captain-facing translation contract" - assert_contains "$contract" "Use the captain's nouns:" \ - "section 9 does not require captain-owned nouns" - assert_contains "$contract" "When evidence uses an internal label, rewrite it before sending:" \ - "section 9 does not own the rewrite mapping list" - pass "section 9 owns the positive captain-facing translation contract" -} - -test_scout_remains_allowed_house_vocabulary() { - local contract - contract=$(section_9) - assert_contains "$contract" "Scout and second mate are accepted Firstmate nautical house vocabulary and do not need translation" \ - "section 9 does not preserve scout as allowed Firstmate vocabulary" - assert_not_contains "$contract" "scout -> investigation" \ - "section 9 must not map scout to investigation" - assert_not_contains "$contract" "scout, ship" \ - "section 9 must not add scout to the internal-vocabulary ban" - assert_not_contains "$contract" "secondmate -> domain supervisor" \ - "section 9 must not map secondmate to domain supervisor" - pass "scout remains allowed in private captain chat" -} - -test_compressed_safety_labels_have_plain_renderings() { - local contract - contract=$(section_9) - for phrase in \ - "fail-closed" \ - "fails closed" \ - "fail-open" \ - "fails open" \ - "fail loudly"; do - assert_contains "$contract" "$phrase" "section 9 does not cover compressed safety label '$phrase'" - done - assert_contains "$contract" "stops safely when something goes wrong" \ - "fail-closed behavior lacks a concrete plain rendering" - assert_contains "$contract" "refuses rather than proceeding" \ - "fail-closed behavior lacks refusal wording" - assert_contains "$contract" "steps aside and lets work continue when the check cannot complete" \ - "fail-open behavior lacks a concrete plain rendering" - pass "compressed safety labels require concrete plain renderings" -} - -test_mapping_list_covers_high_risk_internal_families() { - local contract - contract=$(section_9) - for phrase in \ - "worktree, checkout, primary checkout, or local-main -> local copy" \ - "teardown -> cleanup" \ - "wake, watcher, heartbeat, stale, signal, or check -> notification" \ - "hold, gate, ask-user, needs-decision, blocked, or paused -> the concrete decision" \ - "done, failed, fix-review, checks-passed, cancelled, validation step, or pipeline state -> the concrete result" \ - "brief -> instructions" \ - "crewmate -> worker" \ - "harness, backend, runtime, or adapter -> worker runtime or tool" \ - "status file, metadata, state, task id, or raw path -> durable record"; do - assert_contains "$contract" "$phrase" "section 9 mapping list is missing '$phrase'" - done - pass "section 9 maps high-risk internal vocabulary families" -} - -test_verbatim_internal_evidence_is_rejected_from_chat() { - local contract - contract=$(section_9) - assert_contains "$contract" "Never relay worker reports, status lines, tool output, validation-state labels, or decision records verbatim into captain chat." \ - "section 9 does not reject verbatim internal evidence in captain chat" - assert_contains "$contract" "Private evidence reports may retain exact identifiers, paths, status lines, validation labels, and internal terms" \ - "section 9 does not preserve private evidence precision" - assert_contains "$contract" "the captain-facing chat summary that points to the report still follows this translation rule" \ - "section 9 does not keep chat summaries plain English" - pass "captain chat rejects verbatim internal evidence while private reports stay precise" -} - -test_routine_no_action_response_is_event_scoped() { - local contract - contract=$(section_9) - assert_contains "$contract" 'reply exactly `Captain, shipshape.` without characterizing the visible session' \ - "section 9 does not require the exact event-scoped routine no-action response" - assert_not_contains "$contract" 'Captain, no decision is needed.' \ - "section 9 implies the visible session has no unrelated open decisions" - pass "routine no-action response is exact and scoped to its event" -} - -test_outward_facing_skill_points_reference_section_9_owner() { - assert_grep "using \`AGENTS.md\` section 9's captain-facing translation contract" "$BOOTSTRAP" \ - "bootstrap diagnostics do not reference section 9 at captain handoff" - assert_grep "Acknowledge** in \`AGENTS.md\` section 9 language" "$AFK" \ - "afk acknowledgement does not reference section 9" - assert_grep "Captain, away mode is active; I will batch routine updates" "$AFK" \ - "afk acknowledgement lacks a local plain-English example" - assert_grep "as decisions from Bearings' Captain's Call section under \`AGENTS.md\` section 9" "$DECISION" \ - "decision relay does not reference section 9" - assert_grep "using \`AGENTS.md\` section 9; do not mention metadata, harness, window, or worktree" "$RECOVERY" \ - "stuck-worker failure does not reference section 9" - assert_grep "under \`AGENTS.md\` section 9 that the requested worker runtime is not verified yet" "$HARNESS" \ - "runtime fallback does not reference section 9" - assert_grep "use firstmate's own verified runtime for current work" "$HARNESS" \ - "runtime fallback does not require the current-work fallback" - assert_grep "Do not pause current work for that future-verification choice, and never launch an unverified adapter." "$HARNESS" \ - "runtime fallback permits waiting on future verification or launching an unverified adapter" - assert_grep "translate status prefixes and return-channel evidence through \`AGENTS.md\` section 9" "$CODEXAPP" \ - "Codex Desktop result reporting does not reference section 9" - assert_grep "It supplements \`AGENTS.md\` section 9; apply both, and this public-channel rule wins wherever it is stricter." "$FMX" \ - "X reply safety does not state that it supplements section 9" - assert_grep "under \`AGENTS.md\` section 9 without firstmate's internal vocabulary" "$UPDATE" \ - "Firstmate update reporting does not reference section 9" - pass "outward-facing skill handoffs point to the section 9 owner" -} - -test_section_9_owner_is_not_duplicated_into_skills() { - local duplicate_count file - duplicate_count=0 - for file in "$BOOTSTRAP" "$AFK" "$DECISION" "$RECOVERY" "$HARNESS" "$CODEXAPP" "$UPDATE"; do - if grep -Fq "When evidence uses an internal label, rewrite it before sending:" "$file"; then - duplicate_count=$((duplicate_count + 1)) - fi - done - [ "$duplicate_count" -eq 0 ] || fail "skills duplicated section 9's mapping owner" - pass "skills cross-reference section 9 instead of duplicating the mapping list" -} - -test_ahoy_is_an_internal_user_invocable_skill() { - assert_present "$AHOY" "ahoy skill is missing" - assert_grep 'name: ahoy' "$AHOY" "ahoy skill metadata has the wrong name" - assert_grep 'user-invocable: true' "$AHOY" "ahoy skill is not user-invocable" - assert_grep ' internal: true' "$AHOY" "ahoy skill is not internal" - [ ! -e "$ROOT/skills/ahoy" ] || fail "ahoy must not exist in the public installer-facing skills directory" - pass "ahoy is internal, user-invocable, and absent from public skills" -} - -test_ahoy_readme_uses_cross_harness_convention() { - assert_grep 'Claude and grok use the slash form shown here; codex uses the same names with `$`' "$README" \ - "README lost the cross-harness slash and dollar convention" - assert_grep '| `/ahoy`' "$README" "README built-in skills table does not list /ahoy" - pass "README lists ahoy under the shared cross-harness invocation convention" -} - -test_ahoy_owns_only_the_visible_session_recap() { - assert_grep '[`../bearings/SKILL.md`](../bearings/SKILL.md)' "$AHOY" \ - "first-message fallback does not delegate to Bearings by relative pointer" - assert_grep 'If no prior real captain message exists' "$AHOY" \ - "ahoy does not limit Bearings fallback to the first real captain message" - assert_grep 'Bearings alone owns its gathering, artifact, and response contract.' "$AHOY" \ - "ahoy first-message fallback does not delegate to Bearings alone" - assert_grep 'A captain boundary is an ordinary user-role message unless it matches one of the narrow operational exclusions below.' "$AHOY" \ - "ahoy lacks an explicit captain-authored boundary rule" - assert_grep 'Exclude messages that begin with the current U+2063 `FIRSTMATE_OP:` injection prefix.' "$AHOY" \ - "ahoy does not exclude current marked operational injections" - assert_grep 'Exclude legacy bare-marker away-mode injections only when U+2063 is immediately followed by `Supervisor escalate (`.' "$AHOY" \ - "ahoy does not narrowly exclude the legacy away-mode injection shape" - assert_grep 'Exclude the exact legacy unmarked session-start payload ``Run `bin/fm-session-start.sh` now, exactly once, before executing any other instructions.``' "$AHOY" \ - "ahoy does not exclude the legacy unmarked session-start payload" - assert_grep 'quotes or embeds a current operational message after ordinary captain text' "$AHOY" \ - "ahoy lacks quoted-current near-miss protection" - assert_grep 'Apply the current exclusion only when U+2063 `FIRSTMATE_OP:` begins at the first character of the whole message' "$AHOY" \ - "ahoy does not pin the current-prefix whole-message boundary" - assert_grep 'contains ASCII `FIRSTMATE_OP:` without a leading U+2063' "$AHOY" \ - "ahoy lacks ASCII-only near-miss protection" - assert_grep 'Apply the legacy startup exclusion as a literal whole-message match: ``Captain quote: Run `bin/fm-session-start.sh` now, exactly once, before executing any other instructions.`` is a captain boundary.' "$AHOY" \ - "ahoy does not pin the altered-startup behavioral near miss" - assert_grep 'System, developer, tool, watcher, guard, away-mode, and other injected operational messages are not captain messages.' "$AHOY" \ - "ahoy incorrectly treats synthetic operational messages as captain messages" - assert_grep 'The normal recap branch is session-history-only.' "$AHOY" \ - "later ahoy invocation is not explicitly session-history-only" - assert_grep 'Do not call Bearings, shell commands, fleet snapshots, status readers, GitHub or browser APIs, tools, or file reads or writes.' "$AHOY" \ - "normal recap does not prohibit fresh fleet, file, and tool reads" - assert_grep 'Create no report, persist nothing' "$AHOY" \ - "normal recap does not prohibit artifacts and storage" - assert_grep 'do not guess current live state beyond the last visible event' "$AHOY" \ - "normal recap may falsely claim a live snapshot" - assert_grep 'The current `/ahoy` message is outside the recap interval.' "$AHOY" \ - "current ahoy invocation is not excluded from the recap interval" - assert_grep 'If context compaction makes the prior boundary unavailable' "$AHOY" \ - "ahoy does not disclose an unavailable compacted boundary" - assert_grep 'summarize only visibly supported events' "$AHOY" \ - "compacted fallback may invent unsupported events" - assert_no_grep 'fm-bearings-snapshot.sh' "$AHOY" \ - "ahoy copied Bearings gathering mechanics instead of referencing its owner" - assert_no_grep "Captain's Call" "$AHOY" \ - "ahoy copied Bearings response contract instead of referencing its owner" - pass "ahoy delegates first-message fallback and keeps later recaps visible-session-only" -} - -test_ahoy_scans_visible_history_for_open_decisions() { - assert_grep 'preserve the ordinary recap interval: recap what happened after that message and before the current invocation.' "$AHOY" \ - "ahoy no longer preserves its ordinary recap interval" - assert_grep 'inspect the entire session history visible to the current first mate before the current invocation for every explicit captain decision that remains unanswered' "$AHOY" \ - "ahoy does not scan globally visible session history for open decisions" - assert_grep 'including decisions raised before the ordinary recap boundary.' "$AHOY" \ - "ahoy does not include open decisions from before the recap boundary" - assert_grep 'A later unrelated captain message establishes a recap boundary but does not close an earlier decision.' "$AHOY" \ - "ahoy lets unrelated captain messages close earlier decisions" - assert_grep 'Treat a decision as closed only when a later visible response substantively resolves it, chooses an option, declines it, grants or denies the requested approval, or otherwise directly addresses that decision.' "$AHOY" \ - "ahoy lacks substantive-answer closure semantics" - assert_grep 'Include every visibly supported open decision once, and deduplicate by the decision' "$AHOY" \ - "ahoy does not include and deduplicate visibly open decisions" - assert_grep "substance when the ordinary interval recap already represents it or its wording differs." "$AHOY" \ - "ahoy deduplicates decisions by wording instead of substance" - assert_grep 'If no ordinary events occurred after the previous captain message but an older visibly open decision exists, report that decision instead of claiming nothing happened.' "$AHOY" \ - "ahoy can incorrectly claim nothing happened while an older decision is open" - assert_grep 'Compacted history supports an open decision only when both its request and its still-unanswered status are visible' "$AHOY" \ - "ahoy does not limit compacted decision reporting to visible support" - assert_grep 'report uncertainty instead of reconstructing hidden requests or answers.' "$AHOY" \ - "ahoy may reconstruct hidden decision history after compaction" - pass "ahoy adds visibly open decisions without changing the ordinary recap boundary" -} - -test_ahoy_user_role_injections_share_one_marker() { - local daemon grok_guard opencode_guard opencode_watch pi_guard pi_watch owner sessionstart spawn - daemon=$(cat "$ROOT/bin/fm-supervise-daemon.sh") - grok_guard=$(cat "$ROOT/bin/fm-turnend-guard-grok.sh") - opencode_guard=$(cat "$ROOT/.opencode/plugins/fm-primary-turnend-guard.js") - opencode_watch=$(cat "$ROOT/.opencode/plugins/fm-primary-watch-arm.js") - pi_guard=$(cat "$ROOT/.pi/extensions/fm-primary-turnend-guard.ts") - pi_watch=$(cat "$ROOT/.pi/extensions/fm-primary-pi-watch.ts") - owner=$(cat "$ROOT/bin/fm-operational-input.sh") - sessionstart=$(cat "$ROOT/bin/fm-sessionstart-nudge.sh") - spawn=$(cat "$ROOT/bin/fm-spawn.sh") - - assert_contains "$owner" 'FM_OPERATIONAL_PREFIX="${FM_OPERATIONAL_MARK}FIRSTMATE_OP: "' \ - "canonical owner lost the landed Ahoy prefix" - assert_contains "$sessionstart" 'fm_operational_input_encode session-start' \ - "session-start does not use the canonical typed constructor" - assert_contains "$daemon" 'fm_operational_input_encode away-supervisor' \ - "away-mode does not use the canonical typed constructor" - assert_contains "$grok_guard" 'fm_operational_input_encode turn-end-guard' \ - "Grok guard does not use the canonical typed constructor" - assert_contains "$opencode_guard" 'encodeFirstmateOperationalInput(' \ - "OpenCode guard does not use the cross-language constructor" - assert_contains "$opencode_guard" '"turn-end-guard"' \ - "OpenCode guard does not retain its exact current kind" - assert_contains "$opencode_watch" 'encodeFirstmateOperationalInput(paths.root, "watcher"' \ - "OpenCode watcher does not retain its exact current kind" - assert_contains "$pi_guard" 'encodeFirstmateOperationalInput(' \ - "Pi guard does not use the cross-language constructor" - assert_contains "$pi_guard" '"turn-end-guard"' \ - "Pi guard does not retain its exact current kind" - assert_contains "$pi_watch" '"watcher"' \ - "Pi watcher does not retain its exact current kind" - assert_contains "$spawn" 'encode launch-brief' \ - "cross-harness launches do not use the canonical launch-instruction kind" - for producer in "$daemon" "$grok_guard" "$opencode_guard" "$opencode_watch" "$pi_guard" "$pi_watch" "$sessionstart" "$spawn"; do - assert_not_contains "$producer" 'FIRSTMATE_OP: ' \ - "a current producer copied the canonical marker grammar" - done - pass "ahoy: one canonical owner constructs typed operational input for every Firstmate-controlled user-role producer" -} - -test_section_9_owns_positive_translation_contract -test_scout_remains_allowed_house_vocabulary -test_compressed_safety_labels_have_plain_renderings -test_mapping_list_covers_high_risk_internal_families -test_verbatim_internal_evidence_is_rejected_from_chat -test_routine_no_action_response_is_event_scoped -test_outward_facing_skill_points_reference_section_9_owner -test_section_9_owner_is_not_duplicated_into_skills -test_ahoy_is_an_internal_user_invocable_skill -test_ahoy_readme_uses_cross_harness_convention -test_ahoy_owns_only_the_visible_session_recap -test_ahoy_scans_visible_history_for_open_decisions -test_ahoy_user_role_injections_share_one_marker diff --git a/tests/fm-cd-pretool-check.test.sh b/tests/fm-cd-pretool-check.test.sh index f623430b13..80f8c03fc9 100755 --- a/tests/fm-cd-pretool-check.test.sh +++ b/tests/fm-cd-pretool-check.test.sh @@ -372,68 +372,6 @@ test_policy_cli_direct() { # --- per-harness wiring ----------------------------------------------------- -test_claude_wiring() { - local settings n - settings="$ROOT/.claude/settings.json" - [ -f "$settings" ] || fail "tracked .claude/settings.json is missing" - n=$(jq -r '[.hooks.PreToolUse[0].hooks[].command | select(contains("fm-cd-pretool-check.sh"))] | length' "$settings") - [ "$n" = 1 ] || fail "claude PreToolUse must invoke fm-cd-pretool-check.sh exactly once" - jq -e '[.hooks.PreToolUse[0].hooks[].command | select(contains("fm-cd-pretool-check.sh") and contains("--claude") and contains("CLAUDE_PROJECT_DIR"))] | length == 1' "$settings" >/dev/null \ - || fail "claude cd hook must use CLAUDE_PROJECT_DIR and --claude" - jq -e '[.hooks.PreToolUse[0].hooks[].command | select(contains("fm-arm-pretool-check.sh"))] | length == 1' "$settings" >/dev/null \ - || fail "claude cd hook must not displace the watcher-arm hook" - pass ".claude/settings.json: PreToolUse invokes the cd-guard alongside the arm guard" -} - -test_codex_wiring() { - local settings command - settings="$ROOT/.codex/hooks.json" - [ -f "$settings" ] || fail "tracked .codex/hooks.json is missing" - command=$(jq -r '[.hooks.PreToolUse[0].hooks[].command | select(contains("fm-cd-pretool-check.sh"))][0] // empty' "$settings") - [ -n "$command" ] || fail "codex PreToolUse must invoke fm-cd-pretool-check.sh" - assert_contains "$command" 'pwd -P' "codex cd hook must anchor from the hook process working directory" - assert_contains "$command" 'fm-cd-pretool-check.sh' "codex cd hook must invoke the cd-guard" - jq -e '[.hooks.PreToolUse[0].hooks[].command | select(contains("fm-arm-pretool-check.sh"))] | length == 1' "$settings" >/dev/null \ - || fail "codex cd hook must not displace the watcher-arm hook" - pass ".codex/hooks.json: PreToolUse invokes the cd-guard alongside the arm guard" -} - -test_grok_wiring() { - local settings command - settings="$ROOT/.grok/hooks/fm-primary-cd-check.json" - [ -f "$settings" ] || fail "tracked grok cd hook config is missing" - command=$(jq -r '.hooks.PreToolUse[0].hooks[0].command // empty' "$settings") - [ -n "$command" ] || fail "grok cd hook command is missing" - assert_contains "$command" 'GROK_WORKSPACE_ROOT' "grok cd hook must anchor from GROK_WORKSPACE_ROOT" - assert_contains "$command" 'fm-cd-pretool-check.sh' "grok cd hook must invoke the cd-guard" - assert_contains "$command" '${GROK_WORKSPACE_ROOT:-}' "grok cd hook must default-guard the workspace var" - pass ".grok primary cd hook: PreToolUse invokes the cd-guard" -} - -test_opencode_wiring() { - local plugin content - plugin="$ROOT/.opencode/plugins/fm-primary-cd-check.js" - [ -f "$plugin" ] || fail "tracked OpenCode cd plugin is missing" - content=$(cat "$plugin") - assert_contains "$content" 'tool.execute.before' "OpenCode cd plugin must run before tool execution" - assert_contains "$content" 'fm-cd-pretool-check.sh' "OpenCode cd plugin must invoke the cd-guard" - assert_contains "$content" 'throw new Error' "OpenCode cd plugin must block by throwing" - assert_contains "$content" 'worktree' "OpenCode cd plugin must anchor from the git worktree path" - pass ".opencode cd plugin: tool.execute.before invokes the cd-guard and blocks by throwing" -} - -test_pi_wiring() { - local ext content - ext="$ROOT/.pi/extensions/fm-primary-turnend-guard.ts" - [ -f "$ext" ] || fail "tracked pi primary extension is missing" - content=$(cat "$ext") - assert_contains "$content" 'runCdCheck(command)' "pi extension must run the cd check in tool_call" - assert_contains "$content" 'fm-cd-pretool-check.sh' "pi extension must invoke the cd-guard owner" - assert_contains "$content" 'runPretoolCheck(command)' "pi extension must keep running the watcher-arm check" - assert_contains "$content" 'return { block: true, reason:' "pi extension must block on a checker exit 2" - pass ".pi primary extension: tool_call runs the cd-guard alongside the watcher-arm check" -} - test_scripts_are_shellcheck_clean() { command -v shellcheck >/dev/null 2>&1 || { pass "shellcheck not installed, skipping"; return; } shellcheck "$ROOT/bin/fm-cd-pretool-check.sh" >/dev/null 2>&1 \ @@ -453,9 +391,4 @@ test_fail_open_missing_node test_fail_open_missing_jq_on_stdin test_prefilter_skips_node_without_cd_substring test_policy_cli_direct -test_claude_wiring -test_codex_wiring -test_grok_wiring -test_opencode_wiring -test_pi_wiring test_scripts_are_shellcheck_clean diff --git a/tests/fm-claude-stop-autoarm.test.sh b/tests/fm-claude-stop-autoarm.test.sh index bcc4fceefb..6be8bc1533 100755 --- a/tests/fm-claude-stop-autoarm.test.sh +++ b/tests/fm-claude-stop-autoarm.test.sh @@ -150,28 +150,6 @@ epoch_outcome() { # --- registration contract ---------------------------------------------------- -test_settings_registers_autoarm_with_multi_hour_timeout() { - local settings - settings="$ROOT/.claude/settings.json" - jq -e ' - [.hooks.Stop[].hooks[] | select(.command | contains("fm-claude-stop-autoarm.sh"))] - | length == 1 - ' "$settings" >/dev/null || fail "settings must register exactly one Stop auto-arm hook" - jq -e ' - [.hooks.Stop[].hooks[] | select(.command | contains("fm-claude-stop-autoarm.sh"))][0] - | .asyncRewake == true and .type == "command" and (.timeout | type == "number" and . >= 28800) - ' "$settings" >/dev/null || fail "auto-arm must be asyncRewake with an explicit timeout of at least 28800s (the 600s default is forbidden)" - jq -e ' - [.hooks.Stop[].hooks[] | select(.command | contains("fm-claude-stop-autoarm.sh"))][0].command - | contains("&") | not - ' "$settings" >/dev/null || fail "auto-arm registration must not use shell fire-and-forget" - grep -q '"$SCRIPT_DIR/fm-watch-arm.sh" >"$OUT" 2>&1' "$ROOT/bin/fm-claude-stop-autoarm.sh" \ - || fail "auto-arm must foreground the arm wrapper inside the hook-owned process tree" - grep -q 'asyncRewake' "$ROOT/bin/fm-claude-stop-autoarm.sh" \ - || fail "auto-arm header must document its asyncRewake registration contract" - pass "settings.json registers the asyncRewake auto-arm with timeout >= 28800 and a foreground arm" -} - # --- scope and gates ---------------------------------------------------------- test_inert_in_child_worktree() { @@ -438,7 +416,6 @@ test_fm_lock_status_still_works_with_shared_lib() { pass "fm-lock: shared session-lock lib preserves the status path" } -test_settings_registers_autoarm_with_multi_hour_timeout test_inert_in_child_worktree test_inert_without_session_lock test_reclaims_stale_session_lock_before_arming diff --git a/tests/fm-documentation-audiences.test.sh b/tests/fm-documentation-audiences.test.sh index 11854594af..90222802f6 100755 --- a/tests/fm-documentation-audiences.test.sh +++ b/tests/fm-documentation-audiences.test.sh @@ -135,26 +135,7 @@ MD pass "local links resolve while dates, versions, commands, and incident prose remain semantically reviewed" } -test_no_mistakes_document_schema() { - local config="$ROOT/.no-mistakes.yaml" - assert_grep 'document:' "$config" "trusted Document config is missing" - assert_grep ' instructions: |' "$config" "Document instructions use an unsupported shape" - assert_grep 'docs/documentation-audiences.json' "$config" \ - "Document instructions do not point to the audience inventory" - assert_grep 'complete' "$config" \ - "Document instructions do not require a complete branch-diff review" - if command -v ruby >/dev/null 2>&1; then - ruby -e ' - require "yaml" - data = YAML.safe_load(File.read(ARGV.fetch(0))) - abort unless data.dig("document", "instructions").is_a?(String) - ' "$config" || fail ".no-mistakes.yaml did not parse document.instructions" - fi - pass "no-mistakes uses the supported trusted document.instructions schema" -} - test_repository_inventory_passes test_duplicate_and_setup_classification_fail test_required_pointer_fails test_local_links_and_no_keyword_heuristic -test_no_mistakes_document_schema diff --git a/tests/fm-gate-refuse.test.sh b/tests/fm-gate-refuse.test.sh index 478760cb96..aff57ab18e 100755 --- a/tests/fm-gate-refuse.test.sh +++ b/tests/fm-gate-refuse.test.sh @@ -361,36 +361,6 @@ test_teardown_refuses_and_admits() { pass "fm-teardown: refuses on marker and gate-worktree backstop; a normal teardown is unaffected" } -# --- tracked .no-mistakes.yaml ---------------------------------------------- - -test_no_mistakes_yaml_disables_project_settings() { - local file="$ROOT/.no-mistakes.yaml" val tab - assert_present "$file" "tracked .no-mistakes.yaml is missing" - git -C "$ROOT" ls-files --error-unmatch .no-mistakes.yaml >/dev/null 2>&1 \ - || fail ".no-mistakes.yaml is not tracked by git" - - # Parse with a real YAML loader and assert the field is boolean true, so a - # malformed file or a stringy "true" fails where a naive grep would pass. - if command -v python3 >/dev/null 2>&1 && python3 -c 'import yaml' >/dev/null 2>&1; then - val=$(python3 -c 'import yaml,sys; print(yaml.safe_load(open(sys.argv[1])).get("disable_project_settings"))' "$file") \ - || fail ".no-mistakes.yaml did not parse as YAML (python3)" - [ "$val" = "True" ] || fail "disable_project_settings is not boolean true (python3 read: $val)" - elif command -v ruby >/dev/null 2>&1; then - ruby -ryaml -e 'exit((YAML.safe_load(File.read(ARGV[0]))["disable_project_settings"] == true) ? 0 : 1)' "$file" \ - || fail ".no-mistakes.yaml did not parse or disable_project_settings != true (ruby)" - else - # No YAML loader: fall back to a strict structural check - no tab indentation - # (YAML forbids it) and the top-level key mapped to the bare boolean true. - tab=$(printf '\t') - case "$(cat "$file")" in - *"$tab"*) fail ".no-mistakes.yaml uses a tab (invalid YAML indentation)" ;; - esac - grep -qxE 'disable_project_settings:[[:space:]]+true' "$file" \ - || fail "top-level 'disable_project_settings: true' not found in .no-mistakes.yaml" - fi - pass ".no-mistakes.yaml parses and sets disable_project_settings: true (trusted-only gate opt-out)" -} - test_helper_env_marker_refuses test_helper_empty_env_marker_refuses test_helper_path_backstop_refuses @@ -398,4 +368,3 @@ test_helper_normal_is_noop test_spawn_refuses_and_admits test_send_refuses_and_admits test_teardown_refuses_and_admits -test_no_mistakes_yaml_disables_project_settings diff --git a/tests/fm-gotmp.test.sh b/tests/fm-gotmp.test.sh index 03f0afafa7..2fc7c8d78c 100755 --- a/tests/fm-gotmp.test.sh +++ b/tests/fm-gotmp.test.sh @@ -5,10 +5,10 @@ # gotmp/, exports GOTMPDIR into the crewmate pane, and records tasktmp= in the task's # meta. fm-teardown reads tasktmp= and removes the whole root on cleanup. # -# These tests exercise behavior directly: fm-teardown is run as a subprocess against a -# fake FM_HOME/FM_ROOT (built so the real script resolves into it), with stub helper scripts. -# Nothing is sourced. The fm-spawn side is verified both structurally (the source has -# the contract lines) and behaviorally (the mkdir + meta-write pattern it uses). +# These tests exercise fm-teardown directly as a subprocess against a fake FM_HOME/FM_ROOT +# built so the real script resolves into it, with stub helper scripts. +# The isolated fm-spawn subprocess in fm-kimi-harness.test.sh covers temp-root creation, +# metadata publication, and the pane environment export. set -u # This suite does not source tests/lib.sh, so exempt its teardown subprocess from @@ -18,7 +18,6 @@ set -u export FM_GATE_REFUSE_BYPASS=1 ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -SPAWN="$ROOT/bin/fm-spawn.sh" TEARDOWN="$ROOT/bin/fm-teardown.sh" fail() { @@ -96,40 +95,6 @@ META printf '%s' "$fake" } -# --- fm-spawn side --- - -test_spawn_contract_and_mkdir_pattern() { - # Structural: fm-spawn must create the gotmp dir, record tasktmp in meta, and export - # GOTMPDIR into the pane. Assert the contract lines are present in the source. - # shellcheck disable=SC2016 # single quotes are deliberate: these are literal source strings - grep -F 'mkdir -p "$TASK_TMP/gotmp"' "$SPAWN" >/dev/null \ - || fail "fm-spawn missing: mkdir of gotmp under TASK_TMP" - # shellcheck disable=SC2016 # single quotes are deliberate: literal source string - grep -F 'echo "tasktmp=$TASK_TMP"' "$SPAWN" >/dev/null \ - || fail "fm-spawn missing: tasktmp= line in meta write" - grep -F 'export GOTMPDIR=' "$SPAWN" >/dev/null \ - || fail "fm-spawn missing: GOTMPDIR export into pane" - # Behavioral: the mkdir + meta-write pattern spawn uses must produce a gotmp dir and - # a meta line whose value the teardown grep (tasktmp=, cut -d= -f2-) reads back whole. - local id=spawn-sim-z1 - local sim_root="$TMP_ROOT/$id-root" - local task_tmp="$sim_root/tmp/fm-$id" - mkdir -p "$sim_root/state" - # Replicate spawn's exact mkdir + meta-write lines. - TASK_TMP="$task_tmp" - mkdir -p "$TASK_TMP/gotmp" - { - echo "tasktmp=$TASK_TMP" - } > "$sim_root/state/$id.meta" - [ -d "$task_tmp/gotmp" ] || fail "simulated spawn did not create gotmp dir" - # Teardown reads tasktmp= with `grep '^tasktmp=' | cut -d= -f2-`; round-trip it. - local read_back - read_back=$(grep '^tasktmp=' "$sim_root/state/$id.meta" | cut -d= -f2-) - [ "$read_back" = "$task_tmp" ] \ - || fail "tasktmp value not round-tripped by teardown's grep|cut (got '$read_back')" - pass "fm-spawn creates gotmp dir and records tasktmp in meta" -} - # --- fm-teardown side (real subprocess) --- test_teardown_removes_tasktmp_dir() { @@ -207,7 +172,6 @@ test_teardown_skips_gracefully_when_dir_missing() { pass "fm-teardown skips gracefully when tasktmp= points to a nonexistent dir" } -test_spawn_contract_and_mkdir_pattern test_teardown_removes_tasktmp_dir test_teardown_skips_gracefully_without_tasktmp test_teardown_skips_gracefully_when_dir_missing diff --git a/tests/fm-install-herdr.test.sh b/tests/fm-install-herdr.test.sh deleted file mode 100755 index cc5a70ada8..0000000000 --- a/tests/fm-install-herdr.test.sh +++ /dev/null @@ -1,106 +0,0 @@ -#!/usr/bin/env bash -# Contract tests for the pinned Herdr / Treehouse CI installers and the -# bounded Herdr lab cleanup helper. These tests do not download release assets -# and never start or stop the captain's default Herdr session. -set -u - -# shellcheck source=tests/lib.sh -. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" - -HERDR_INSTALL="$ROOT/bin/fm-install-herdr.sh" -TREEHOUSE_INSTALL="$ROOT/bin/fm-install-treehouse.sh" -CLEANUP="$ROOT/bin/fm-herdr-ci-cleanup.sh" -CI="$ROOT/.github/workflows/ci.yml" - -assert_present "$HERDR_INSTALL" "bin/fm-install-herdr.sh is missing" -assert_present "$TREEHOUSE_INSTALL" "bin/fm-install-treehouse.sh is missing" -assert_present "$CLEANUP" "bin/fm-herdr-ci-cleanup.sh is missing" -[ -x "$HERDR_INSTALL" ] || fail "fm-install-herdr.sh must be executable" -[ -x "$TREEHOUSE_INSTALL" ] || fail "fm-install-treehouse.sh must be executable" -[ -x "$CLEANUP" ] || fail "fm-herdr-ci-cleanup.sh must be executable" - -test_herdr_installer_pins_exact_version_and_checksums() { - assert_grep 'FM_HERDR_CI_VERSION=0.7.4' "$HERDR_INSTALL" \ - "Herdr installer must pin suite-verified 0.7.4" - assert_grep 'FM_HERDR_CI_MIN_PROTOCOL=16' "$HERDR_INSTALL" \ - "Herdr installer must require protocol floor 16" - assert_grep 'ogulcancelik/herdr' "$HERDR_INSTALL" \ - "Herdr installer must use the official GitHub release source" - assert_grep 'herdr-linux-x86_64' "$HERDR_INSTALL" \ - "Herdr installer must name the Linux x86_64 release asset" - assert_grep 'bc0fc02d4ba500f9cac2353a43e67fe036785ecca6eb55378e050fac3c103059' "$HERDR_INSTALL" \ - "Herdr installer must pin the Linux x86_64 SHA-256" - assert_grep 'sha256sum' "$HERDR_INSTALL" \ - "Herdr installer must verify a SHA-256 checksum" - assert_grep '--max-filesize' "$HERDR_INSTALL" \ - "Herdr installer must bound the download size" - assert_no_grep 'brew install' "$HERDR_INSTALL" \ - "Herdr installer must not use a floating package-manager install" - assert_no_grep 'apt-get install' "$HERDR_INSTALL" \ - "Herdr installer must not use a floating package-manager install" - pass "Herdr installer pins exact version, asset, checksum, and protocol floor" -} - -test_treehouse_installer_pins_exact_version_and_checksums() { - assert_grep 'FM_TREEHOUSE_CI_VERSION=2.0.1' "$TREEHOUSE_INSTALL" \ - "Treehouse installer must pin the suite-verified 2.0.1 release" - assert_grep 'kunchenguid/treehouse' "$TREEHOUSE_INSTALL" \ - "Treehouse installer must use the official GitHub release source" - assert_grep 'linux-amd64.tar.gz' "$TREEHOUSE_INSTALL" \ - "Treehouse installer must name the Linux amd64 archive" - assert_grep '1d5a32751ab921670103fd201ddb2b91b47338cb13976f45642b827cf8976af2' "$TREEHOUSE_INSTALL" \ - "Treehouse installer must pin the Linux amd64 SHA-256" - assert_grep '--max-filesize' "$TREEHOUSE_INSTALL" \ - "Treehouse installer must bound the download size" - assert_no_grep 'brew install' "$TREEHOUSE_INSTALL" \ - "Treehouse installer must not use a floating package-manager install" - pass "Treehouse installer pins exact version, asset, and checksum" -} - -test_cleanup_only_targets_job_owned_lab_sessions() { - assert_grep 'fm-lab-' "$CLEANUP" \ - "cleanup must only consider fm-lab-* session names" - assert_grep 'default == false' "$CLEANUP" \ - "cleanup must refuse default sessions" - assert_grep 'snapshot' "$CLEANUP" \ - "cleanup must support a pre-suite snapshot" - assert_grep 'teardown' "$CLEANUP" \ - "cleanup must support post-suite teardown of the delta" - # Must not call ambient server stop. - assert_no_grep 'server stop' "$CLEANUP" \ - "cleanup must never call ambient herdr server stop" - pass "cleanup is bounded to job-owned fm-lab-* sessions" -} - -test_ci_wires_installers_and_required_lane() { - assert_grep 'tests-herdr:' "$CI" "CI must define the required Herdr Behavior job" - assert_grep 'fm-install-herdr.sh' "$CI" "CI must call the Herdr installer" - assert_grep 'fm-install-treehouse.sh' "$CI" "CI must call the Treehouse installer" - assert_grep 'fm-herdr-ci-cleanup.sh snapshot' "$CI" "CI must snapshot sessions before the suite" - assert_grep 'fm-herdr-ci-cleanup.sh teardown' "$CI" "CI must teardown job-owned sessions after" - assert_grep "fail-on-gate-skip 'herdr not found'" "$CI" \ - "CI Herdr lane must fail on herdr-not-found" - assert_grep 'family real-herdr-gated' "$CI" \ - "CI Herdr lane must run only the real-herdr-gated family" - assert_grep 'lane portable-parallel-1' "$CI" \ - "portable CI must run parallel shard 1" - assert_grep 'lane portable-parallel-2' "$CI" \ - "portable CI must run parallel shard 2" - assert_grep 'lane portable-serial' "$CI" \ - "portable CI must run the serial remainder" - assert_grep 'fm-test-run.sh --check-coverage' "$CI" \ - "CI must prove portable lanes and Herdr partition the complete inventory" - # Live harness credential tests must stay out of the default Herdr lane. - assert_no_grep 'live-harness-optin' "$CI" \ - "CI must not run live-harness-optin in the required Herdr lane" - assert_no_grep 'FM_AFK_PI_HERDR_E2E' "$CI" \ - "CI must not enable live Pi/Herdr credential tests" - assert_no_grep 'FM_SEND_MARKER_HERDR_E2E' "$CI" \ - "CI must not enable live marker Herdr credential tests" - pass "CI wires pinned installers into a required serial Herdr lane" -} - -test_herdr_installer_pins_exact_version_and_checksums -test_treehouse_installer_pins_exact_version_and_checksums -test_cleanup_only_targets_job_owned_lab_sessions -test_ci_wires_installers_and_required_lane diff --git a/tests/fm-instruction-owners.test.sh b/tests/fm-instruction-owners.test.sh deleted file mode 100755 index 754e00ddc8..0000000000 --- a/tests/fm-instruction-owners.test.sh +++ /dev/null @@ -1,305 +0,0 @@ -#!/usr/bin/env bash -# Static contract tests for conditional instruction owners introduced before the -# AGENTS.md reduction pass. -# shellcheck disable=SC2016 -set -u - -# shellcheck source=tests/lib.sh -. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" - -DIAG="$ROOT/.agents/skills/diagnostic-reasoning/SKILL.md" -PROJECT="$ROOT/.agents/skills/project-management/SKILL.md" -HARNESS="$ROOT/.agents/skills/harness-adapters/SKILL.md" -CODING="$ROOT/.agents/skills/firstmate-coding-guidelines/SKILL.md" -RECOVERY="$ROOT/.agents/skills/stuck-crewmate-recovery/SKILL.md" -SECONDMATE="$ROOT/.agents/skills/secondmate-provisioning/SKILL.md" -CONFIG="$ROOT/docs/configuration.md" -AGENTS="$ROOT/AGENTS.md" -BRIEF="$ROOT/bin/fm-brief.sh" -BOOTSTRAP="$ROOT/bin/fm-bootstrap.sh" - -test_new_skill_metadata_and_triggers() { - local skill name count - for pair in "diagnostic-reasoning:$DIAG" "project-management:$PROJECT"; do - name=${pair%%:*} - skill=${pair#*:} - assert_present "$skill" "$name skill is missing" - assert_grep "name: $name" "$skill" "$name skill metadata has the wrong name" - assert_grep "user-invocable: false" "$skill" "$name skill must not be user-invocable" - assert_grep " internal: true" "$skill" "$name skill must be internal" - count=$(grep -Fc -- "- \`$name\` -" "$ROOT/AGENTS.md") - [ "$count" -eq 1 ] || fail "$name must have exactly one AGENTS.md trigger entry, found $count" - done - assert_grep 'Use before scoping a reported bug and before acting on a diagnostic report.' "$DIAG" \ - "diagnostic skill metadata lost its precise load trigger" - assert_grep '`diagnostic-reasoning` - load before scoping a reported bug and before acting on a diagnostic report.' "$ROOT/AGENTS.md" \ - "AGENTS.md lost the diagnostic-reasoning trigger" - assert_grep 'Use before adding, creating, removing, or initializing a project.' "$PROJECT" \ - "project-management skill metadata lost its precise load trigger" - assert_grep '`project-management` - load before adding, creating, removing, or initializing a project.' "$ROOT/AGENTS.md" \ - "AGENTS.md lost the project-management trigger" - pass "new internal skills have one precise AGENTS.md trigger each" -} - -test_diagnostic_owner_covers_causal_procedure() { - assert_grep "single owner of Firstmate's bug-diagnosis reasoning procedure" "$DIAG" \ - "diagnostic skill does not declare ownership" - for phrase in \ - "end-to-end reproduction aligned with the real user path" \ - "initiating trigger" \ - "masking condition" \ - "visible symptom" \ - "proven path" \ - "relevant history" \ - "smallest counterfactual" \ - "disconfirming evidence"; do - assert_grep "$phrase" "$DIAG" "diagnostic owner is missing '$phrase'" - done - assert_grep "evidence, not authorization to change code" "$DIAG" \ - "diagnostic owner lost the diagnosis-only authority boundary" - pass "diagnostic-reasoning owns the approved evidence procedure" -} - -test_project_management_owner_covers_guarded_operations() { - assert_grep "single owner of Firstmate's project-management procedure" "$PROJECT" \ - "project-management skill does not declare ownership" - for phrase in \ - 'bin/fm-project-mode.sh' \ - '`no-mistakes`' \ - '`direct-PR`' \ - '`local-only`' \ - 'Default it off' \ - 'Creating a GitHub repository is outward-facing.' \ - "captain's explicit consent" \ - 'Never issue a raw removal command from Firstmate.' \ - 'no-mistakes init && no-mistakes doctor'; do - assert_grep "$phrase" "$PROJECT" "project-management owner is missing '$phrase'" - done - pass "project-management owns registry, delivery posture, consent, initialization, and removal safety" -} - -test_generic_effort_fallback_respects_precedence() { - local section - section=$(awk ' - /^Effort precedence is / { found = 1 } - found && /^The supported launch-profile flags / { exit } - found { print } - ' "$HARNESS") - assert_contains "$section" "explicit per-task captain instruction first" \ - "effort rubric lost per-task captain precedence" - assert_contains "$section" "standing dispatch profile or secondmate pin" \ - "effort rubric lost standing configuration precedence" - assert_contains "$section" 'Use `low` for well-understood work' \ - "effort rubric lost its low fallback" - assert_contains "$section" '`xhigh` for ambiguous investigation or design' \ - "effort rubric lost its xhigh fallback" - assert_contains "$section" "Choose intermediate levels proportionally" \ - "effort rubric lost proportional intermediate levels" - assert_contains "$section" 'Never select `max` from this fallback' \ - "effort rubric permits max without an explicit captain preference" - if printf '%s\n' "$section" | grep -qi sol; then - fail "generic effort fallback must not contain Sol-specific policy" - fi - pass "generic effort fallback applies only below captain and standing configuration" -} - -test_agent_owned_quota_array_dispatch_contract() { - local phrase - for phrase in \ - 'Firstmate alone resolves a matched profile array' \ - 'run `quota-axi --json` at that intake' \ - 'evaluate every configured candidate against that current output' \ - 'inspectable real headroom including quota-window pace' \ - 'if any harness/model/provider relationship, applicable quota data, or interpretation cannot be established, stop and report that candidate' \ - 'instead of omitting it, guessing, falling back, or calling the result quota-informed' \ - 'Preserve malformed profile configuration as an actionable error' \ - "preserve the captain's strongest-reasoning class rather than silently downgrading it" \ - 'Break genuine headroom ties without array-order or harness bias' \ - '`quota-axi` owns how model or product windows relate to bounding account windows' \ - 'remains data-only' \ - 'Load `quota-array-dispatch` before choosing among a matched profile array'; do - assert_grep "$phrase" "$AGENTS" "array-dispatch contract lost '$phrase'" - done - - for phrase in \ - '| claude | Open the current interactive session' \ - '| codex | Open the current interactive session' \ - '| opencode | Run `opencode models [provider]`' \ - '| pi / pi-signed | Run the selected executable as ` --list-models [search]`' \ - '| grok | Run `grok models`' \ - "For an unfamiliar harness or model namespace, establish support and provider identity from that harness's authoritative CLI help, model listing, or current documentation rather than guessing" \ - 'If those sources do not establish the relationship needed for dispatch, fail loudly and report the unresolved candidate.'; do - assert_grep "$phrase" "$HARNESS" "model discovery guidance lost '$phrase'" - done - assert_grep 'not as a permanent namespace or provider mapping' "$HARNESS" \ - "model discovery guidance permits a fixed provider table" - assert_grep 'load `quota-array-dispatch` for the pace-aware candidate choice' "$HARNESS" \ - "harness-adapters lost the quota-array-dispatch handoff" - assert_grep '`quota-array-dispatch` owns the pace-aware profile-array selection procedure' "$CONFIG" \ - "configuration docs do not point to quota-array-dispatch" - assert_grep 'quota-axi is required for the' "$BOOTSTRAP" \ - "bootstrap docs lost the quota-axi dependency pointer" - assert_grep 'agent-owned dispatch-profile array procedure in AGENTS.md section 4' "$BOOTSTRAP" \ - "bootstrap docs do not point to the agent-owned array procedure" - assert_grep 'quota-array-dispatch/SKILL.md' "$BOOTSTRAP" \ - "bootstrap docs do not point to quota-array-dispatch" - pass "firstmate directly compares every quota candidate with authoritative model discovery" -} - -test_shared_authoring_requirements_are_owned() { - assert_grep "review every affected supported primary harness and runtime backend" "$CODING" \ - "coding guidance lost the supported compatibility matrix review" - assert_grep "prefer deterministic and idempotent enforcement over relying on agent memory alone" "$CODING" \ - "coding guidance lost deterministic idempotent enforcement" - assert_grep "critical safety, routing, startup, and supervision infrastructure" "$CODING" \ - "coding guidance lost the critical infrastructure scope" - pass "firstmate-coding-guidelines owns compatibility review and deterministic enforcement" -} - -test_secondmate_registry_contract_stays_concise() { - local guidance routing_section schema_line - routing_section=$(awk ' - /^## Routing table$/ { found = 1 } - found && /^## Charter and seed$/ { exit } - found { print } - ' "$SECONDMATE") - guidance=$(awk ' - /^## Routing table$/ { found = 1 } - found && /^## Backlog handoff$/ { exit } - found { print } - ' "$SECONDMATE") - schema_line="- - (home: ; scope: ; projects: , ; added )" - assert_contains "$routing_section" "$schema_line" \ - "secondmate routing table lost the parser-compatible single-line schema" - assert_contains "$routing_section" "Each registry entry stays concise and single-line" \ - "secondmate routing table no longer requires concise single-line entries" - assert_contains "$routing_section" "genuinely domain-specific hard rules" \ - "secondmate routing table no longer limits extra prose to domain-specific hard rules" - assert_contains "$routing_section" "The home-seeded \`data/charter.md\` is the sole owner of boilerplate idle-by-default behavior, the normal delegation lifecycle, and standard escalation contracts" \ - "secondmate routing table lost the explicit charter ownership pointer" - assert_contains "$routing_section" "no extra registry pointer field is needed" \ - "secondmate routing table no longer explains why the existing home field is the charter pointer" - for phrase in \ - "go idle and wait silently" \ - "Act only on tasks" \ - "never spawn a survey" \ - "run normal firstmate bootstrap" \ - "escalation back to the main firstmate status file" \ - "requests-from-main-firstmate contract" \ - "waits for routed tasks, never self-initiating a survey or audit" \ - "marked supervisor requests return through status" \ - "unmarked captain messages stay conversational"; do - if printf '%s\n' "$guidance" | grep -F "$phrase" >/dev/null; then - fail "secondmate provisioning guidance restated charter boilerplate: $phrase" - fi - done - pass "secondmate registry guidance keeps concise routes and points to the charter" -} - -test_state_startup_and_ordinary_recovery_placement() { - assert_grep "single owner of the top-level operational-home layout" "$CONFIG" \ - "configuration docs do not own the operational state layout" - assert_grep "header is the single owner of session-start ordering" "$CONFIG" \ - "session-start mechanism is not assigned to the script header" - assert_grep "Ordinary dead-direct-report recovery is owned by \`stuck-crewmate-recovery\`" "$CONFIG" \ - "D05 ordinary recovery placement is missing" - assert_grep "## Session-start reconciliation for a dead ordinary direct report" "$RECOVERY" \ - "stuck-crewmate-recovery lacks the dead ordinary direct-report procedure" - assert_grep "treehouse status" "$RECOVERY" \ - "ordinary recovery lost treehouse inventory inspection" - assert_grep "recorded \`orca_worktree_id=\` and \`terminal=\`" "$RECOVERY" \ - "ordinary recovery lost Orca inventory inspection" - assert_grep "session-start digest reports an ordinary direct report's endpoint dead or its metadata has no window" "$AGENTS" \ - "AGENTS.md does not trigger ordinary dead-report recovery" - pass "state, startup, and ordinary recovery have focused owners and triggers" -} - -test_compressed_agents_owner_map() { - assert_grep '`docs/configuration.md` is the single owner of the top-level operational-home layout' "$AGENTS" \ - "AGENTS.md lost the state-layout owner pointer" - assert_grep 'header is the single owner of composed commands, ordering, and digest contents' "$AGENTS" \ - "AGENTS.md lost the session-start owner pointer" - assert_grep '`docs/configuration.md` owns dispatch-profile and runtime-backend schemas' "$AGENTS" \ - "AGENTS.md lost the dispatch-schema owner pointer" - assert_grep 'That skill owns registry syntax, delivery-mode selection' "$AGENTS" \ - "AGENTS.md lost the project-management owner pointer" - assert_grep 'The delivery lifecycle is an always-loaded operational contract' "$AGENTS" \ - "AGENTS.md no longer owns the delivery lifecycle" - assert_grep 'Fleet supervision is an always-loaded operational contract' "$AGENTS" \ - "AGENTS.md no longer owns fleet supervision" - assert_grep '`.tasks.toml`, `docs/configuration.md`, and current `tasks-axi --help` own the backlog schema' "$AGENTS" \ - "AGENTS.md lost the backlog-mechanics owner pointer" - assert_grep '`bin/fm-brief.sh` and its help own scaffold syntax' "$AGENTS" \ - "AGENTS.md lost the brief-mechanics owner pointer" - assert_grep '`docs/configuration.md` owns activation, generated state, cadence, wire protocol' "$AGENTS" \ - "AGENTS.md lost the X-mode mechanics owner pointer" - pass "compressed AGENTS.md records the approved one-owner map" -} - -test_intake_reuses_evidence_and_parallelizes_safe_work() { - for phrase in \ - 'consult existing reports and established evidence' \ - 'remaining bounded research inside it' \ - 'unresolved uncertainty could materially change whether or what to build' \ - 'relay it without a design-only scout' \ - 'ask one concise implementation question when useful' \ - 'Never both present a likely-enough solution' \ - 'overlap as a risk signal rather than an automatic reason to wait' \ - 'independently implemented and validated' \ - 'selected delivery path can reconcile ordinary rebases or conflicts' \ - 'Serialize only for a true semantic dependency' \ - 'shared mutable external state' \ - 'incompatible concurrent migration' \ - 'same-file editing alone is insufficient' \ - 'genuine blockers remain durable'; do - assert_grep "$phrase" "$AGENTS" "intake contract lost '$phrase'" - done - assert_grep 'dispatch isolated work immediately with no concurrency cap' "$AGENTS" \ - "intake contract lost unbounded safe parallel dispatch" - assert_grep 'captain explicitly requests a separate knowledge or design deliverable' "$AGENTS" \ - "intake contract lost captain-requested separate scouts" - assert_grep 'When implementation is separately authorized, promote the existing scout' "$AGENTS" \ - "intake contract lost genuine scout promotion" - pass "intake reuses evidence, reserves scouts for uncertainty, and parallelizes safe work" -} - -test_compressed_agents_retains_authority_and_supervision_safety() { - for phrase in \ - 'A lock-refused session must not spawn, steer, merge, drain the wake queue' \ - 'A diagnostic request, report, recommendation, or implementation-ready finding is evidence, not authorization to change code.' \ - '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.' \ - '**local-only** has the worker stop with a clean ready branch, then waits for the configured merge authority' \ - 'A status line is a wake event, not current state' \ - 'keep exactly one live supervision cycle' \ - 'Never broadly kill watchers' \ - 'While `state/.afk` exists, the daemon owns supervision' \ - 'post the final completion follow-up before teardown'; do - assert_grep "$phrase" "$AGENTS" "compressed AGENTS.md lost safety phrase '$phrase'" - done - assert_no_grep 'Firstmate does not personally review code or deliverables' "$AGENTS" \ - "AGENTS.md retained the weaker duplicate review prohibition" - assert_no_grep 'firstmate reviews your branch' "$AGENTS" \ - "AGENTS.md retained a personal branch-review requirement" - assert_no_grep 'firstmate reviews, captain approves' "$BRIEF" \ - "generated brief retained a stacked personal-review requirement" - if grep -q "$(printf '\342\200\224')" "$AGENTS"; then - fail "AGENTS.md contains an em dash" - fi - pass "compressed AGENTS.md retains authority, supervision, AFK, and X safety" -} - -test_new_skill_metadata_and_triggers -test_diagnostic_owner_covers_causal_procedure -test_project_management_owner_covers_guarded_operations -test_generic_effort_fallback_respects_precedence -test_agent_owned_quota_array_dispatch_contract -test_shared_authoring_requirements_are_owned -test_secondmate_registry_contract_stays_concise -test_state_startup_and_ordinary_recovery_placement -test_compressed_agents_owner_map -test_intake_reuses_evidence_and_parallelizes_safe_work -test_compressed_agents_retains_authority_and_supervision_safety diff --git a/tests/fm-kimi-harness.test.sh b/tests/fm-kimi-harness.test.sh index 8ac5922ec5..8e27052d8c 100755 --- a/tests/fm-kimi-harness.test.sh +++ b/tests/fm-kimi-harness.test.sh @@ -9,33 +9,17 @@ SPAWN="$ROOT/bin/fm-spawn.sh" TEARDOWN="$ROOT/bin/fm-teardown.sh" KIMI_HOOK="$ROOT/bin/fm-kimi-turnend-hook.sh" TMP_ROOT=$(fm_test_tmproot fm-kimi-harness) +KIMI_RUNTIME_TASK_TMP= PYTHON_BIN=$(command -v python3) || fail "test needs python3" PYTHON_BIN_DIR=$(dirname "$PYTHON_BIN") JQ_BIN=$(command -v jq) || fail "test needs jq" BASE_PATH=${FM_TEST_BASE_PATH:-$PYTHON_BIN_DIR:/usr/bin:/bin:/usr/sbin:/sbin} -assert_source_line() { - local line=$1 - grep -Fqx -- "$line" "$SPAWN" || fail "existing launch template changed: $line" -} - -test_existing_launch_templates_are_byte_pinned() { - assert_source_line " claude) printf '%s' 'CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false claude --dangerously-skip-permissions __MODELFLAG____EFFORTFLAG__\"\$(__OPINPUT__ encode launch-brief < __BRIEF__)\"' ;;" - assert_source_line " printf '%s' 'codex __MODELFLAG____EFFORTFLAG__--dangerously-bypass-approvals-and-sandbox \"\$(__OPINPUT__ encode launch-brief < __BRIEF__)\"'" - assert_source_line " printf '%s' 'codex __MODELFLAG____EFFORTFLAG__--dangerously-bypass-approvals-and-sandbox -c \"notify=[\\\"bash\\\",\\\"-c\\\",\\\"touch __TURNEND__\\\"]\" \"\$(__OPINPUT__ encode launch-brief < __BRIEF__)\"'" - assert_source_line " opencode) printf '%s' 'OPENCODE_CONFIG_CONTENT='\\''{\"permission\":{\"*\":\"allow\"}}'\\'' opencode __MODELFLAG__--prompt \"\$(__OPINPUT__ encode launch-brief < __BRIEF__)\"' ;;" - assert_source_line " printf '%s%s' \"\$harness\" ' __MODELFLAG____EFFORTFLAG__-e __PITURNEND__ -e __PIWATCH__ \"\$(__OPINPUT__ encode launch-brief < __BRIEF__)\"'" - assert_source_line " printf '%s%s' \"\$harness\" ' __MODELFLAG____EFFORTFLAG__-e __PIEXT__ \"\$(__OPINPUT__ encode launch-brief < __BRIEF__)\"'" - assert_source_line " grok) printf '%s' 'grok --always-approve __MODELFLAG____EFFORTFLAG__\"\$(__OPINPUT__ encode launch-brief < __BRIEF__)\"' ;;" - pass "fm-spawn: the five pre-existing adapters' launch templates stay byte-pinned" -} - -test_tracked_files_have_no_user_absolute_paths() { - local pattern="/""Users/" matches - matches=$(git -C "$ROOT" grep -n -F "$pattern" -- . || true) - [ -z "$matches" ] || fail "tracked files contain user-specific absolute paths: $matches" - pass "repository: tracked files contain no user-specific absolute paths" +cleanup_kimi_harness() { + [ -z "$KIMI_RUNTIME_TASK_TMP" ] || rm -rf "$KIMI_RUNTIME_TASK_TMP" + rm -rf "$TMP_ROOT" } +trap cleanup_kimi_harness EXIT make_spawn_fakebin() { local dir=$1 fakebin @@ -193,8 +177,11 @@ EOF } test_kimi_launch_then_send_is_verified() { - local id rec out rc launch pointer brief_real meta - id=kimi-success-z1 + local id rec out rc launch pointer brief_real meta task_tmp + id="kimi-success-z1-$$" + task_tmp="/tmp/fm-$id" + KIMI_RUNTIME_TASK_TMP=$task_tmp + rm -rf "$task_tmp" rec=$(make_spawn_case success "$id") read_spawn_record "$rec" out=$(FM_FAKE_KIMI_SWALLOW_FIRST=yes run_spawn \ @@ -218,6 +205,10 @@ test_kimi_launch_then_send_is_verified() { meta="$HOME_DIR/state/$id.meta" assert_grep 'model=kimi-code/k3' "$meta" "kimi meta lost the requested model" assert_grep 'effort=high' "$meta" "kimi meta did not retain the unsupported effort axis" + assert_grep "tasktmp=$task_tmp" "$meta" "kimi meta did not record its task temp root" + assert_present "$task_tmp/gotmp" "kimi spawn did not create its Go temp directory" + assert_grep "export GOTMPDIR=$task_tmp/gotmp" "$CASE_DIR/tmux-calls.log" \ + "kimi spawn did not export its Go temp directory into the pane" assert_grep 'BEGIN FIRSTMATE KIMI TURN-END HOOK' "$HOME_DIR/.kimi-code/config.toml" \ "kimi spawn did not install its guarded global hook region" assert_grep 'token=' "$WT_DIR/.fm-kimi-turnend" "kimi spawn did not write its token pointer" @@ -575,7 +566,7 @@ SH } test_kimi_busy_signature_is_scoped_to_spinner_lines() { - local capture phase kimi_regex_lines + local capture # shellcheck source=/dev/null . "$ROOT/bin/fm-tmux-lib.sh" unset FM_BUSY_REGEX @@ -589,10 +580,11 @@ test_kimi_busy_signature_is_scoped_to_spinner_lines() { # These fixtures reproduce the observed spinner shape rather than byte-exact # transcriptions. Leading whitespace is deliberately varied; separator whitespace # follows the captured contract. - printf ' 🌑 · Tip: ask Kimi to schedule tasks, e.g. "remind me at 5pm"\n│ > │\n' > "$capture" - fm_pane_is_busy fake kimi || fail "the first real Kimi spinner shape was not recognized as busy" - printf ' 🌗 · Tip: /plugins: manage plugins ...\n│ > │\n' > "$capture" - fm_pane_is_busy fake kimi || fail "the tool-execution Kimi spinner shape was not recognized as busy" + local phase + for phase in 🌑 🌒 🌓 🌔 🌕 🌖 🌗 🌘; do + printf ' %s · Tip: Kimi is working\n│ > │\n' "$phase" > "$capture" + fm_pane_is_busy fake kimi || fail "Kimi spinner phase $phase was not recognized as busy" + done printf 'ordinary response ending with 🌕\n│ > │\n' > "$capture" if fm_pane_is_busy fake kimi; then fail "a moon outside Kimi's spinner-line shape was misread as busy" @@ -617,14 +609,6 @@ test_kimi_busy_signature_is_scoped_to_spinner_lines() { if fm_pane_is_busy fake kimi; then fail "Kimi's idle thinking-effort status label was misread as busy" fi - kimi_regex_lines=$(grep 'KIMI_BUSY_REGEX' "$ROOT/bin/fm-tmux-lib.sh" "$ROOT/bin/fm-watch.sh") - if printf '%s\n' "$kimi_regex_lines" | grep -qi thinking; then - fail "Kimi busy regex still depends on a Thinking or thinking token" - fi - for phase in 🌑 🌒 🌓 🌔 🌕 🌖 🌗 🌘; do - grep -Fq "$phase" "$ROOT/bin/fm-tmux-lib.sh" \ - || fail "shared Kimi matcher is missing moon phase $phase" - done pass "busy detection: real Kimi moon-plus-middot captures require its harness while idle labels stay idle" } @@ -673,8 +657,6 @@ test_kimi_bordered_prompt_needs_no_override() { pass "composer classifier: kimi's existing bordered > shape is already safe without an override" } -test_tracked_files_have_no_user_absolute_paths -test_existing_launch_templates_are_byte_pinned test_kimi_hook_install_is_surgical_idempotent_and_removable test_kimi_hook_remove_preserves_owned_newline_boundary test_kimi_hook_fails_closed_on_missing_malformed_or_partial_config diff --git a/tests/fm-lint.test.sh b/tests/fm-lint.test.sh index a2b3c8fb29..17fb097f75 100755 --- a/tests/fm-lint.test.sh +++ b/tests/fm-lint.test.sh @@ -18,11 +18,7 @@ set -u . "$(dirname "${BASH_SOURCE[0]}")/lib.sh" LINT="$ROOT/bin/fm-lint.sh" -CI="$ROOT/.github/workflows/ci.yml" -NM="$ROOT/.no-mistakes.yaml" INSTALLER="$ROOT/bin/fm-install-shellcheck.sh" -# The authoritative file set the one owner must run. -CANON='ROOTS=(bin/*.sh bin/backends/*.sh tests/*.sh)' # The pinned version, read from the single source (the one owner itself). REQUIRED=$("$LINT" --required-version) @@ -33,48 +29,13 @@ pinned_ready() { [ "$(shellcheck --version | awk '/^version:/ {print $2; exit}')" = "$REQUIRED" ] } -test_owner_exists_and_executable() { - assert_present "$LINT" "bin/fm-lint.sh is missing" - [ -x "$LINT" ] || fail "bin/fm-lint.sh must be executable so CI/gate can run it directly" - pass "one-owner lint script exists and is executable" -} - -test_owner_defines_canonical_set() { - assert_grep "$CANON" "$LINT" "fm-lint.sh must run the canonical shellcheck file set" - # It must not weaken CI: no severity downgrade and no blanket disable/exclude - # that would hide findings CI fails on. - assert_no_grep '--severity' "$LINT" "fm-lint.sh must not lower severity below the CI default" - assert_no_grep '--exclude' "$LINT" "fm-lint.sh must not blanket-exclude checks CI enforces" - assert_grep "\"\$FM_LINT_SHELLCHECK\" --norc --external-sources -- \"\${roots[@]}\"" "$LINT" "every bounded worker must ignore ambient config and preserve annotated production sources" - [ "$(grep -Fc -- '--norc --external-sources' "$LINT")" -eq 1 ] || fail "the one worker command must own ShellCheck configuration" - assert_grep "JOBS=\${FM_LINT_JOBS:-2}" "$LINT" "canonical lint must default to two bounded workers" - pass "fm-lint.sh is the sole authoritative definition at CI-default severity" -} - -test_ci_invokes_the_owner() { - grep -Eq '^ - run: bin/fm-lint\.sh$' "$CI" || fail "CI lint job must invoke the one-owner script as a run step" - # Guard against regression to an inline re-spelling of the command. - assert_no_grep 'run: shellcheck' "$CI" "CI must call fm-lint.sh, not re-spell shellcheck inline" - pass "CI lint job calls the one-owner script, not an inline command" -} - -test_stock_bash_parse_uses_owner_inventory() { +test_list_files_reports_the_shell_inventory() { local listed expected listed=$("$LINT" --list-files) expected=$(find bin bin/backends tests -maxdepth 1 -type f -name '*.sh' -print | LC_ALL=C sort) [ "$(printf '%s\n' "$listed" | LC_ALL=C sort)" = "$expected" ] \ - || fail "fm-lint.sh --list-files did not return the complete canonical shell inventory" - # shellcheck disable=SC2016 # Literal assertion must remain unexpanded. - assert_grep 'bin/fm-lint.sh --list-files > "$shell_inventory"' "$CI" \ - "stock macOS Bash parse sweep must consume fm-lint.sh's canonical inventory" - assert_no_grep 'for f in bin/*.sh bin/backends/*.sh tests/*.sh' "$CI" \ - "stock macOS Bash parse sweep must not duplicate the canonical inventory" - pass "stock macOS Bash parse sweep consumes the canonical lint inventory" -} - -test_nomistakes_invokes_the_owner() { - grep -Fqx " lint: 'bin/fm-lint.sh'" "$NM" || fail "no-mistakes commands.lint must map exactly to the one-owner script" - pass "no-mistakes pre-push lint calls the one-owner script" + || fail "fm-lint.sh --list-files did not return the complete shell inventory" + pass "fm-lint.sh --list-files reports the complete shell inventory" } test_pins_an_explicit_version() { @@ -85,17 +46,6 @@ test_pins_an_explicit_version() { pass "fm-lint.sh pins an explicit ShellCheck version ($REQUIRED)" } -test_ci_installs_and_logs_the_pinned_version() { - # CI must derive the version from the one owner (never hardcode a divergent - # number) and log the resolved version as parity evidence. - assert_grep "VERSION=\"\$(\"\$ROOT/bin/fm-lint.sh\" --required-version)\"" "$INSTALLER" "installer must read the version fm-lint.sh pins" - [ "$(grep -Fc "bin/fm-install-shellcheck.sh \"\$RUNNER_TEMP/bin\"" "$CI")" -eq 4 ] || fail "lint and all three portable behavior jobs must use the shared ShellCheck installer" - assert_grep "ACTUAL_SHA256=\$(sha256sum" "$INSTALLER" "installer must calculate the ShellCheck archive checksum" - assert_grep "[ \"\$ACTUAL_SHA256\" = \"\$SHA256\" ]" "$INSTALLER" "installer must verify the ShellCheck archive checksum" - assert_grep "\"\$DESTINATION/shellcheck\" --version" "$INSTALLER" "installer must log the resolved ShellCheck version as evidence" - pass "CI installs and logs the pinned ShellCheck version from the one owner" -} - test_installer_retries_transient_download_failure() { local tmp fakebin destination out tmp=$(fm_test_tmproot fm-shellcheck-download) @@ -252,26 +202,6 @@ SH pass "fm-lint.sh passes a clean fixture" } -test_source_graph_boundaries_keep_every_owner() { - local adapter file production_context_tests="" - [ "$(grep -Fc '# shellcheck source=/dev/null' "$ROOT/bin/fm-backend.sh")" -eq 5 ] \ - || fail "the dispatcher must stop static source following at all five dynamic adapters" - for adapter in tmux herdr zellij orca cmux; do - assert_present "$ROOT/bin/backends/$adapter.sh" "canonical adapter root is missing: $adapter" - done - assert_present "$ROOT/bin/fm-push-transition-lib.sh" "narrow push-transition owner is missing" - assert_grep '# shellcheck source=bin/fm-push-transition-lib.sh' "$ROOT/bin/fm-watch.sh" "the watcher must consume the narrow push-transition owner" - assert_grep ". \"\$ROOT/bin/fm-push-transition-lib.sh\"" "$ROOT/tests/fm-backend-herdr-eventwait-smoke.test.sh" "the Herdr event-wait smoke must consume the narrow production owner" - assert_no_grep '# shellcheck source=bin/fm-watch.sh' "$ROOT/tests/fm-backend-herdr-eventwait-smoke.test.sh" "the event-wait smoke must not re-import the whole watcher graph" - for file in "$ROOT"/tests/*.sh; do - grep -q '^[[:space:]]*# shellcheck source=bin/' "$file" || continue - production_context_tests="${production_context_tests}$(basename "$file")|" - done - [ "$production_context_tests" = 'fm-backend-herdr.test.sh|fm-daemon.test.sh|fm-pending-reply.test.sh|fm-secondmate-sync.test.sh|' ] \ - || fail "only callback/variable interop tests may retain production source context: $production_context_tests" - pass "dispatcher, adapters, production owner, and tests have explicit lint boundaries" -} - test_jobs_are_deterministic_and_complete() { if ! pinned_ready; then pass "SKIP (ShellCheck $REQUIRED not resolved): deterministic bounded jobs check" @@ -496,19 +426,13 @@ SH pass "seeded dispatcher, adapter, production-owner, and test-local diagnostics preserve parity" } -test_owner_exists_and_executable -test_owner_defines_canonical_set -test_ci_invokes_the_owner -test_stock_bash_parse_uses_owner_inventory -test_nomistakes_invokes_the_owner +test_list_files_reports_the_shell_inventory test_pins_an_explicit_version -test_ci_installs_and_logs_the_pinned_version test_installer_retries_transient_download_failure test_rejects_wrong_shellcheck_version test_catches_a_real_lint_defect test_ignores_ambient_shellcheck_opts test_clean_fixture_passes -test_source_graph_boundaries_keep_every_owner test_jobs_are_deterministic_and_complete test_worker_trees_stop_on_signal test_seeded_module_boundary_parity diff --git a/tests/fm-nm-test-contract.test.sh b/tests/fm-nm-test-contract.test.sh deleted file mode 100755 index 54c19eab2f..0000000000 --- a/tests/fm-nm-test-contract.test.sh +++ /dev/null @@ -1,127 +0,0 @@ -#!/usr/bin/env bash -# Contract: local no-mistakes Test is intent-targeted; CI owns broad regression. -# -# Firstmate must not configure commands.test as a complete tests/*.test.sh walk -# (that duplicated CI and burned local pipeline time). Lint stays pinned to -# bin/fm-lint.sh. Remote CI owns broad regression through separate portable and -# required real-Herdr Behavior lanes composed around bin/fm-test-run.sh. -set -u - -# shellcheck source=tests/lib.sh -. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" - -NM="$ROOT/.no-mistakes.yaml" -CI="$ROOT/.github/workflows/ci.yml" - -test_nm_yaml_tracked() { - assert_present "$NM" "tracked .no-mistakes.yaml is missing" - git -C "$ROOT" ls-files --error-unmatch .no-mistakes.yaml >/dev/null 2>&1 \ - || fail ".no-mistakes.yaml is not tracked by git" - pass ".no-mistakes.yaml is present and tracked" -} - -test_nm_keeps_lint_pin() { - grep -Fqx " lint: 'bin/fm-lint.sh'" "$NM" \ - || fail "commands.lint must remain exactly bin/fm-lint.sh" - pass "commands.lint stays pinned to bin/fm-lint.sh" -} - -# True when the YAML maps a non-empty commands.test (string or mapping value). -# Empty / null / absent is the intended targeted-Test posture. -nm_commands_test_value() { - if command -v python3 >/dev/null 2>&1 && python3 -c 'import yaml' >/dev/null 2>&1; then - python3 -c ' -import yaml, sys -doc = yaml.safe_load(open(sys.argv[1])) or {} -cmds = doc.get("commands") or {} -val = cmds.get("test") if isinstance(cmds, dict) else None -if val is None or val is False: - print("") -elif isinstance(val, str): - print(val) -else: - print(repr(val)) -' "$NM" - return - fi - if command -v ruby >/dev/null 2>&1; then - ruby -ryaml -e ' -doc = YAML.safe_load(File.read(ARGV[0])) || {} -cmds = doc["commands"] || {} -val = cmds.is_a?(Hash) ? cmds["test"] : nil -if val.nil? || val == false - puts "" -elsif val.is_a?(String) - puts val -else - puts val.inspect -end -' "$NM" - return - fi - # Structural fallback: any commands.test line under the commands block. - awk ' - /^commands:[[:space:]]*$/ { in_cmds=1; next } - in_cmds && /^[^[:space:]#]/ { in_cmds=0 } - in_cmds && /^[[:space:]]+test:[[:space:]]*/ { - sub(/^[[:space:]]+test:[[:space:]]*/, "") - gsub(/^['\''"]|['\''"]$/, "") - print - exit - } - ' "$NM" -} - -test_nm_has_no_complete_local_test_command() { - local val - val=$(nm_commands_test_value) || fail "failed to read commands.test from .no-mistakes.yaml" - if [ -n "$val" ]; then - case "$val" in - *'tests/*.test.sh'*|*'tests/'*'.test.sh'*) - fail "commands.test must not walk the complete tests/*.test.sh suite; got: $val" - ;; - *) - # Any non-empty override still steers Test away from intent-targeted default. - fail "commands.test must be absent or empty so Test stays intent-targeted; got: $val" - ;; - esac - fi - # Also refuse a commented-out full-suite remnant that could be re-enabled by habit. - if grep -E '^[[:space:]]*#?[[:space:]]*test:[[:space:]].*tests/\*\.test\.sh' "$NM" >/dev/null 2>&1; then - fail ".no-mistakes.yaml still documents a full-suite commands.test line (active or comment)" - fi - pass "no-mistakes does not configure a complete local Test command" -} - -test_ci_still_runs_broad_behavior_suite() { - assert_present "$CI" "ci.yml is missing" - # Portable shards and the serial remainder cover every portable behavior - # script through the one owner, with a deterministic inventory guard. - grep -Fq 'bin/fm-test-run.sh --lane portable-parallel-1' "$CI" \ - || fail "CI must invoke portable parallel shard 1 through fm-test-run.sh" - grep -Fq 'bin/fm-test-run.sh --lane portable-parallel-2' "$CI" \ - || fail "CI must invoke portable parallel shard 2 through fm-test-run.sh" - grep -Fq 'bin/fm-test-run.sh --lane portable-serial' "$CI" \ - || fail "CI must invoke the portable serial remainder through fm-test-run.sh" - grep -Fq 'bin/fm-test-run.sh --check-coverage' "$CI" \ - || fail "CI must prove complete lane coverage through fm-test-run.sh" - # Guard against regression to an uninstrumented inline loop that drops timing. - if grep -Eq 'for test_script in tests/\*\.test\.sh' "$CI"; then - fail "CI Behavior must not re-spell an inline tests/*.test.sh loop; use fm-test-run.sh" - fi - # Preserve other CI lanes this task must not shrink. - grep -Eq 'name:[[:space:]]*Lint shell scripts' "$CI" \ - || fail "CI must retain the lint job" - grep -Eq 'name:[[:space:]]*Stock macOS Bash snapshot compatibility' "$CI" \ - || fail "CI must retain the macOS stock Bash compatibility job" - grep -Eq 'name:[[:space:]]*Repo invariants' "$CI" \ - || fail "CI must retain the repo invariants job" - grep -Fq 'tests-herdr:' "$CI" \ - || fail "CI must retain the required Herdr Behavior job" - pass "CI still owns partitioned broad behavior coverage and companion jobs" -} - -test_nm_yaml_tracked -test_nm_keeps_lint_pin -test_nm_has_no_complete_local_test_command -test_ci_still_runs_broad_behavior_suite diff --git a/tests/fm-no-mistakes-ownership.test.sh b/tests/fm-no-mistakes-ownership.test.sh deleted file mode 100755 index b7e7fc2a6f..0000000000 --- a/tests/fm-no-mistakes-ownership.test.sh +++ /dev/null @@ -1,39 +0,0 @@ -#!/usr/bin/env bash -# Static contract tests for crew-owned no-mistakes validation runs. -set -u - -# shellcheck source=tests/lib.sh disable=SC1091 -. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" - -validate_contract() { - awk ' - /^### Validate$/ { found = 1; next } - found && /^### / { exit } - found { print } - ' "$ROOT/AGENTS.md" -} - -test_worker_owns_synchronous_driver() { - local contract - contract=$(validate_contract) - - assert_contains "$contract" 'The task worker that starts a no-mistakes run drives the pipeline' \ - "Validate contract does not assign the run to its initiating task worker" - assert_contains "$contract" "owns every \`no-mistakes axi run\` and \`no-mistakes axi respond\` call through the next gate or outcome" \ - "Validate contract does not assign every synchronous driver call to the task worker" - assert_contains "$contract" 'process every synchronous return until completion or a genuinely new escalation' \ - "Validate contract does not require the task worker to process every synchronous return" - pass "Validate contract assigns the complete synchronous driver loop to the initiating task worker" -} - -test_firstmate_never_responds_for_crew_run() { - local contract - contract=$(validate_contract) - - assert_contains "$contract" "Firstmate never invokes \`no-mistakes axi respond\` for a crew-owned run." \ - "Validate contract permits Firstmate to respond directly for a crew-owned run" - pass "Validate contract forbids Firstmate from responding directly for a crew-owned run" -} - -test_worker_owns_synchronous_driver -test_firstmate_never_responds_for_crew_run diff --git a/tests/fm-pi-watch-extension.test.sh b/tests/fm-pi-watch-extension.test.sh index 518df0e874..f888319489 100755 --- a/tests/fm-pi-watch-extension.test.sh +++ b/tests/fm-pi-watch-extension.test.sh @@ -59,64 +59,6 @@ export const Type = { JS } -test_tracked_extension_present_and_self_hashing() { - local text expected_config_source - expected_config_source="config_dir=\\\"\${FM_CONFIG_OVERRIDE:-\$FM_HOME/config}\\\"" - assert_present "$EXT" "tracked Pi primary watcher extension is missing" - text=$(cat "$EXT") - assert_contains "$text" "fm_watch_arm_pi" "tracked extension missing tool name" - assert_contains "$text" "fm-watch-arm-pi" "tracked extension missing command name" - assert_contains "$text" "fm-watch-arm.sh" "tracked extension missing watcher arm" - assert_contains "$text" "sendUserMessage" "tracked extension missing Pi wake API" - assert_contains "$text" 'encodeFirstmateOperationalInput' "tracked extension does not construct typed synthetic user-role wakes" - assert_contains "$text" "deliverAs: \"followUp\"" "tracked extension missing followUp delivery" - assert_contains "$text" ".pi-watch-extension-loaded" "tracked extension missing loaded marker" - assert_contains "$text" 'createHash("sha256").update(readFileSync(extensionFile)).digest("hex")' "tracked extension does not self-hash its own content for extensionVersion" - assert_contains "$text" 'fileURLToPath(import.meta.url)' "tracked extension does not self-locate via import.meta.url" - assert_contains "$text" 'type LockOwnership = "owned" | "missing" | "other"' "tracked extension does not distinguish missing lock from another owner" - assert_contains "$text" "readFileSync(\`\${state}/.lock\`" "tracked extension does not read the effective session lock" - assert_contains "$text" 'return pidAlive(lockPid) ? "other" : "missing"' "tracked extension does not allow a pre-lock load marker" - assert_contains "$text" 'if (lockOwnership() === "other") return' "tracked extension overwrites another live session marker" - assert_contains "$text" 'const ownership = lockOwnership()' "tracked extension arm does not inspect the distinct lock ownership state" - assert_contains "$text" 'if (ownership === "other") return { ok: false' "tracked extension arm does not preserve the live-other read-only refusal" - assert_contains "$text" 'if (ownership === "missing")' "tracked extension arm collapses a stale or absent lock into the live-other refusal" - assert_contains "$text" "no live session holds the lock" "tracked extension arm missing stale-lock recovery guidance" - assert_contains "$text" "run bin/fm-session-start.sh to reclaim it" "tracked extension arm does not direct stale-lock reclamation" - assert_contains "$text" "call fm_watch_arm_pi to re-arm" "tracked extension arm does not direct supervision re-arm" - assert_contains "$text" "writeFileSync(marker, \`\${extensionVersion}\\n\${process.pid}\\n\`)" "tracked extension does not write the content version and process marker" - assert_contains "$text" "const config = process.env.FM_CONFIG_OVERRIDE" "tracked extension missing effective config resolution" - assert_contains "$text" "FM_CONFIG_OVERRIDE: config" "tracked extension does not pass the effective config to the watcher arm" - assert_contains "$text" "FM_WATCH_ARM_SCRIPT: armScript" "tracked extension does not pass the effective watcher arm script" - assert_contains "$text" "$expected_config_source" "tracked extension does not source the effective x-mode config" - assert_contains "$text" "exec \\\"\$FM_WATCH_ARM_SCRIPT\\\" --restart" "tracked extension does not restart into a Pi-owned watcher child" - assert_contains "$text" 'label: "Arm firstmate watcher"' "tracked extension tool is missing its human-readable label" - assert_not_contains "$text" "Always use this tool" "tracked extension kept broad tool-selection guidance" - assert_contains "$text" "only for the first required cycle or after a notification says the cycle is missing, failed, or unhealthy" "tracked extension tool metadata is missing the Pi first-cycle or explicit-repair rule" - assert_contains "$text" "Do not call it after ordinary work, turn completion, or ordinary signal, stale, check, or heartbeat handling" "tracked extension prompt guidance does not prevent redundant ordinary-notification calls" - assert_contains "$text" 'parameters: Type.Object({})' "tracked extension tool is not using Pi's canonical TypeBox schema" - assert_contains "$text" 'content: [{ type: "text", text: result.message }]' "tracked extension tool is missing Pi text content" - assert_contains "$text" 'details: result' "tracked extension tool is missing structured result details" - assert_contains "$text" 'ctx.ui.notify' "tracked extension command does not notify through Pi's UI" - assert_contains "$text" 'process.once("exit", cleanupOnProcessExit)' "tracked extension lacks clean-process-exit cleanup" - assert_contains "$text" "type SessionGeneration" "tracked extension lacks an explicit session-generation owner" - assert_contains "$text" "function activateGeneration" "tracked extension does not activate a live generation for replacement sessions" - assert_contains "$text" "function generationIsLive" "tracked extension does not gate arm mutations on the live generation" - assert_contains "$text" "watcher: not armed - Pi session is shutting down" "tracked extension missing the terminal shutdown refusal" - assert_not_contains "$text" "[ -f config/x-mode.env ]" "tracked extension kept a repo-relative x-mode config path" - pass "Pi primary watcher extension is tracked, self-hashing, and self-locating" -} - -test_spawn_template_mentions_pi_watch_placeholder() { - local text - text=$(cat "$ROOT/bin/fm-spawn.sh") - assert_contains "$text" "-e __PITURNEND__ -e __PIWATCH__" "Pi secondmate launch template does not include both primary extensions" - assert_contains "$text" "\$PROJ_ABS/.pi/extensions/fm-primary-pi-watch.ts" "fm-spawn does not point the Pi secondmate watch placeholder at the tracked extension" - assert_not_contains "$text" "fm-pi-watch-extension.sh" "fm-spawn should no longer generate the Pi watch extension before launch" - assert_contains "$text" "__PITURNEND__" "fm-spawn does not replace the Pi turn-end guard extension placeholder" - assert_contains "$text" "__PIWATCH__" "fm-spawn does not replace the Pi watch extension placeholder" - pass "Pi secondmate launch wiring includes both tracked primary extensions" -} - test_pi_extension_reports_external_healthy_watcher() { local repo home plugin out status repo="$TMP_ROOT/pi-external-healthy-root" @@ -1234,26 +1176,6 @@ EOF pass "Pi process-exit cleanup stops the attached arm child" } -test_opencode_primary_watch_plugin_static_wiring() { - local plugin module_boundary text - plugin="$ROOT/.opencode/plugins/fm-primary-watch-arm.js" - module_boundary="$ROOT/.opencode/plugins/package.json" - assert_present "$plugin" "OpenCode primary watch plugin missing" - assert_present "$module_boundary" "OpenCode plugin ESM package boundary missing" - assert_contains "$(cat "$module_boundary")" '"type": "module"' "OpenCode plugin package boundary is not explicitly ESM" - text=$(cat "$plugin") - assert_contains "$text" "session.idle" "OpenCode plugin does not listen for session.idle" - assert_contains "$text" "fm-watch-arm.sh" "OpenCode plugin does not spawn the watcher arm" - assert_contains "$text" "promptAsync" "OpenCode plugin does not wake with promptAsync" - assert_contains "$text" 'encodeFirstmateOperationalInput' "OpenCode plugin does not construct typed synthetic user-role wakes" - assert_contains "$text" ".fm-secondmate-home" "OpenCode plugin does not scope out secondmate homes" - assert_contains "$text" "rev-parse\", \"--git-dir" "OpenCode plugin does not check linked worktree scope" - assert_contains "$text" "sessionOwnsLock" "OpenCode plugin does not gate arm attempts on the session lock" - assert_contains "$text" 'fm-watch-arm.sh" --restart' "OpenCode plugin does not restart into its own watcher child" - assert_contains "$text" 'setArmStatus("external")' "OpenCode plugin still treats an external healthy watcher as armed" - pass "OpenCode primary watcher plugin has the verified TUI wake wiring" -} - test_opencode_plugin_package_boundary_is_explicit_esm() { local fixture plugin out status fixture="$TMP_ROOT/opencode-esm-boundary/.opencode" @@ -2202,8 +2124,6 @@ EOF pass "OpenCode healthy arm output does not suppress the turn-end guard" } -test_tracked_extension_present_and_self_hashing -test_spawn_template_mentions_pi_watch_placeholder test_pi_extension_reports_external_healthy_watcher test_pi_tool_returns_agent_tool_result test_pi_redundant_tool_call_is_owned_noop @@ -2219,7 +2139,6 @@ test_pi_arm_distinguishes_session_lock_ownership test_pi_session_transition_generation_owner test_pi_process_exit_cleanup_listener_lifecycle test_pi_process_exit_cleanup_stops_arm_child -test_opencode_primary_watch_plugin_static_wiring test_opencode_plugin_package_boundary_is_explicit_esm test_opencode_primary_watch_plugin_uses_effective_state_home test_opencode_primary_watch_plugin_sources_effective_config diff --git a/tests/fm-pr-check-security.test.sh b/tests/fm-pr-check-security.test.sh index 2e813965b4..1b21e5d6a8 100755 --- a/tests/fm-pr-check-security.test.sh +++ b/tests/fm-pr-check-security.test.sh @@ -2882,11 +2882,6 @@ EOF [ "$rc" -eq 2 ] || fail "merge wrapper did not refuse a GitLab merge request URL" [ ! -s "$dir/gh-axi.log" ] || fail "merge wrapper reached the GitHub CLI for a GitLab URL" - # The instance is data, never a constant, so self-hosted instances work. - ! grep -qF gitlab.com "$ROOT/bin/fm-pr-lib.sh" \ - || fail "the shared PR library hardcodes a GitLab host" - ! grep -qF gitlab.com "$ROOT/bin/fm-pr-poll.sh" \ - || fail "the static poll hardcodes a GitLab host" pass "GitLab merge requests are followed on any instance and never wake falsely" } diff --git a/tests/fm-quota-array-dispatch.test.sh b/tests/fm-quota-array-dispatch.test.sh deleted file mode 100755 index 4fb1328ba3..0000000000 --- a/tests/fm-quota-array-dispatch.test.sh +++ /dev/null @@ -1,369 +0,0 @@ -#!/usr/bin/env bash -# Contract and deterministic fixture tests for quota-array-dispatch. -# -# The skill owns the agent-facing decision procedure. -# This test encodes the same inspectable comparison rules against sanitized -# fixtures so acceptance cases stay deterministic without introducing a -# production routing wrapper. -# shellcheck disable=SC2016 -set -u - -# shellcheck source=tests/lib.sh -. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" - -AGENTS="$ROOT/AGENTS.md" -OWNER="$ROOT/.agents/skills/quota-array-dispatch/SKILL.md" -HARNESS="$ROOT/.agents/skills/harness-adapters/SKILL.md" -CONFIG="$ROOT/docs/configuration.md" -ARCHITECTURE="$ROOT/docs/architecture.md" -BOOTSTRAP="$ROOT/bin/fm-bootstrap.sh" -AUDIENCES="$ROOT/docs/documentation-audiences.json" -CASES="$ROOT/tests/fixtures/quota-array-dispatch/cases.json" -SHAPE="$ROOT/tests/fixtures/quota-array-dispatch/schema-v3-shape.json" - -intake_boundary() { - awk ' - /^## 4\. Harness and runtime dispatch$/ { found = 1; next } - found && /^## 5\. Recovery$/ { exit } - found { print } - ' "$AGENTS" -} - -select_candidate_py() { - python3 - "$@" <<'PY' -import json, sys - -def conservation_pressure(c): - if not c.get("paceAvailable", True): - return False - status = c.get("paceStatus") - ahead_ids = c.get("aheadWindowIds") or [] - bounding_windows = c.get("boundingWindows") or [] - if status == "ahead": - return True - if status == "mixed" and ahead_ids: - return True - if any(window.get("paceStatus") == "ahead" for window in bounding_windows): - return True - return False - -def select(case): - required = case.get("requiredReasoningClass") - cands = list(case["candidates"]) - if required: - matching = [c for c in cands if c.get("reasoningClass") == required] - if not matching: - return {"error": "required reasoning class unavailable"} - # Strongest-reasoning rule: never drop to a weaker class for quota. - cands = matching - - # Fit filter: fixtures mark comparable; keep only comparable for these cases. - cands = [c for c in cands if c.get("fit") == "comparable"] - if not cands: - return {"error": "no comparable candidates"} - - def sort_key(c): - pressured = conservation_pressure(c) - unknown = bool(c.get("unknownPace")) or c.get("paceStatus") == "unknown" - pace_available = bool(c.get("paceAvailable", True)) - reserve = c.get("worstReserve") - if reserve is None: - reserve_key = float("-inf") - else: - reserve_key = float(reserve) - raw = float(c.get("rawHeadroom") or 0) - # Sort ascending by preference rank components that python min understands - # via a tuple where lower is better only for pressure/unknown flags. - return ( - 1 if pressured else 0, - 1 if (unknown and pace_available) else 0, - 0 if pace_available else 1, # when pace absent, still comparable via raw only - # Among pressured: least-negative reserve => higher reserve first => negate - (-reserve_key if pressured else 0), - # Among sustainable with pace: prefer higher reserve then higher raw - (-reserve_key if (not pressured and pace_available and not unknown) else 0), - -raw, - ) - - # Special-case all-tight already constrained to required class above. - best_key = min(sort_key(c) for c in cands) - winners = [c for c in cands if sort_key(c) == best_key] - if len(winners) > 1: - return { - "error": "genuine tie requires captain choice", - "candidates": sorted(c["id"] for c in winners), - } - winner = winners[0] - if winner.get("authAvailable") is False: - return { - "error": "selected candidate authentication unavailable", - "harness": winner["harness"], - "model": winner["model"], - "authenticationSurface": winner["authenticationSurface"], - "failureEvidence": winner["authFailure"], - } - return { - "id": winner["id"], - "pressured": conservation_pressure(winner), - } - -case = json.loads(sys.argv[1]) -print(json.dumps(select(case))) -PY -} - -test_owner_and_always_loaded_boundary() { - local boundary trigger_count - boundary=$(intake_boundary) - - assert_present "$OWNER" "quota-array-dispatch owner is missing" - assert_grep 'name: quota-array-dispatch' "$OWNER" "quota-array-dispatch skill has the wrong name" - assert_grep 'user-invocable: false' "$OWNER" "quota-array-dispatch skill must be agent-only" - assert_grep 'single owner of the pace-aware profile-array selection procedure' "$OWNER" \ - "quota-array-dispatch skill does not declare ownership" - - assert_contains "$boundary" 'Firstmate alone resolves a matched profile array' \ - "intake boundary lost agent-owned array resolution" - assert_contains "$boundary" 'run `quota-axi --json` at that intake' \ - "intake boundary lost quota-axi intake read" - assert_contains "$boundary" 'evaluate every configured candidate against that current output' \ - "intake boundary lost full-candidate accounting" - assert_contains "$boundary" 'inspectable real headroom including quota-window pace' \ - "intake boundary lost pace-aware headroom wording" - assert_contains "$boundary" 'if any harness/model/provider relationship, applicable quota data, or interpretation cannot be established, stop and report that candidate' \ - "intake boundary lost unresolved-candidate refusal" - assert_contains "$boundary" 'instead of omitting it, guessing, falling back, or calling the result quota-informed' \ - "intake boundary lost no-guess wording" - assert_contains "$boundary" 'Preserve malformed profile configuration as an actionable error' \ - "intake boundary lost malformed-config refusal" - assert_contains "$boundary" "preserve the captain's strongest-reasoning class rather than silently downgrading it" \ - "intake boundary lost strongest-reasoning rule" - assert_contains "$boundary" 'Break genuine headroom ties without array-order or harness bias' \ - "intake boundary lost genuine-tie rule" - assert_contains "$boundary" '`quota-axi` owns how model or product windows relate to bounding account windows' \ - "intake boundary lost quota-axi window ownership" - assert_contains "$boundary" 'remains data-only' \ - "intake boundary lost data-only producer boundary" - assert_contains "$boundary" 'Load `quota-array-dispatch` before choosing among a matched profile array' \ - "intake boundary lost quota-array-dispatch load trigger" - - trigger_count=$(grep -Fc -- '- `quota-array-dispatch` -' "$AGENTS") - [ "$trigger_count" -eq 1 ] || fail "quota-array-dispatch must have exactly one section 13 trigger, found $trigger_count" - - # Full pace procedure stays out of AGENTS.md. - if printf '%s\n' "$boundary" | grep -q 'reservePercentPoints'; then - fail "AGENTS.md intake boundary duplicated pace formula detail" - fi - if printf '%s\n' "$boundary" | grep -q 'aheadWindowIds'; then - fail "AGENTS.md intake boundary duplicated aheadWindowIds detail" - fi - - pass "quota-array-dispatch has one conditional owner and a concise always-loaded boundary" -} - -test_owner_contains_selection_procedure() { - local phrase lines words bytes - for phrase in \ - 'reservePercentPoints = percentRemaining - timeRemainingPercent' \ - 'Negative reserve means usage is ahead of reset pace and creates conservation pressure' \ - 'Positive reserve means usage is behind reset pace' \ - '`on_pace` is neutral' \ - 'effective pace status is `mixed` and any `aheadWindowIds` remain' \ - 'Comparable fit/reasoning: prefer no ahead pressure over pressure' \ - 'even with higher raw headroom' \ - 'prefer the least-negative worst applicable reserve' \ - 'Sustainable candidates: use known pace plus raw headroom' \ - 'Do not collapse those facts into an opaque composite score' \ - '`unknown` is valid explicit uncertainty from quota-axi' \ - 'Prefer known sustainable evidence over `unknown` when comparable' \ - 'If unresolved pace changes the choice, report uncertainty' \ - 'do not crash, fabricate pace, or treat absence as healthy' \ - 'stop and report every tied candidate for captain choice' \ - 'Do not select by array order, harness name, or another arbitrary identity ordering' \ - 'Do not add a daemon, opaque composite score, routing wrapper, hard-coded model-specific policy' \ - 'Report duplicate concrete profiles as a configuration error' \ - 'Unresolved relationship or quota: stop and report the tuple and concrete evidence' \ - 'After selecting, check auth only through that tuple'\''s surface' \ - 'Name the inspectable facts used for every candidate'; do - assert_grep "$phrase" "$OWNER" "quota-array-dispatch procedure lost '$phrase'" - done - - # Expanded acceptance scenarios live in deterministic fixtures, not runtime prose. - for phrase in \ - 'Higher raw quota but materially ahead vs lower raw quota on/behind pace' \ - 'Sanitized producer shape' \ - '## When to load' \ - '## Intake boundary this skill does not relax'; do - if grep -Fq -- "$phrase" "$OWNER"; then - fail "quota-array-dispatch should not keep removed runtime prose: $phrase" - fi - done - - lines=$(wc -l < "$OWNER" | tr -d ' ') - words=$(wc -w < "$OWNER" | tr -d ' ') - bytes=$(wc -c < "$OWNER" | tr -d ' ') - [ "$lines" -le 65 ] || fail "quota-array-dispatch skill is too long: $lines lines (want <= 65)" - [ "$words" -le 550 ] || fail "quota-array-dispatch skill is too wordy: $words words (want <= 550)" - [ "$bytes" -le 4600 ] || fail "quota-array-dispatch skill is too large: $bytes bytes (want <= 4600)" - pass "quota-array-dispatch owns the compact pace procedure ($lines lines, $words words, $bytes bytes)" -} - -test_cross_references_stay_pointers() { - assert_grep '`quota-array-dispatch` owns the pace-aware profile-array selection procedure' "$CONFIG" \ - "configuration docs do not point to quota-array-dispatch" - assert_no_grep '`AGENTS.md` section 4 owns the dispatch and array-selection procedure.' "$CONFIG" \ - "configuration docs still claim AGENTS.md owns the full array-selection procedure" - assert_grep 'quota-array-dispatch' "$ARCHITECTURE" \ - "architecture docs lost the quota-array-dispatch pointer" - assert_grep 'quota-array-dispatch' "$BOOTSTRAP" \ - "bootstrap header lost the quota-array-dispatch pointer" - assert_grep 'load `quota-array-dispatch` for the pace-aware candidate choice' "$HARNESS" \ - "harness-adapters lost the array-selection handoff" - assert_grep '.agents/skills/quota-array-dispatch/SKILL.md' "$AUDIENCES" \ - "documentation audience inventory missing quota-array-dispatch" - pass "cross-references point at the single procedure owner" -} - -test_schema_v3_shape_fixture() { - python3 - "$SHAPE" <<'PY' || fail "schema v3 shape fixture is invalid" -import json, sys -path = sys.argv[1] -data = json.load(open(path)) -assert data.get("schemaVersion") == 3, data.get("schemaVersion") -assert isinstance(data.get("providers"), list) and data["providers"], "providers" -provider = data["providers"][0] -assert "windows" in provider and provider["windows"], "windows" -window = provider["windows"][0] -assert "pace" in window and "status" in window["pace"], window -eff = provider["quotaSemantics"]["effectiveAvailability"][0] -assert "pace" in eff and "status" in eff["pace"], eff -assert "effectivePercentRemaining" in eff -# Privacy: no live account residue markers. -blob = json.dumps(data) -for bad in ("sk-", "@", "Bearer ", "accountId", "organizationId"): - assert bad not in blob, bad -PY - pass "sanitized schemaVersion 3 fixture preserves producer pace shape without private details" -} - -test_deterministic_acceptance_cases() { - local raw case_json case_id expect expect_error got reason - raw=$(cat "$CASES") - while IFS= read -r case_json; do - case_id=$(python3 -c 'import json,sys; print(json.loads(sys.argv[1])["id"])' "$case_json") - expect=$(python3 -c 'import json,sys; print(json.loads(sys.argv[1]).get("expect", ""))' "$case_json") - expect_error=$(python3 -c 'import json,sys; print(json.loads(sys.argv[1]).get("expectError", ""))' "$case_json") - reason=$(python3 -c 'import json,sys; print(json.loads(sys.argv[1])["reason"])' "$case_json") - got=$(select_candidate_py "$case_json") - python3 -c ' -import json,sys -got=json.loads(sys.argv[1]) -expect=sys.argv[2] -expect_error=sys.argv[3] -case_id=sys.argv[4] -err=got.get("error") -if expect_error: - if err != expect_error: - raise SystemExit("%s: expected error %s, got %s" % (case_id, expect_error, got)) -elif err: - raise SystemExit("%s: selector error: %s" % (case_id, err)) -elif got.get("id") != expect: - raise SystemExit("%s: expected %s, got %s" % (case_id, expect, got)) -' "$got" "$expect" "$expect_error" "$case_id" \ - || fail "case $case_id failed ($reason); selector returned $got" - if [ -n "$expect_error" ]; then - pass "case $case_id -> $expect_error ($reason)" - else - pass "case $case_id -> $expect ($reason)" - fi - done < <(python3 -c 'import json,sys; data=json.load(sys.stdin); [print(json.dumps(c, separators=(",", ":"))) for c in data["cases"]]' <<<"$raw") -} - -test_dispatch_identity_and_blocked_report() { - local reports - assert_grep '`harness-adapters` owns identity' "$OWNER" \ - "quota-array-dispatch does not point to the adapter identity owner" - assert_grep "After selecting, check auth only through that tuple's surface; another harness CLI cannot block it" "$OWNER" \ - "quota-array-dispatch does not scope evidence to the concrete tuple" - assert_no_grep 'Unresolved relationship, auth, or quota' "$OWNER" \ - "quota-array-dispatch checks authentication before selecting a candidate" - assert_grep 'A blocked credential report must name `harness`, `model`, authentication surface, and concrete failure evidence' "$OWNER" \ - "blocked reports do not preserve the minimum identity and evidence fields" - assert_grep 'The concrete `harness` field owns adapter identity independently of the model provider' "$HARNESS" \ - "harness-adapters lost the anti-conflation owner paragraph" - assert_grep '`harness=pi` with `model=xai/grok-*` is Pi using xAI, not `harness=grok`' "$HARNESS" \ - "harness-adapters lost the concrete Pi/xAI versus Grok distinction" - assert_grep 'does not require Grok CLI login' "$HARNESS" \ - "Pi/xAI guidance incorrectly requires Grok CLI login" - assert_no_grep '### Dispatch identity mapping' "$HARNESS" \ - "identity guidance grew a separate table instead of one owner paragraph" - - reports=$(python3 - <<'PY' - -def auth_surface(harness, model, provider): - surfaces = { - ("pi", "xai/grok-4.5", "xai"): "Pi xAI OAuth", - ("grok", "grok-4.5", "grok"): "Grok Build CLI", - } - try: - return surfaces[(harness, model, provider)] - except KeyError: - raise AssertionError("unresolved concrete profile") from None - -def evaluate(harness, model, provider, auth_available, failure): - surface = auth_surface(harness, model, provider) - if auth_available: - return (f"ready: harness={harness} model={model} provider={provider} " - f"authentication surface checked={surface}") - return (f"blocked: harness={harness} model={model} provider={provider} " - f"authentication surface checked={surface} failure evidence={failure}") - -pi = evaluate("pi", "xai/grok-4.5", "xai", True, None) -grok = evaluate("grok", "grok-4.5", "grok", False, "Grok Build CLI login missing") -assert "Grok Build CLI" not in pi -assert "Pi xAI OAuth" in pi -assert "Grok Build CLI" in grok -for mismatched in ( - ("grok", "xai/grok-4.5", "xai"), - ("pi", "grok-4.5", "grok"), -): - try: - auth_surface(*mismatched) - except AssertionError: - pass - else: - raise AssertionError(f"accepted mismatched profile: {mismatched}") -print(pi) -print(grok) -PY -) || fail "identity counterfactual fixture failed" - assert_contains "$reports" 'ready: harness=pi model=xai/grok-4.5 provider=xai authentication surface checked=Pi xAI OAuth' \ - "Pi/xAI did not remain dispatchable with its own authentication" - assert_not_contains "$reports" 'harness=pi model=xai/grok-4.5 provider=xai authentication surface checked=Grok Build CLI' \ - "Pi/xAI was reported with the standalone Grok CLI surface" - assert_contains "$reports" 'blocked: harness=grok model=grok-4.5 provider=grok authentication surface checked=Grok Build CLI' \ - "explicit Grok candidate did not use the Grok Build CLI surface" - for field in harness=grok model=grok-4.5 provider=grok 'authentication surface checked=Grok Build CLI' 'failure evidence=Grok Build CLI login missing'; do - assert_contains "$reports" "$field" "Grok blocked report lost '$field'" - done - printf '%s\n' "$reports" - pass "dispatch identity stays concrete across the Pi/xAI versus Grok counterfactual" -} - -test_no_duplicate_procedure_in_agents() { - # Guard against re-expanding the full procedure into AGENTS.md. - local count - count=$(grep -c 'conservation pressure' "$AGENTS" || true) - [ "$count" -eq 0 ] || fail "AGENTS.md should not restate conservation-pressure procedure detail" - count=$(grep -c 'worst applicable reserve' "$AGENTS" || true) - [ "$count" -eq 0 ] || fail "AGENTS.md should not restate worst-reserve procedure detail" - pass "AGENTS.md does not duplicate the pace procedure body" -} - -test_owner_and_always_loaded_boundary -test_owner_contains_selection_procedure -test_cross_references_stay_pointers -test_schema_v3_shape_fixture -test_deterministic_acceptance_cases -test_dispatch_identity_and_blocked_report -test_no_duplicate_procedure_in_agents diff --git a/tests/fm-secondmate-sync.test.sh b/tests/fm-secondmate-sync.test.sh index 79781e28ad..999aebc2df 100755 --- a/tests/fm-secondmate-sync.test.sh +++ b/tests/fm-secondmate-sync.test.sh @@ -837,15 +837,6 @@ test_seed_marker_does_not_mask_real_dirt() { pass "T14 marker tolerance does not mask a genuinely dirty home" } -# --- T15: the shipped firstmate repo gitignores the seed marker ----------------- -# Pins the actual fix so it cannot silently regress: without this .gitignore entry -# every seeded home would read dirty again the moment it lands on this repo's HEAD. -test_repo_gitignores_seed_marker() { - grep -qxF '.fm-secondmate-home' "$ROOT/.gitignore" \ - || fail "the firstmate repo .gitignore must ignore the seed marker (.fm-secondmate-home)" - pass "T15 the firstmate repo gitignores the secondmate seed marker" -} - test_ff_updated test_ff_current test_ff_dirty @@ -866,6 +857,5 @@ test_spawn_warns_when_sync_skipped_before_launch test_seed_marker_clean_when_gitignored test_seed_marker_converges_existing_home test_seed_marker_does_not_mask_real_dirt -test_repo_gitignores_seed_marker echo "# all fm-secondmate-sync tests passed" diff --git a/tests/fm-send-secondmate-marker-herdr-e2e.test.sh b/tests/fm-send-secondmate-marker-herdr-e2e.test.sh index 528ea49f22..5e28b9aaf8 100755 --- a/tests/fm-send-secondmate-marker-herdr-e2e.test.sh +++ b/tests/fm-send-secondmate-marker-herdr-e2e.test.sh @@ -27,7 +27,7 @@ if [ "${FM_SEND_MARKER_HERDR_E2E:-0}" != 1 ]; then exit 0 fi -for tool in git herdr jq pi python3; do +for tool in git herdr jq pi; do command -v "$tool" >/dev/null 2>&1 || { echo "skip: $tool not found"; exit 0; } done @@ -39,6 +39,7 @@ SECOND_HOME="$TMP_ROOT/secondmate-home" CAPTURE="$TMP_ROOT/pi-before-agent.jsonl" FAKEBIN="$TMP_ROOT/fakebin" ORIGINAL_PATH=$PATH +REAL_PI=$(command -v pi) ID='marker-pi-sm' REQUEST='FM_MARKER_HERDR_E2E exact-id request' DIRECT='FM_MARKER_HERDR_DIRECT captain input' @@ -93,38 +94,25 @@ You are a task-local secondmate used only for the marker transport regression. Stay idle and do not initiate work. EOF -# The extension is already an explicit Pi -e resource in the real secondmate -# launch template, so its project_trust hook can grant session-only trust before -# project resources load. before_agent_start records the exact prompt bytes and -# aborts before any provider request, keeping this transport regression local. +# A separate explicit Pi extension grants session-only project trust, records +# before_agent_start prompt bytes, and aborts before any provider request. +# The PATH wrapper adds only that test resource while preserving the production +# secondmate launch and its own extension arguments unchanged. CAPTURE_JSON=$(printf '%s' "$CAPTURE" | jq -Rs .) -python3 - "$SECOND_HOME/.pi/extensions/fm-primary-turnend-guard.ts" "$CAPTURE_JSON" <<'PY' -from pathlib import Path -import sys - -path = Path(sys.argv[1]) -capture_json = sys.argv[2] -source = path.read_text() -import_anchor = 'import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";\n' -source = source.replace( - import_anchor, - import_anchor - + 'import { appendFileSync as fmAppendFileSync } from "node:fs";\n' - + f'const fmCapturePath = {capture_json};\n', - 1, -) -factory_anchor = 'export default function (pi: ExtensionAPI) {\n' -replacement = '''export default function (pi: ExtensionAPI) { +CAPTURE_EXTENSION="$TMP_ROOT/fm-send-marker-capture.ts" +cat > "$CAPTURE_EXTENSION" < ({ trusted: "yes", remember: false })); pi.on("before_agent_start", (event, ctx) => { - fmAppendFileSync(fmCapturePath, `${JSON.stringify({ prompt: event.prompt, hex: Buffer.from(event.prompt, "utf8").toString("hex") })}\\n`); + appendFileSync(capturePath, \`\${JSON.stringify({ prompt: event.prompt, hex: Buffer.from(event.prompt, "utf8").toString("hex") })}\\n\`); ctx.abort(); }); -''' -if import_anchor not in source or factory_anchor not in source: - raise SystemExit("Pi extension insertion point missing") -path.write_text(source.replace(factory_anchor, replacement, 1)) -PY +} +EOF +printf '#!/usr/bin/env bash\nexec %q -e %q "$@"\n' "$REAL_PI" "$CAPTURE_EXTENSION" > "$FAKEBIN/pi" +chmod +x "$FAKEBIN/pi" "$LAB_HELPER" provision "$SESSION" PATH="$FAKEBIN:$ORIGINAL_PATH" FM_GATE_REFUSE_BYPASS=1 FM_HOME="$SENDER_HOME" HERDR_SESSION="$SESSION" \ diff --git a/tests/fm-sessionstart-nudge.test.sh b/tests/fm-sessionstart-nudge.test.sh index 28bb3d18b5..878295cba5 100755 --- a/tests/fm-sessionstart-nudge.test.sh +++ b/tests/fm-sessionstart-nudge.test.sh @@ -148,44 +148,6 @@ EOF pass "OpenCode session.created delivers the exact wrapper nudge once per session" } -test_tracked_harness_registration() { - local command pi_plugin opencode_plugin - jq -e '.hooks.SessionStart | length == 1' "$ROOT/.claude/settings.json" >/dev/null \ - || fail "Claude SessionStart hook is not registered exactly once" - jq -e '.hooks.SessionStart[0].matcher == "startup|resume|clear"' "$ROOT/.claude/settings.json" >/dev/null \ - || fail "Claude SessionStart matcher must include startup/resume/clear and exclude compact" - jq -e 'any(.hooks.SessionStart[]?.hooks[]?.command?; contains("fm-sessionstart-nudge.sh"))' \ - "$ROOT/.claude/settings.json" >/dev/null || fail "Claude SessionStart hook does not invoke the wrapper" - - command=$(jq -r '.hooks.SessionStart[0].hooks[0].command' "$ROOT/.codex/hooks.json") - # shellcheck disable=SC2016 - assert_contains "$command" 'payload=$(cat' "Codex SessionStart hook does not read its payload" - # shellcheck disable=SC2016 - assert_contains "$command" 'root=$(pwd -P)' "Codex SessionStart hook is not pwd-anchored" - assert_contains "$command" 'fm-sessionstart-nudge.sh' "Codex SessionStart hook does not invoke the wrapper" - - command=$(jq -r '.hooks.SessionStart[0].hooks[0].command' "$ROOT/.grok/hooks/fm-primary-sessionstart-nudge.json") - # shellcheck disable=SC2016 - assert_contains "$command" '${GROK_WORKSPACE_ROOT:-}' "Grok SessionStart hook lacks an inline-default workspace root" - # shellcheck disable=SC2016 - assert_not_contains "$command" '${GROK_WORKSPACE_ROOT}' "Grok SessionStart hook contains a bare variable expansion" - assert_contains "$command" 'fm-sessionstart-nudge.sh' "Grok SessionStart hook does not invoke the wrapper" - - pi_plugin=$(cat "$ROOT/.pi/extensions/fm-primary-turnend-guard.ts") - assert_contains "$pi_plugin" '["startup", "new", "resume"]' "Pi SessionStart handler has the wrong reason allowlist" - assert_contains "$pi_plugin" 'fm-sessionstart-nudge.sh' "Pi SessionStart handler does not invoke the wrapper" - assert_contains "$pi_plugin" 'firstmate-sessionstart-nudge' "Pi SessionStart handler does not inject a custom context message" - assert_contains "$pi_plugin" 'details: { kind: "session-start" }' "Pi SessionStart context does not retain its exact structured kind" - assert_contains "$pi_plugin" 'pi.sendMessage' "Pi SessionStart handler does not use the context-safe message API" - - opencode_plugin=$(cat "$ROOT/.opencode/plugins/fm-primary-sessionstart-nudge.js") - assert_contains "$opencode_plugin" 'session.created' "OpenCode plugin does not listen for session.created" - assert_contains "$opencode_plugin" 'fm-sessionstart-nudge.sh' "OpenCode plugin does not invoke the wrapper" - assert_contains "$opencode_plugin" 'promptAsync' "OpenCode plugin does not prompt the nudge turn" - - pass "all five verified harnesses register the shared session-start nudge" -} - test_genuine_primary_nudges test_gate_env_is_silent test_gate_common_dir_is_silent @@ -194,4 +156,3 @@ test_linked_secondmate_primary_nudges test_missing_state_is_silent test_owned_lock_is_silent test_opencode_plugin_delivers_exact_nudge_once -test_tracked_harness_registration diff --git a/tests/fm-stow-contract.test.sh b/tests/fm-stow-contract.test.sh deleted file mode 100755 index c43f47c4db..0000000000 --- a/tests/fm-stow-contract.test.sh +++ /dev/null @@ -1,37 +0,0 @@ -#!/usr/bin/env bash -# Behavior tests for /stow's inspect-then-update memory contract. -set -u - -# shellcheck source=tests/lib.sh disable=SC1091 -. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" - -test_stow_skill_task_note_contract() { - local stow="$ROOT/.agents/skills/stow/SKILL.md" - - assert_grep 'tasks-axi show --full' "$stow" "stow skill does not require inspecting task notes first" - assert_grep 'tasks-axi update --body-file ' "$stow" "stow skill does not require task body replacement" - assert_grep '--archive-body' "$stow" "stow skill does not document recoverable task body archival" - assert_grep 'Never append.' "$stow" "stow skill does not forbid append-first task notes" - assert_no_grep 'carry that context into the replacement body' "$stow" "stow skill still preserves archive-only context in the replacement body" - pass "stow skill task-note contract includes recoverable body archival" -} - -test_agents_backlog_task_note_contract() { - local agents="$ROOT/AGENTS.md" - - # shellcheck disable=SC2016 # Literal backticks must remain unexpanded. - assert_grep 'current `tasks-axi --help` own the backlog schema' "$agents" \ - "AGENTS.md does not point exact task-note mechanics to the command owner" - assert_grep 'Inspect the current task note before replacing its considered body' "$agents" \ - "AGENTS.md does not require inspecting task notes before replacement" - assert_grep 'archive the superseded body when recoverability matters rather than appending by default' "$agents" \ - "AGENTS.md lost recoverable replacement and no-append semantics" - assert_no_grep 'tasks-axi show --full' "$agents" \ - "AGENTS.md duplicates exact task-note read syntax from its conditional owner" - assert_no_grep 'tasks-axi update --body-file ' "$agents" \ - "AGENTS.md duplicates exact task-note update syntax from its conditional owner" - pass "AGENTS.md keeps task-note hygiene inline and points exact mechanics to their owner" -} - -test_stow_skill_task_note_contract -test_agents_backlog_task_note_contract diff --git a/tests/fm-subagent-pretool-check.test.sh b/tests/fm-subagent-pretool-check.test.sh index 27fbc96dc7..c1a2115897 100755 --- a/tests/fm-subagent-pretool-check.test.sh +++ b/tests/fm-subagent-pretool-check.test.sh @@ -7,7 +7,6 @@ set -u . "$(dirname "${BASH_SOURCE[0]}")/lib.sh" CHECK="$ROOT/bin/fm-subagent-pretool-check.sh" -SETTINGS="$ROOT/.claude/settings.json" TMP_ROOT=$(fm_test_tmproot fm-subagent-pretool-tests) PRIMARY="$TMP_ROOT/primary" STATE="$PRIMARY/state" @@ -73,15 +72,9 @@ expect_deny() { } # --------------------------------------------------------------------------- -# Tracked settings boundary and delegation-shape PreToolUse guard. +# Delegation-shape PreToolUse guard. # --------------------------------------------------------------------------- -test_tracked_settings_do_not_ship_permissions_deny() { - jq -e 'keys == ["hooks"] and (has("permissions") | not)' "$SETTINGS" >/dev/null \ - || fail "tracked Claude settings must contain only hooks and no permissions key" - pass "tracked Claude settings do not ship permissions.deny" -} - test_guard_denies_every_currently_known_delegation_tool() { local tool for tool in $DELEGATION_TOOLS; do @@ -283,31 +276,6 @@ test_missing_jq_stdin_transport_fails_open() { pass "missing jq for stdin transport fails open rather than denying every tool call" } -test_claude_hook_registration_preserves_bash_seatbelts() { - jq -e ' - [.hooks.PreToolUse[] | .hooks[].command] - | any(contains("fm-subagent-pretool-check.sh --claude")) - ' "$SETTINGS" >/dev/null || fail "Claude settings omit the delegation-shape PreToolUse guard" - # A stem-enumerating matcher repeats the fail-open-by-enumeration defect the - # script exists to remove. Match all tools and let the script be the single - # owner of classification. - jq -e ' - [.hooks.PreToolUse[] | select(.hooks[].command | contains("fm-subagent-pretool-check.sh")) | .matcher] | .[0] - | . == ".*" - ' "$SETTINGS" >/dev/null || fail "the guard matcher must match all tools" - jq -e ' - [.hooks.PreToolUse[] | select(.matcher == "Bash") | .hooks[].command] - == [ - "\"$CLAUDE_PROJECT_DIR\"/bin/fm-arm-pretool-check.sh --claude", - "\"$CLAUDE_PROJECT_DIR\"/bin/fm-cd-pretool-check.sh --claude" - ] - ' "$SETTINGS" >/dev/null || fail "Claude Bash PreToolUse must retain only the arm-shape and persistent-cd seatbelts" - jq -e '.hooks.Stop[0].hooks[0].command | contains("fm-turnend-guard.sh")' "$SETTINGS" >/dev/null \ - || fail "the Stop turn-end guard changed" - pass "Claude wires the delegation guard, retains only non-status Bash seatbelts, and preserves the Stop guard" -} - -test_tracked_settings_do_not_ship_permissions_deny test_guard_denies_every_currently_known_delegation_tool test_guard_denies_hypothetical_future_tools test_guard_allows_ordinary_and_observe_only_tools @@ -321,4 +289,3 @@ test_secondmate_home_is_in_scope test_stdin_transports_and_output_shapes test_malformed_transport_fails_open test_missing_jq_stdin_transport_fails_open -test_claude_hook_registration_preserves_bash_seatbelts diff --git a/tests/fm-test-isolation-proof.test.sh b/tests/fm-test-isolation-proof.test.sh index 6a11def0ea..1847338e8c 100755 --- a/tests/fm-test-isolation-proof.test.sh +++ b/tests/fm-test-isolation-proof.test.sh @@ -1,24 +1,12 @@ #!/usr/bin/env bash -# Contract tests for bin/fm-test-isolation-proof.sh - the Phase 2 pre-shard -# isolation proof harness. -# -# These tests assert the candidate-set contract, serial exclusions, aggregate -# failure reporting, and that Phase 4 production shards consume this exact set. -# They deliberately do NOT re-run the full concurrent candidate matrix on every -# invocation (that matrix is owned by the harness itself and archived under -# docs/fm-test-isolation-proof.md after a deliberate proof run). +# Behavioral tests for the isolation-proof and test-run public interfaces. set -u -# shellcheck disable=SC1091 # shellcheck source=tests/lib.sh . "$(dirname "${BASH_SOURCE[0]}")/lib.sh" PROOF="$ROOT/bin/fm-test-isolation-proof.sh" RUNNER="$ROOT/bin/fm-test-run.sh" -CI="$ROOT/.github/workflows/ci.yml" -CONTRIB="$ROOT/CONTRIBUTING.md" -PROOF_DOC="$ROOT/docs/fm-test-isolation-proof.md" -PROOF_JSON="$ROOT/docs/fm-test-isolation-proof.json" assert_present "$PROOF" "bin/fm-test-isolation-proof.sh is missing" [ -x "$PROOF" ] || fail "bin/fm-test-isolation-proof.sh must be executable" @@ -31,7 +19,6 @@ test_list_candidates_nonempty_and_stable() { [ "$count" -ge 10 ] || fail "expected a bounded non-trivial candidate set, got $count" sorted=$(printf '%s\n' "$listed" | LC_ALL=C sort) [ "$listed" = "$sorted" ] || fail "--list must be sorted for a stable matrix" - # No duplicates. [ "$(printf '%s\n' "$listed" | uniq | wc -l | tr -d ' ')" = "$count" ] \ || fail "--list must not duplicate candidates" while IFS= read -r line; do @@ -47,11 +34,8 @@ test_list_candidates_nonempty_and_stable() { test_candidates_exclude_serial_classes() { local listed listed=$("$PROOF" --list) - # Self must never re-enter the concurrent matrix. - printf '%s\n' "$listed" | grep -Fq 'tests/fm-test-isolation-proof.test.sh' \ - && fail "isolation-proof test must not be a parallel candidate" - # Real tmux smoke, watcher lock, real herdr, AFK, live harnesses stay serial. for banned in \ + tests/fm-test-isolation-proof.test.sh \ tests/fm-backend-tmux-smoke.test.sh \ tests/fm-watcher-lock.test.sh \ tests/fm-wake-queue.test.sh \ @@ -66,16 +50,6 @@ test_candidates_exclude_serial_classes() { pass "serial classes remain excluded from the parallel candidate set" } -test_candidates_match_archived_proof() { - local listed archived - assert_present "$PROOF_JSON" "docs/fm-test-isolation-proof.json missing" - listed=$("$PROOF" --list) - archived=$(jq -r '.scripts[].path' "$PROOF_JSON" | LC_ALL=C sort) - [ "$listed" = "$archived" ] \ - || fail "candidate set must exactly match the archived isolation proof" - pass "candidate set exactly matches the archived isolation proof" -} - test_extra_hermetic_candidates_present() { local listed listed=$("$PROOF" --list) @@ -89,7 +63,7 @@ test_extra_hermetic_candidates_present() { printf '%s\n' "$listed" | grep -Fxq "$want" \ || fail "extra hermetic candidate missing: $want" done - pass "audited fake-backend / stub-network extras are candidates" + pass "audited fake-backend and stub-network extras are candidates" } test_list_exclusions_documents_reasons() { @@ -111,82 +85,7 @@ test_family_map_labels_this_contract() { pass "isolation-proof contract test is family-mapped" } -test_aggregate_failure_under_concurrency() { - local tmp pass_f fail_f harness rc out - tmp=$(mktemp -d "${TMPDIR:-/tmp}/fm-isolation-agg.XXXXXX") - pass_f="$tmp/pass.test.sh" - fail_f="$tmp/fail.test.sh" - cat >"$pass_f" <<'SH' -#!/usr/bin/env bash -echo "ok - pass" -exit 0 -SH - cat >"$fail_f" <<'SH' -#!/usr/bin/env bash -echo "not ok - fail" -exit 1 -SH - chmod +x "$pass_f" "$fail_f" - # Minimal fixture harness mirroring aggregate + concurrent wait semantics. - harness="$tmp/harness.sh" - cat >"$harness" <<'SH' -#!/usr/bin/env bash -set -eu -jobs=$1 -shift -pids=() -rcs=() -paths=() -idx=0 -for s in "$@"; do - idx=$((idx + 1)) - ( - bash "$s" - echo $? >"${TMPDIR:-/tmp}/iso-rc-$idx" - ) & - pids+=("$!") - paths+=("$s") - while [ "${#pids[@]}" -ge "$jobs" ]; do - wait "${pids[0]}" || true - pids=("${pids[@]:1}") - done -done -while [ "${#pids[@]}" -gt 0 ]; do - wait "${pids[0]}" || true - pids=("${pids[@]:1}") -done -failed=0 -for i in $(seq 1 "$idx"); do - rc=$(cat "${TMPDIR:-/tmp}/iso-rc-$i" 2>/dev/null || echo 1) - [ "$rc" -eq 0 ] || failed=$((failed + 1)) - rm -f "${TMPDIR:-/tmp}/iso-rc-$i" -done -echo "FM_ISOLATION_SUMMARY total=$idx failed=$failed" -[ "$failed" -eq 0 ] -SH - chmod +x "$harness" - set +e - out=$(TMPDIR="$tmp" bash "$harness" 2 "$pass_f" "$fail_f" 2>&1) - rc=$? - set -e - [ "$rc" -ne 0 ] || fail "concurrent aggregate must fail when any candidate fails" - printf '%s\n' "$out" | grep -Fq 'FM_ISOLATION_SUMMARY total=2 failed=1' \ - || fail "aggregate summary must report total=2 failed=1: $out" - rm -rf "$tmp" - pass "aggregate failure reporting survives concurrency" -} - -test_phase4_consumes_proven_set_only() { - assert_present "$CI" "ci.yml missing" - assert_present "$RUNNER" "fm-test-run.sh missing" - # Phase 4 portable parallel lanes must exist and use lane selection, not --all. - grep -Fq 'bin/fm-test-run.sh --lane portable-parallel-1' "$CI" \ - || fail "CI portable parallel 1 must use --lane portable-parallel-1" - grep -Fq 'bin/fm-test-run.sh --lane portable-parallel-2' "$CI" \ - || fail "CI portable parallel 2 must use --lane portable-parallel-2" - grep -Fq 'bin/fm-test-run.sh --lane portable-serial' "$CI" \ - || fail "CI portable serial must use --lane portable-serial" - # Shard union must equal this harness's proven list. +test_parallel_shards_consume_the_proven_set() { local proven shards proven=$("$PROOF" --list | LC_ALL=C sort -u) shards=$( @@ -197,76 +96,12 @@ test_phase4_consumes_proven_set_only() { ) [ "$proven" = "$shards" ] \ || fail "portable parallel shards must equal isolation-proof --list exactly" - # Local --jobs is bounded to this proven set (refuse is contract-tested in - # fm-test-run.test.sh); the option must exist. - grep -E '^[[:space:]]*--jobs\)' "$RUNNER" >/dev/null 2>&1 \ - || fail "fm-test-run.sh must expose bounded --jobs after Phase 4" - pass "Phase 4 portable shards consume the proven-isolated set only" -} - -test_docs_record_proof_owner() { - assert_present "$PROOF_DOC" "docs/fm-test-isolation-proof.md missing" - grep -Fq 'bin/fm-test-isolation-proof.sh' "$PROOF_DOC" \ - || fail "proof doc must name the harness owner" - grep -Fq 'production_sharding_enabled' "$PROOF_DOC" \ - || fail "proof doc must record the archived proof-time sharding flag" - grep -Fq 'concurrency' "$PROOF_DOC" \ - || fail "proof doc must record concurrency" - assert_present "$CONTRIB" "CONTRIBUTING.md missing" - grep -Fq 'fm-test-isolation-proof' "$CONTRIB" \ - || fail "CONTRIBUTING must document the isolation-proof entry point" - pass "docs archive the isolation-proof owner and posture" -} - -test_docs_match_archived_proof() { - python3 - "$PROOF_DOC" "$PROOF_JSON" <<'PY' \ - || fail "proof Markdown must match the archived proof JSON" -import json -import re -import sys - -markdown = open(sys.argv[1], encoding="utf-8").read() -with open(sys.argv[2], encoding="utf-8") as stream: - proof = json.load(stream) - -summary = proof["summary"] -posture = [ - f'| `run_id` | `{proof["run_id"]}` |', - f'| `started_at` | `{proof["started_at"]}` |', - f'| `finished_at` | `{proof["finished_at"]}` |', - f'| concurrency | **{proof["concurrency"]}** |', - f'| candidates | **{summary["total"]}** |', - f'| failed | **{summary["failed"]}** |', - f'| wall duration_ms | **{summary["duration_ms"]}** (~{summary["duration_ms"] / 1000:.1f}s) |', - f'| `production_sharding_enabled` | `{str(proof["production_sharding_enabled"]).capitalize()}` |', - f'| `fm_test_run_jobs_enabled` | `{str(proof["fm_test_run_jobs_enabled"]).capitalize()}` |', - f'| host proof date | {proof["finished_at"][:10]} (UTC day of archive write) |', -] -assert all(line in markdown for line in posture) -section = markdown.split("## Per-candidate durations (concurrent run)", 1)[1] -section = section.split("## Audit notes (why this set)", 1)[0] -actual = [ - (int(duration), int(exit_code), int(worker), path) - for duration, exit_code, worker, path in re.findall( - r"^\| (\d+) \| (\d+) \| (\d+) \| `([^`]+)` \|$", section, re.MULTILINE - ) -] -expected = [ - (row["duration_ms"], row["exit"], row["worker"], row["path"]) - for row in sorted(proof["scripts"], key=lambda row: row["duration_ms"], reverse=True) -] -assert actual == expected -PY - pass "proof Markdown matches archived JSON posture and durations" + pass "parallel shards consume the proven-isolated set only" } test_list_candidates_nonempty_and_stable test_candidates_exclude_serial_classes -test_candidates_match_archived_proof test_extra_hermetic_candidates_present test_list_exclusions_documents_reasons test_family_map_labels_this_contract -test_aggregate_failure_under_concurrency -test_phase4_consumes_proven_set_only -test_docs_record_proof_owner -test_docs_match_archived_proof +test_parallel_shards_consume_the_proven_set diff --git a/tests/fm-test-run.test.sh b/tests/fm-test-run.test.sh index 7c7dbc5d1b..cfb1578fc4 100755 --- a/tests/fm-test-run.test.sh +++ b/tests/fm-test-run.test.sh @@ -11,9 +11,6 @@ set -u . "$(dirname "${BASH_SOURCE[0]}")/lib.sh" RUNNER="$ROOT/bin/fm-test-run.sh" -CI="$ROOT/.github/workflows/ci.yml" -CONTRIB="$ROOT/CONTRIBUTING.md" -SHARD_DOC="$ROOT/docs/fm-test-portable-shards.md" assert_present "$RUNNER" "bin/fm-test-run.sh is missing" [ -x "$RUNNER" ] || fail "bin/fm-test-run.sh must be executable" @@ -98,7 +95,7 @@ init_changed_fixture_repo() { chmod +x "$repo/bin/fm-test-run.sh" for script in \ fm-brief.test.sh \ - fm-captain-translation-contract.test.sh \ + fm-ask-user-authority.test.sh \ fm-cd-pretool-check.test.sh \ fm-daemon.test.sh \ fm-backend-herdr-smoke.test.sh \ @@ -167,7 +164,7 @@ test_changed_dependency_selection_and_unmapped_failure() { printf '\n' >>"$repo/.pi/extensions/fm-primary-pi-watch.ts" printf '\n' >>"$repo/.pi/extensions/fm-primary-turnend-guard.ts" listed=$(cd "$repo" && bin/fm-test-run.sh --list --changed --base HEAD) - assert_contains "$listed" "tests/fm-captain-translation-contract.test.sh" "skill source selects pure contract coverage" + assert_contains "$listed" "tests/fm-ask-user-authority.test.sh" "skill source selects pure contract coverage" assert_contains "$listed" "tests/fm-cd-pretool-check.test.sh" "Claude and Pi source selects hook coverage" assert_contains "$listed" "tests/fm-pi-watch-extension.test.sh" "Pi source selects watcher coverage" git -C "$repo" add .agents .claude .pi @@ -353,84 +350,6 @@ test_exclude_family() { pass "exclude-family drops the named primary family after selection" } -test_ci_and_docs_call_the_owner() { - assert_present "$CI" "ci.yml missing" - assert_present "$CONTRIB" "CONTRIBUTING.md missing" - grep -Fq 'tests-portable-parallel-1:' "$CI" \ - || fail "CI must define portable parallel shard 1" - grep -Fq 'tests-portable-parallel-2:' "$CI" \ - || fail "CI must define portable parallel shard 2" - grep -Fq 'tests-portable-serial:' "$CI" \ - || fail "CI must define the portable serial lane" - grep -Fq 'bin/fm-test-run.sh --lane portable-parallel-1' "$CI" \ - || fail "CI shard 1 must invoke --lane portable-parallel-1" - grep -Fq 'bin/fm-test-run.sh --lane portable-parallel-2' "$CI" \ - || fail "CI shard 2 must invoke --lane portable-parallel-2" - local shard job_body - for shard in 1 2; do - job_body=$(awk -v job=" tests-portable-parallel-$shard:" ' - $0 == job { in_job=1; next } - in_job && /^ [a-zA-Z0-9_-]+:/ { exit } - in_job { print } - ' "$CI") - printf '%s\n' "$job_body" | grep -Fq 'npm install -g tasks-axi' \ - || fail "CI portable parallel shard $shard must install tasks-axi" - printf '%s\n' "$job_body" | grep -Fq 'tasks-axi --version' \ - || fail "CI portable parallel shard $shard must verify tasks-axi" - done - grep -Fq 'bin/fm-test-run.sh --lane portable-serial' "$CI" \ - || fail "CI portable serial must invoke --lane portable-serial" - grep -Fq 'bin/fm-test-run.sh --check-coverage' "$CI" \ - || fail "CI must run the coverage guard" - grep -Fq 'tests-herdr:' "$CI" \ - || fail "CI must define the required tests-herdr job" - grep -Fq 'bin/fm-test-run.sh --family real-herdr-gated' "$CI" \ - || fail "Herdr CI job must run the real-herdr-gated family via fm-test-run" - grep -Fq -- "--fail-on-gate-skip 'herdr not found'" "$CI" \ - || fail "Herdr CI job must fail on herdr-not-found skips" - grep -Fq 'bin/fm-install-herdr.sh' "$CI" \ - || fail "Herdr CI job must install via bin/fm-install-herdr.sh" - grep -Fq 'bin/fm-install-treehouse.sh' "$CI" \ - || fail "Herdr CI job must install via bin/fm-install-treehouse.sh" - grep -Fq 'bin/fm-herdr-ci-cleanup.sh' "$CI" \ - || fail "Herdr CI job must use bounded lab cleanup" - grep -Fq 'tests-timing-aggregate:' "$CI" \ - || fail "CI must aggregate per-lane timing artifacts" - grep -Fq 'timeout-minutes: 20' "$CI" \ - || fail "portable serial hang tripwire must be timeout-minutes: 20" - grep -Fq 'timeout-minutes: 10' "$CI" \ - || fail "portable parallel shards must keep a hang tripwire (10m)" - # Interim full-suite 25m portable timeout must not remain after sharding. - if grep -Eq 'timeout-minutes: 25' "$CI"; then - fail "CI still has interim timeout-minutes: 25 after portable sharding" - fi - # Stale "~2-3 minutes" claim must not remain. - if grep -Eq '2-3 minutes' "$CI"; then - fail "CI workflow still claims the suite finishes in ~2-3 minutes" - fi - # No retry-green strategy on Behavior lanes. - if grep -Eqi 'retry:|max-attempts:|continue-on-error:\s*true' "$CI"; then - fail "CI must not use retries or continue-on-error as a green strategy" - fi - grep -Fq 'fm-test-timing' "$CI" \ - || fail "CI must upload timing artifacts" - grep -Fq 'bin/fm-test-run.sh --all' "$CONTRIB" \ - || fail "CONTRIBUTING must document bin/fm-test-run.sh --all" - grep -Fq 'bin/fm-test-run.sh --family' "$CONTRIB" \ - || fail "CONTRIBUTING must document family selection" - grep -Fq 'bin/fm-test-run.sh --changed' "$CONTRIB" \ - || fail "CONTRIBUTING must document changed-file selection" - grep -Fq 'bin/fm-test-run.sh --proven-isolated --jobs' "$CONTRIB" \ - || fail "CONTRIBUTING must document proven-isolated --jobs" - grep -Fq 'intent-targeted' "$CONTRIB" \ - || fail "CONTRIBUTING must document intent-targeted no-mistakes Test" - # Do not restore a complete-suite commands.test. - if grep -E '^[[:space:]]*test:[[:space:]].*tests/\*\.test\.sh' "$ROOT/.no-mistakes.yaml" >/dev/null 2>&1; then - fail ".no-mistakes.yaml must not set a full-suite commands.test" - fi - pass "CI and CONTRIBUTING call the one-owner runner; no full-suite local Test" -} - test_portable_shard_union_and_coverage_guard() { local s1 s2 proven serial herdr all_count union_count overlap out first s1=$("$RUNNER" --list --lane portable-parallel-1) @@ -462,40 +381,11 @@ test_portable_shard_union_and_coverage_guard() { || fail "lanes must not duplicate scripts" # LPT order: first script of shard 1 is the longest proven script. first=$(printf '%s\n' "$s1" | head -n 1) - [ "$first" = "tests/fm-arm-pretool-check.test.sh" ] \ - || fail "shard 1 must start with longest proven script, got $first" + [ "$first" = "tests/fm-x-mode.test.sh" ] \ + || fail "shard 1 must start with the longest proven script, got $first" pass "portable shard union, disjointness, and coverage guard hold" } -test_portable_shard_docs_match_lanes() { - python3 - "$RUNNER" "$SHARD_DOC" <<'PY' \ - || fail "portable shard documentation must match lane counts and timing sums" -import re -import subprocess -import sys - -runner, doc_path = sys.argv[1:3] -markdown = open(doc_path, encoding="utf-8").read() -averages = { - path: int(duration) - for duration, path in re.findall(r"^\| (\d+) \| `([^`]+)` \|$", markdown, re.MULTILINE) -} -totals = {} -for lane in ("portable-parallel-1", "portable-parallel-2"): - scripts = subprocess.check_output( - [runner, "--list", "--lane", lane], text=True - ).splitlines() - totals[lane] = (len(scripts), sum(averages[path] for path in scripts)) - -for lane, (count, duration) in totals.items(): - expected = f"| `{lane}` | {count} | {duration} ms (~{duration / 1000:.1f} s) |" - assert expected in markdown -imbalance = abs(totals["portable-parallel-1"][1] - totals["portable-parallel-2"][1]) -assert f"| imbalance | | {imbalance} ms |" in markdown -PY - pass "portable shard documentation matches lane counts and timing sums" -} - test_jobs_requires_proven_isolated() { local tmp rc tmp=$(mktemp -d "${TMPDIR:-/tmp}/fm-test-run-jobs.XXXXXX") @@ -522,8 +412,8 @@ test_jobs_parallel_scheduler_and_failure_propagation() { runner="$repo/bin/fm-test-run.sh" evidence="$tmp/evidence" fake_bin="$tmp/fake-bin" - a=tests/fm-no-mistakes-ownership.test.sh - b=tests/fm-stow-contract.test.sh + a=tests/fm-brief.test.sh + b=tests/fm-composer-lib.test.sh c=tests/fm-lint.test.sh d=tests/fm-supervision-instructions.test.sh mkdir -p "$repo/bin" "$repo/tests" "$evidence" "$fake_bin" @@ -688,9 +578,7 @@ test_aggregate_exit_behavior test_gate_skip_accounting test_fail_on_gate_skip_token test_exclude_family -test_ci_and_docs_call_the_owner test_portable_shard_union_and_coverage_guard -test_portable_shard_docs_match_lanes test_jobs_requires_proven_isolated test_jobs_parallel_scheduler_and_failure_propagation test_aggregate_json diff --git a/tests/fm-turnend-guard.test.sh b/tests/fm-turnend-guard.test.sh index 2b82165178..242407c1a3 100755 --- a/tests/fm-turnend-guard.test.sh +++ b/tests/fm-turnend-guard.test.sh @@ -706,38 +706,6 @@ test_grok_adapter_missing_jq_and_no_supervision_allow() { pass "fm-turnend-guard-grok: missing jq and no-supervision-needed stops stay silent and bounded" } -test_settings_hook_uses_claude_project_dir() { - local settings command autoarm - settings="$ROOT/.claude/settings.json" - [ -f "$settings" ] || fail "tracked .claude/settings.json is missing" - command=$(jq -r '.hooks.Stop[0].hooks[0].command // empty' "$settings") - autoarm=$(jq -r '.hooks.Stop[0].hooks[1].command // empty' "$settings") - [ -n "$command" ] || fail "Stop hook command is missing from .claude/settings.json" - assert_contains "$command" 'CLAUDE_PROJECT_DIR' "Stop hook must resolve via CLAUDE_PROJECT_DIR, not a cwd-relative path" - assert_contains "$command" 'fm-turnend-guard.sh --claude' "Stop hook must invoke fm-turnend-guard.sh in cooperative --claude mode" - assert_contains "$command" 'GROK_AGENT' "Claude blocking Stop hook must stay inert when Grok loads Claude-compatible settings" - assert_contains "$autoarm" 'GROK_AGENT' "Claude auto-arm Stop hook must stay inert when Grok loads Claude-compatible settings" - case "$command" in - bin/fm-turnend-guard.sh|./bin/fm-turnend-guard.sh) - fail "Stop hook must not use a bare relative path (cwd-dependent): $command" - ;; - esac - pass ".claude/settings.json: Stop hook uses CLAUDE_PROJECT_DIR-anchored --claude guard command" -} - -test_codex_hook_invokes_shared_guard() { - local settings command - settings="$ROOT/.codex/hooks.json" - [ -f "$settings" ] || fail "tracked .codex/hooks.json is missing" - command=$(jq -r '.hooks.Stop[0].hooks[0].command // empty' "$settings") - [ -n "$command" ] || fail "Stop hook command is missing from .codex/hooks.json" - assert_contains "$command" 'pwd -P' "codex hook must anchor from the hook process working directory" - assert_contains "$command" '.codex/hooks.json' "codex hook must verify the hook-loaded firstmate root" - assert_contains "$command" 'fm-turnend-guard.sh' "codex hook must invoke the shared guard" - assert_not_contains "$command" '.cwd' "codex hook must not use payload cwd to select the guard executable" - pass ".codex/hooks.json: Stop hook invokes the shared primary guard" -} - test_codex_hook_uses_process_pwd_when_payload_cwd_is_outside_root() { local settings command dir expected_root outside payload out status settings="$ROOT/.codex/hooks.json" @@ -801,23 +769,6 @@ EOF pass ".codex/hooks.json: Stop hook ignores nested git root guard scripts" } -test_opencode_plugin_forces_followup() { - local plugin content - plugin="$ROOT/.opencode/plugins/fm-primary-turnend-guard.js" - [ -f "$plugin" ] || fail "tracked OpenCode primary plugin is missing" - content=$(cat "$plugin") - assert_contains "$content" 'session.idle' "OpenCode plugin must run on session.idle" - assert_contains "$content" 'fm-turnend-guard.sh' "OpenCode plugin must invoke the shared guard" - assert_contains "$content" 'promptAsync' "OpenCode plugin must force a follow-up turn" - assert_contains "$content" 'encodeFirstmateOperationalInput' "OpenCode plugin must use the typed operational-input constructor" - assert_contains "$content" 'skipNextIdle' "OpenCode plugin must carry a loop guard" - assert_contains "$content" 'worktree' "OpenCode plugin must anchor the guard from the git worktree path" - assert_contains "$content" 'watcher cycle is missing, failed, or unhealthy' "OpenCode plugin must identify a blind turn as watcher recovery" - assert_contains "$content" 'harness recovery instruction below' "OpenCode plugin must delegate recovery action to the shared guard line" - assert_not_contains "$content" 'Resume supervision according to the session-start operating block' "OpenCode plugin must not route a blind turn through ordinary continuity" - pass ".opencode primary plugin: session.idle forces one follow-up through the shared guard" -} - test_opencode_plugin_anchors_guard_to_worktree() { local plugin parent worktree_dir wrong_dir out status plugin="$ROOT/.opencode/plugins/fm-primary-turnend-guard.js" @@ -877,30 +828,6 @@ EOF pass ".opencode primary plugin: guard path is anchored to worktree, not directory" } -test_pi_extension_forces_followup() { - local ext content - ext="$ROOT/.pi/extensions/fm-primary-turnend-guard.ts" - [ -f "$ext" ] || fail "tracked pi primary extension is missing" - content=$(cat "$ext") - assert_contains "$content" 'agent_settled' "pi extension must run after one logical agent run settles" - assert_contains "$content" 'fm-turnend-guard.sh' "pi extension must invoke the shared guard" - assert_contains "$content" 'sendUserMessage' "pi extension must force a follow-up turn" - assert_contains "$content" 'encodeFirstmateOperationalInput' "pi extension must use the typed operational-input constructor" - assert_contains "$content" 'deliverAs: "followUp"' "pi extension must queue the follow-up safely" - assert_contains "$content" 'guardFollowupActive' "pi extension must carry a logical-run loop guard" - assert_not_contains "$content" 'skipNextTurnEnd' "pi extension kept the internal-turn loop guard" - assert_contains "$content" 'watcher cycle is missing, failed, or unhealthy' "pi extension must identify a blind turn as watcher recovery" - assert_contains "$content" 'harness recovery instruction below' "pi extension must delegate recovery action to the shared guard line" - assert_not_contains "$content" 'Resume supervision according to the session-start operating block' "pi extension must not route a blind turn through ordinary continuity" - assert_contains "$content" '.pi-turnend-extension-loaded' "pi extension must write its loaded marker for session-start diagnostics" - assert_contains "$content" 'lockOwnership' "pi extension loaded marker must respect the session lock" - assert_contains "$content" 'const command = String((event.input as { command?: unknown })?.command ?? "")' "pi extension changed bash command extraction for the PreToolUse contract" - assert_contains "$content" 'runPretoolCheck(command)' "pi extension changed the PreToolUse checker invocation" - assert_contains "$content" 'return { block: true, reason:' "pi extension changed the checker exit-2 block result" - assert_not_contains "$content" 'Run bin/fm-watch-arm.sh as a background task' "pi extension must not hardcode the old watcher-arm instruction" - pass ".pi primary extension: agent_settled forces one follow-up through the shared guard" -} - test_pi_extension_injects_once_per_logical_agent_run() { local repo home ext log out status repo="$TMP_ROOT/pi-logical-run-root" @@ -1177,17 +1104,6 @@ test_hook_claude_mode_secondmate_reblocks_like_primary() { pass "fm-turnend-guard --claude: secondmate home re-blocks unclaimed and allows auto-arm-claimed stops" } -test_grok_hook_invokes_adapter() { - local settings command - settings="$ROOT/.grok/hooks/fm-primary-turnend-guard.json" - [ -f "$settings" ] || fail "tracked grok primary hook config is missing" - command=$(jq -r '.hooks.Stop[0].hooks[0].command // empty' "$settings") - [ -n "$command" ] || fail "Stop hook command is missing from grok primary hook config" - assert_contains "$command" 'GROK_WORKSPACE_ROOT' "grok hook must anchor from GROK_WORKSPACE_ROOT" - assert_contains "$command" 'fm-turnend-guard-grok.sh' "grok hook must invoke the adapter" - pass ".grok primary hook: Stop hook invokes the grok adapter" -} - test_predicate_healthy_no_inflight test_predicate_unhealthy_no_beacon test_predicate_unhealthy_stale_beacon @@ -1224,16 +1140,11 @@ test_grok_adapter_native_true_allows_without_resume test_grok_adapter_snake_case_native_and_camel_precedence test_grok_adapter_invalid_inputs_start_neither_path test_grok_adapter_missing_jq_and_no_supervision_allow -test_settings_hook_uses_claude_project_dir -test_codex_hook_invokes_shared_guard test_codex_hook_uses_process_pwd_when_payload_cwd_is_outside_root test_codex_hook_ignores_nested_git_root_guard -test_opencode_plugin_forces_followup test_opencode_plugin_anchors_guard_to_worktree -test_pi_extension_forces_followup test_pi_extension_injects_once_per_logical_agent_run test_pi_extension_retries_after_followup_delivery_failure -test_grok_hook_invokes_adapter test_hook_claude_mode_reblocks_stop_hook_active_when_unhealthy test_hook_claude_mode_reblocks_x_mode_without_tasks test_hook_claude_mode_allows_when_autoarm_owner_alive diff --git a/tests/no-mistakes-required-workflow.test.sh b/tests/no-mistakes-required-workflow.test.sh deleted file mode 100755 index dc87c9970f..0000000000 --- a/tests/no-mistakes-required-workflow.test.sh +++ /dev/null @@ -1,96 +0,0 @@ -#!/usr/bin/env bash -# Contract and synthetic event replay for the PR body compliance workflow. -# shellcheck disable=SC2016 -set -u - -# shellcheck source=tests/lib.sh -. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" - -WORKFLOW="$ROOT/.github/workflows/no-mistakes-required.yml" -MARKER='Updates from [git push no-mistakes](https://github.com/kunchenguid/no-mistakes)' - -extract_signature_script() { - awk ' - /^ run: \|$/ { capture=1; next } - capture && /^ / { sub(/^ /, ""); print; next } - capture { exit } - ' "$WORKFLOW" -} - -signature_result() { - local body=$1 script - script=$(extract_signature_script) - PR_NUMBER=418 PR_AUTHOR=synthetic-fork-contributor PR_BODY="$body" bash -c "$script" >/dev/null 2>&1 -} - -render_group() { - local action=$1 run_id=$2 - case "$action" in - opened|edited) printf 'no-mistakes-required-418-%s\n' "$run_id" ;; - synchronize|reopened) printf 'no-mistakes-required-418-head-change\n' ;; - esac -} - -render_run_name() { - local action=$1 run_number=$2 run_id=$3 - printf 'PR #418 body compliance - %s - event %s (run %s)\n' "$action" "$run_number" "$run_id" -} - -test_signature_sequence_at_fixed_head() { - signature_result "Synthetic body\n$MARKER" || fail "signed opened event must succeed" - if signature_result 'Synthetic unsigned edit'; then - fail "unsigned edited event must fail" - fi - signature_result "Synthetic signed edit\n$MARKER" || fail "signed edited event must succeed" - pass "fixed-head signed opened, unsigned edited, signed edited yields 0/1/0" -} - -test_event_identity_contract() { - local opened edited_one edited_two synchronize reopened - opened=$(render_group opened 9001) - edited_one=$(render_group edited 9002) - edited_two=$(render_group edited 9003) - synchronize=$(render_group synchronize 9004) - reopened=$(render_group reopened 9005) - [ "$opened" != "$edited_one" ] && [ "$opened" != "$edited_two" ] && [ "$edited_one" != "$edited_two" ] || \ - fail "body events must have distinct immutable groups" - [ "$synchronize" = "$reopened" ] || fail "synchronize and reopened must share head-change" - case "$opened $edited_one $edited_two" in *head-change*) fail "body event reused head-change" ;; esac - - assert_grep "group: no-mistakes-required-\${{ github.event.pull_request.number }}-\${{ (github.event.action == 'opened' || github.event.action == 'edited') && github.run_id || 'head-change' }}" "$WORKFLOW" \ - "workflow does not implement immutable body-event groups" - assert_grep 'cancel-in-progress: true' "$WORKFLOW" "workflow lost cancellation for coalesced head changes" - pass "body event groups are distinct while head changes remain coalesced" -} - -test_run_names_are_ordered_and_unique() { - local first second - first=$(render_run_name edited 73 9002) - second=$(render_run_name edited 74 9003) - [ "$first" = 'PR #418 body compliance - edited - event 73 (run 9002)' ] || fail "first synthetic run name is incomplete" - [ "$second" = 'PR #418 body compliance - edited - event 74 (run 9003)' ] || fail "second synthetic run name is incomplete" - [ "$first" != "$second" ] || fail "distinct events must have unique run names" - assert_grep 'run-name: "PR #${{ github.event.pull_request.number }} body compliance - ${{ github.event.action }} - event ${{ github.run_number }} (run ${{ github.run_id }})"' "$WORKFLOW" \ - "workflow run name does not expose PR, action, monotonic run number, and immutable run ID" - pass "run names expose monotonic numbers and immutable IDs" -} - -test_security_and_signature_contract_is_preserved() { - assert_grep ' pull_request:' "$WORKFLOW" "workflow must use pull_request" - assert_no_grep 'pull_request_target' "$WORKFLOW" "workflow must not use pull_request_target" - assert_grep ' contents: read' "$WORKFLOW" "contents permission must remain read-only" - assert_no_grep 'contents: write' "$WORKFLOW" "workflow must not gain contents write permission" - assert_no_grep 'secrets.' "$WORKFLOW" "workflow must not read secrets" - assert_no_grep 'actions/checkout' "$WORKFLOW" "workflow must not check out fork code" - assert_grep 'name: PR must be raised via no-mistakes' "$WORKFLOW" "stable required check name changed" - assert_grep "$MARKER" "$WORKFLOW" "signature marker changed" - assert_grep "github.event.pull_request.user.login != 'github-actions[bot]'" "$WORKFLOW" "github-actions bot exemption changed" - assert_grep "github.event.pull_request.user.login != 'dependabot[bot]'" "$WORKFLOW" "dependabot bot exemption changed" - assert_no_grep 'release-please[bot]' "$WORKFLOW" "Firstmate must not exempt release-please" - pass "fork, permission, check-name, marker, and bot-exemption contracts are preserved" -} - -test_signature_sequence_at_fixed_head -test_event_identity_contract -test_run_names_are_ordered_and_unique -test_security_and_signature_contract_is_preserved From 56a7ac6b5e8d7bef389a8364bbc2dd77eac89e47 Mon Sep 17 00:00:00 2001 From: Kun Chen <3233006+kunchenguid@users.noreply.github.com> Date: Wed, 29 Jul 2026 20:37:25 -0700 Subject: [PATCH 18/70] fix(watch): escalate busy workers with no completed turn (#1286) * fix(watch): bound how long a busy pane may run with no completed turn A busy pane (backend busy state or the harness's rendered footer) was unconditional, unbounded proof of liveness in every escalation path, so a hung foreground tool call behind a busy signature could run for hours undetected (2026-07 hibit-agent-focus-nonsteal-r1 incident: a catastrophic- backtracking regex hung one bash call for 25h behind an unchanging "Working..." footer). FM_BUSY_TURN_MAX_SECS (default 3600s) now bounds how long a busy pane may run with no completed turn (state/.turn-ended, or its spawn record before any turn has completed). Past the bound, busy_turn_over_age routes the pane through the existing wedge_timer_check, reusing the identical stale reason, escalation counter, and demand-deep-inspection marker for human inspection only - never an automatic interrupt, signal, or restart of the worker or its tool process. A completed turn resets the age. Reproduced end-to-end against the real installed Pi TUI: a foreground `sleep 999999` bash call with no timeout renders the actual busy footer, and two captures ~15s apart show the elapsed counter changing the pane hash while the same turn stays unfinished. Running the pre-fix watcher against the real captures showed it never starts a wedge timer no matter how long the pane stays busy; the fixed watcher starts and escalates the timer through the same mechanism, while the real hung process remained untouched and alive throughout. * no-mistakes(review): fix: parse enriched AFK stale reasons * no-mistakes(review): fix: preserve enriched wedges during AFK supervision * no-mistakes(review): fix: route all enriched AFK wedges * no-mistakes(document): Clarify busy-turn age supervision documentation --- bin/fm-supervise-daemon.sh | 11 +- bin/fm-watch.sh | 68 +++++++-- docs/architecture.md | 1 + docs/configuration.md | 1 + tests/fm-daemon.test.sh | 52 +++++++ tests/fm-watch-triage.test.sh | 275 ++++++++++++++++++++++++++++++++++ 6 files changed, 395 insertions(+), 13 deletions(-) diff --git a/bin/fm-supervise-daemon.sh b/bin/fm-supervise-daemon.sh index 6ec14aed4b..30554edbbd 100755 --- a/bin/fm-supervise-daemon.sh +++ b/bin/fm-supervise-daemon.sh @@ -1181,7 +1181,7 @@ is_wake_reason() { # # --- dispatch one wake reason to self-handle or escalate -------------------- # Side effects: logging, marker records, escalation buffer appends. handle_wake() { # - local reason=$1 state=$2 decision action distilled task last + local reason=$1 state=$2 decision action distilled task last stale_detail local kind="" arg="" if should_force_self "$reason"; then log "wake force-self (FM_INJECT_SKIP): $reason" @@ -1190,8 +1190,13 @@ handle_wake() { # case "$reason" in signal:*) kind=signal; arg="${reason#signal: }" decision=$(classify_signal "$arg" "$state") ;; - stale:*) kind=stale; arg="${reason#stale: }" - decision=$(classify_stale "$arg" "$state") ;; + stale:*) kind=stale; arg="${reason#stale: }"; stale_detail="${arg#"$arg"}" + case "$arg" in *" ("*) stale_detail="${arg#*" ("}"; arg="${arg%% \(*}" ;; esac + decision=$(classify_stale "$arg" "$state") + case "$stale_detail" in + idle\ *s,\ possible\ wedge,\ escalation\ *) + decision="escalate|${reason#stale: }" ;; + esac ;; check:*) decision=$(classify_check "$reason") ;; heartbeat|heartbeat:*) decision=$(classify_heartbeat) ;; *) decision=$(classify_unknown "$reason") ;; diff --git a/bin/fm-watch.sh b/bin/fm-watch.sh index b7006e6362..e5501f852b 100755 --- a/bin/fm-watch.sh +++ b/bin/fm-watch.sh @@ -30,7 +30,16 @@ # also carries a "demand-deep-inspection" marker so the # wake payload itself, not just repetition, forces a # closer look instead of another routine supervision -# resume. Unless afk is active. +# resume. Unless afk is active. A genuinely busy pane +# (window_is_busy true) is exempt from the above, but +# only up to BUSY_TURN_MAX_SECS with no completed turn +# (state/.turn-ended, or the spawn record before any +# turn completes); past that bound busy_turn_over_age +# routes it through the same wedge timer, so it surfaces +# with the identical "stale: ..." reason, escalation +# count, and demand-deep-inspection marker, for human +# inspection only - never an automatic interrupt, +# signal, or restart of the worker or its tool process. # check: