Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .agents/skills/firstmate-coding-guidelines/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,8 @@ Run `bin/fm-doc-audience-check.sh`; it enforces classification, README setup rou
- Plain dash `-`, never an em dash.
- Never add an agent name as a commit co-author.
- `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.
- The same file set must also parse under stock macOS Bash 3.2 (`/bin/bash -n`), which `bin/fm-lint.sh` sweeps under whatever `/bin/bash` is present and CI's macOS lane annotates per file on a real 3.2; shellcheck and Bash 5 accept constructs 3.2 rejects, so neither a clean shellcheck run nor a green sweep on a Bash 5 `/bin/bash` proves a script is usable on macOS.
- Run `bin/fm-lint.sh` before treating a script change as done; it is the single owner of the lint definition (file set, config, pinned shellcheck version, and the stock Bash 3.2 parse sweep) 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 `<subject>.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.
Expand Down
3 changes: 2 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ See the [no-mistakes quick start](https://kunchenguid.github.io/no-mistakes/star
- Helper scripts in `bin/` are plain bash.
Each starts with a usage header comment; keep it accurate when you change behavior.
Test scripts and helpers in `tests/` are plain bash too.
`bin/fm-lint.sh` must pass: it is the single owner of the lint definition (the shellcheck file set, config, and pinned shellcheck version), and both CI and the no-mistakes pre-push gate run it, so local and CI can never diverge.
`bin/fm-lint.sh` must pass: it is the single owner of the lint definition (the shellcheck file set, config, pinned shellcheck version, and the stock Bash 3.2 parse sweep), and both CI and the no-mistakes pre-push gate run it, so local and CI can never diverge.
It pins one exact shellcheck version and refuses to run under any other; print it with `bin/fm-lint.sh --required-version` and install that build locally.
- Changes to harness adapters (detection in `bin/fm-harness.sh`, launch and hook mechanics in `bin/fm-spawn.sh`, semantic busy sources and trust gates in `bin/fm-busy-lib.sh`, delivery-only rendered guards in `bin/fm-tmux-lib.sh`, cleanup in `bin/fm-teardown.sh`, and facts in `.agents/skills/harness-adapters/SKILL.md`) must be verified empirically against the real harness, never written from documentation alone.
- Changes to runtime session backends (`bin/fm-backend.sh`, `bin/backends/`, and the scripts that dispatch through them) keep current setup and limits in the relevant backend guide and active empirical evidence in [`docs/verification/runtime-backends.md`](docs/verification/runtime-backends.md).
Expand Down Expand Up @@ -94,6 +94,7 @@ 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 stock macOS Bash compatibility in [`.github/workflows/ci.yml`](.github/workflows/ci.yml).
`bin/fm-lint.sh` runs the same parse sweep under whatever `/bin/bash` is present, skipping it only where `/bin/bash` is absent, so the local lint and pre-push gate state that invariant too instead of leaving it to CI alone; on Linux `/bin/bash` is typically Bash 5, so a green local sweep does not by itself prove the 3.2 invariant and the macOS lane above remains what proves it.
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 `<subject>.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.
Expand Down
4 changes: 2 additions & 2 deletions bin/fm-bootstrap.sh
Original file line number Diff line number Diff line change
Expand Up @@ -708,14 +708,14 @@ x_mode_setup() {
fmx_poll_shim_valid "$shim" "$shim_home" "$FM_ROOT" \
|| { fmx_arm_failed; return 0; }

cadence_body=$(cat <<'EOF'
IFS= read -r -d '' cadence_body <<'EOF' || true
# Auto-generated by fm-bootstrap.sh - X mode watcher cadence.
# Source this before the active harness protocol starts a watcher process so
# fm-watch.sh polls the X check every 30s. Non-X instances have no such file and
# keep the default 300s cadence.
export FM_CHECK_INTERVAL=30
EOF
)
cadence_body=${cadence_body%$'\n'}
x_mode_write_if_changed "$cadence" "$cadence_body" 600 || { fmx_arm_failed; return 0; }

echo "FMX: X mode on - relay poll armed via state/x-watch.check.sh; 30s watcher cadence in config/x-mode.env"
Expand Down
11 changes: 11 additions & 0 deletions bin/fm-brief.sh
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,17 @@ fi
# Ship task: shape Setup / Rule 1 / Definition of done by the project's delivery mode.
# yolo does not affect the brief because the worker never owns approval decisions;
# firstmate applies the authority contract in AGENTS.md section 7, so discard it.
#
# The Definition-of-done bodies below are captured with `read -r -d ''`, not with
# `DOD=$(cat <<EOF ... EOF)`. Stock macOS Bash 3.2 tracks quote state through a
# heredoc body while scanning for the closing `)` of a command substitution, so a
# single apostrophe in prose there ("firstmate's authority check") makes `bash -n`
# reject the whole file with "unexpected EOF while looking for matching `''" - the
# script becomes unusable on macOS while Bash 5 parses it fine (issue #166).
# Keep these blocks free of command substitution so prose can contain apostrophes.
# `read` returns 1 at EOF when the NUL delimiter is never found, hence `|| true`
# under `set -e`; the single trailing newline it keeps is stripped after the case,
# matching what `$(...)` used to strip.
read -r MODE _ <<EOF
$("$FM_ROOT/bin/fm-project-mode.sh" "$REPO")
EOF
Expand Down
46 changes: 45 additions & 1 deletion bin/fm-lint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@
# Optional quiet telemetry writes one bounded TSV snapshot of content and source
# graph identity, wall/CPU/RSS, shard load, and competing ShellCheck processes.
#
# The same run also parses every bin root under whatever /bin/bash is, because
# Bash 3.2 rejects constructs ShellCheck and Bash 5 accept, and skips that sweep
# only where /bin/bash is absent. On Linux /bin/bash is typically Bash 5, so a
# green sweep there does not prove the 3.2 invariant; CI's macOS lane does.
#
# Usage:
# fm-lint.sh lint the canonical file set
# fm-lint.sh <path>... lint explicit roots with the same config
Expand Down Expand Up @@ -84,7 +89,7 @@ if [ "${1:-}" = "--required-version" ]; then
fi

fm_lint_usage() {
sed -n '2,26{s/^# \{0,1\}//;p;}' "$SELF"
sed -n '2,31{s/^# \{0,1\}//;p;}' "$SELF"
}

JOBS=${FM_LINT_JOBS:-2}
Expand Down Expand Up @@ -166,6 +171,41 @@ if [ "$resolved" != "$REQUIRED_SHELLCHECK" ]; then
exit 1
fi

# Stock macOS Bash 3.2 rejects constructs ShellCheck and Bash 5 accept (issue
# #166: an apostrophe in a heredoc nested in $(...)), so the same owner that
# defines the lint file set also parses every shell root under /bin/bash. The
# sweep is skipped only where /bin/bash is absent; where it exists it runs
# whatever version that is, so on Linux (typically Bash 5) a green sweep does
# not by itself prove the 3.2 invariant - CI's macOS lane is what proves it.
# That lane takes its file set from --list-files, so the sweep covers the same
# inventory rather than a narrower glob: a tests/ root that only 3.2 rejects
# would otherwise pass this gate and fail that lane.
# Selection is by repo-relative location, never by how the argument was spelled,
# so a linted root can never silently skip the parse check.
PARSE_RC=0
if [ -x /bin/bash ]; then
for path in "${ROOTS[@]}"; do
rel=$path
case "$rel" in
"$ROOT"/*) rel=${rel#"$ROOT/"} ;;
esac
while :; do
case "$rel" in
./*) rel=${rel#./} ;;
*) break ;;
esac
done
case "$rel" in
*.sh) [ -f "$path" ] || continue ;;
*) continue ;;
esac
/bin/bash -n "$path" || {
printf 'fm-lint.sh: %s does not parse under /bin/bash.\n' "$rel" >&2
PARSE_RC=1
}
done
fi

if [ -n "$TELEMETRY" ]; then
telemetry_parent=$(dirname "$TELEMETRY")
[ -d "$telemetry_parent" ] || {
Expand Down Expand Up @@ -356,6 +396,10 @@ while [ "$worker" -lt "$SHARD_COUNT" ]; do
worker=$((worker + 1))
done

if [ "$overall_rc" -eq 0 ] && [ "$PARSE_RC" -ne 0 ]; then
overall_rc=$PARSE_RC
fi

if [ -n "$TELEMETRY" ]; then
TELEMETRY_END_EPOCH=$(date +%s)
TELEMETRY_SHELLCHECK_END=$(fm_lint_shellcheck_count)
Expand Down
32 changes: 32 additions & 0 deletions tests/fm-backend.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -959,6 +959,37 @@ 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
Expand Down Expand Up @@ -1154,6 +1185,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
Expand Down
11 changes: 7 additions & 4 deletions tests/fm-brief.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,13 @@ mkdir -p "$BRIEF_HOME/data"
# is a weak guard on its own; test_no_heredoc_in_command_substitution and the
# macos-stock-bash CI job carry the real cross-version enforcement.
test_script_parses() {
local out rc
out=$(bash -n "$ROOT/bin/fm-brief.sh" 2>&1); rc=$?
expect_code 0 "$rc" "bash -n bin/fm-brief.sh must parse cleanly (got: $out)"
[ -z "$out" ] || fail "bash -n bin/fm-brief.sh emitted unexpected output: $out"
local out rc shell
for shell in bash /bin/bash; do
[ -x "$shell" ] || command -v "$shell" >/dev/null || continue
out=$("$shell" -n "$ROOT/bin/fm-brief.sh" 2>&1); rc=$?
expect_code 0 "$rc" "$shell -n bin/fm-brief.sh must parse cleanly (got: $out)"
[ -z "$out" ] || fail "$shell -n bin/fm-brief.sh emitted unexpected output: $out"
done
pass "fm-brief.sh: bash -n succeeds"
}

Expand Down
113 changes: 113 additions & 0 deletions tests/fm-lint.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,116 @@ pinned_ready() {
[ "$(shellcheck --version | awk '/^version:/ {print $2; exit}')" = "$REQUIRED" ]
}

# True only when /bin/bash really is the stock macOS Bash 3.2. The sweep runs
# under whatever /bin/bash is, so an assertion about a construct only 3.2
# rejects is valid only here; on a Bash 5 /bin/bash the same probe parses fine.
stock_bash_is_32() {
[ -x /bin/bash ] || return 1
case $(/bin/bash --version 2>/dev/null | head -1) in
*"version 3.2"*) return 0 ;;
*) return 1 ;;
esac
}

# The probe lives inside the repo because the sweep selects by repo-relative
# location, so cleanup is registered with the repo's EXIT-trap convention
# BEFORE the non-parsing file exists: an interrupted run must never leave a
# file that would poison every later lint run and the CI parse loop. The
# dot-prefixed directory keeps it out of the canonical `*.sh` globs even if it
# did leak, while the case pattern still matches it across `/`.
# Assigns FM_LINT_PROBE_DIR rather than echoing it: registering the EXIT trap
# inside a command substitution would arm it in the subshell, whose exit would
# delete the directory before the caller could use it.
FM_LINT_PROBE_DIR=
fm_lint_probe_dir() { # [parent-dir-under-repo]
FM_LINT_PROBE_DIR=$(mktemp -d "$ROOT/${1:-bin}/.fm-lint-probe.XXXXXX") || return 1
if [ "${#FM_TEST_CLEANUP_DIRS[@]}" -eq 0 ]; then
trap fm_test_cleanup EXIT
fi
FM_TEST_CLEANUP_DIRS+=("$FM_LINT_PROBE_DIR")
}

# Path-normalization coverage that holds on every bash: an unterminated `if`
# fails to parse under 3.2 and 5 alike, so this runs on the portable Linux
# shards too and is what actually guards the repo-relative selection.
test_parse_sweep_selects_a_linted_root_however_spelled() {
local tmp probe rel rc out
[ -x /bin/bash ] || { pass "SKIP (no /bin/bash): parse sweep path-spelling check"; return 0; }
pinned_ready || { pass "SKIP (ShellCheck $REQUIRED not resolved): parse sweep path-spelling check"; return 0; }
fm_lint_probe_dir || fail "could not create the probe directory"
tmp=$FM_LINT_PROBE_DIR
probe="$tmp/probe.sh"
{
printf '#!/usr/bin/env bash\n'
printf 'if true; then\n'
printf ' :\n'
} > "$probe"
rel=${probe#"$ROOT/"}
# An explicit bin root keeps the probe scoped: the sweep still covers it
# because it matches the bin file set, without relinting the whole tree.
# A linted root that skipped the parse check would report a clean run it
# never verified, so all three spellings must reach the same sweep.
for spelling in "$rel" "$probe" "./$rel"; do
out=$(cd "$ROOT" && "$LINT" "$spelling" 2>&1); rc=$?
[ "$rc" -ne 0 ] || fail "fm-lint.sh must fail for a non-parsing bin root spelled $spelling"
assert_contains "$out" "$rel does not parse under /bin/bash" \
"the spelling $spelling must resolve to the repo-relative root the sweep names"
done
pass "fm-lint.sh sweeps a linted bin root however the path is spelled"
}

# The 3.2-only half: proves the sweep rejects the exact construct issue #166 is
# about, rather than passing vacuously. Bash 5 parses this probe fine, so this
# assertion is only meaningful where /bin/bash is the stock 3.2 shell.
test_parse_sweep_rejects_a_bash32_only_defect() {
local tmp probe rel rc out
pinned_ready || { pass "SKIP (ShellCheck $REQUIRED not resolved): Bash 3.2 parse sweep rejection"; return 0; }
stock_bash_is_32 || {
pass "SKIP (/bin/bash is not stock Bash 3.2; run on macOS to enable): Bash 3.2 parse sweep rejection"
return 0
}
fm_lint_probe_dir || fail "could not create the probe directory"
tmp=$FM_LINT_PROBE_DIR
probe="$tmp/probe.sh"
{
printf '#!/usr/bin/env bash\n'
# shellcheck disable=SC2016 # the probe body is literal shell source, not an expansion
printf 'BODY=$(cat <<EOF\n'
printf "the crew's lab\n"
printf 'EOF\n)\n'
# shellcheck disable=SC2016 # the probe body is literal shell source, not an expansion
printf 'printf %%s "$BODY"\n'
} > "$probe"
rel=${probe#"$ROOT/"}
out=$(cd "$ROOT" && "$LINT" "$rel" 2>&1); rc=$?
[ "$rc" -ne 0 ] || fail "fm-lint.sh must fail on the heredoc-in-\$() defect Bash 3.2 rejects"
assert_contains "$out" "$rel does not parse under /bin/bash" "fm-lint.sh must name the file that fails the 3.2 parse"
pass "fm-lint.sh fails on a Bash 3.2-only parse defect ShellCheck accepts"
}

# The gate and the CI macOS lane must hold this rule over one file set. That
# lane parses whatever --list-files reports, which includes tests/, so a sweep
# scoped to the bin roots alone would pass here and fail there on the same file.
test_parse_sweep_covers_the_whole_owner_inventory() {
local tmp probe rel rc out
[ -x /bin/bash ] || { pass "SKIP (no /bin/bash): parse sweep inventory coverage"; return 0; }
pinned_ready || { pass "SKIP (ShellCheck $REQUIRED not resolved): parse sweep inventory coverage"; return 0; }
fm_lint_probe_dir tests || fail "could not create the probe directory"
tmp=$FM_LINT_PROBE_DIR
probe="$tmp/probe.sh"
{
printf '#!/usr/bin/env bash\n'
printf 'if true; then\n'
printf ' :\n'
} > "$probe"
rel=${probe#"$ROOT/"}
out=$(cd "$ROOT" && "$LINT" "$rel" 2>&1); rc=$?
[ "$rc" -ne 0 ] || fail "the parse sweep must cover every inventoried root, not only the bin roots"
assert_contains "$out" "$rel does not parse under /bin/bash" \
"the sweep must name the inventoried tests/ root that fails to parse"
pass "the parse sweep covers the same inventory the CI macOS lane reads"
}

test_list_files_reports_the_shell_inventory() {
local listed expected
listed=$("$LINT" --list-files)
Expand Down Expand Up @@ -426,6 +536,9 @@ SH
pass "seeded dispatcher, adapter, production-owner, and test-local diagnostics preserve parity"
}

test_parse_sweep_selects_a_linted_root_however_spelled
test_parse_sweep_rejects_a_bash32_only_defect
test_parse_sweep_covers_the_whole_owner_inventory
test_list_files_reports_the_shell_inventory
test_pins_an_explicit_version
test_installer_retries_transient_download_failure
Expand Down
Loading