Skip to content
Closed
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
37 changes: 27 additions & 10 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -305,7 +305,7 @@ jobs:
if-no-files-found: warn

macos-stock-bash:
name: Stock macOS Bash snapshot compatibility
name: Stock macOS Bash compatibility
runs-on: macos-latest
timeout-minutes: 10
steps:
Expand All @@ -323,19 +323,36 @@ jobs:
/bin/bash --version | head -1
command -v jq >/dev/null || { echo "::error::jq is required"; exit 1; }

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; }
# bin/fm-lint.sh is the single owner of firstmate's shell-script file
# set, so this sweep consumes its --list output rather than spelling
# its own, and must parse every entry that list publishes.
canonical_scripts=$(bin/fm-lint.sh --list)
expected_scripts=$(printf '%s\n' "$canonical_scripts" | grep -c '[^[:space:]]' || true)
[ "$expected_scripts" -gt 0 ] || {
echo "::error::bin/fm-lint.sh --list published no shell scripts"
exit 1
}
parsed_scripts=0
while IFS= read -r script; do
[ -n "$script" ] || continue
[ -f "$script" ] || {
echo "::error::canonical shell script $script does not exist"
exit 1
}
/bin/bash -n "$script"
parsed_scripts=$((parsed_scripts + 1))
done <<< "$canonical_scripts"
[ "$parsed_scripts" -eq "$expected_scripts" ] || {
echo "::error::Bash 3.2 parsed $parsed_scripts of $expected_scripts canonical shell scripts"
exit 1
}
printf 'Bash 3.2 parsed %s canonical shell scripts\n' "$parsed_scripts"

snapshot_output=$(/bin/bash tests/fm-fleet-snapshot-view.test.sh)
printf '%s\n' "$snapshot_output"
snapshot_count=$(printf '%s\n' "$snapshot_output" | grep -c '^ok - ')
[ "$snapshot_count" -eq 15 ] || {
echo "::error::expected 15 snapshot/fleet-view tests, got $snapshot_count"
[ "$snapshot_count" -eq 16 ] || {
echo "::error::expected 16 snapshot/fleet-view tests, got $snapshot_count"
exit 1
}

Expand Down
8 changes: 6 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,11 @@ 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.
Stock macOS `/bin/bash` 3.2 is the supported floor for both, so avoid Bash 4+ constructs such as associative arrays and `${var,,}`/`${var^^}`, keep `$BASHPID` in its guarded `${BASHPID:-$$}` form, and never build a here-document inside a command substitution (`VAR=$(cat <<EOF ...)`), which 3.2 mis-parses even with a quoted delimiter.
Emit the body from a function and assign its output, or read the here-document straight into the variable with `IFS= read -r -d '' VAR <<EOF || true`; `tests/fm-lint.test.sh` fails any `bin/` script that reintroduces the construct, and CI's stock-Bash lane parses the whole canonical set through the real 3.2 binary.
`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.
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.
Any other gate that must iterate the same scripts consumes `bin/fm-lint.sh --list` (the canonical file set, one path per line) instead of re-spelling the globs.
- 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).
- [`docs/documentation-audiences.md`](docs/documentation-audiences.md) and its machine-consumed inventory own prose classification; run `bin/fm-doc-audience-check.sh` after documentation changes.
Expand All @@ -71,7 +74,7 @@ That is firstmate-specific; do not commit `.no-mistakes/evidence/` here even whe
Check and test the toolbelt before pushing:

```sh
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 --list | while IFS= read -r script; do /bin/bash -n "$script"; done # syntax-check the canonical file set on the 3.2 floor: /bin/bash, never PATH bash, which is Homebrew 5.x on most Macs
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/<subject>.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)
Expand All @@ -94,9 +97,10 @@ 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).
That macOS lane parses every path `bin/fm-lint.sh --list` publishes through the real `/bin/bash` 3.2, requires an exact parsed-file count, and then runs the fleet snapshot/view and Bearings suites under the same interpreter.
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.
Tests that need a real optional backend, an external dependency the product itself requires, or an explicit opt-in (real herdr/zellij/cmux smoke tests, the Kimi turn-end harness's `python3` with `tomllib`, 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.
The [Herdr backend guide](docs/herdr-backend.md#destructive-lab-safety) owns the lane's isolation boundary, while [runtime backend verification](docs/verification/runtime-backends.md#herdr) owns active empirical evidence; live harness credential tests remain opt-in.

## Questions
Expand Down
9 changes: 1 addition & 8 deletions bin/fm-bootstrap.sh
Original file line number Diff line number Diff line change
Expand Up @@ -708,14 +708,7 @@ x_mode_setup() {
fmx_poll_shim_valid "$shim" "$shim_home" "$FM_ROOT" \
|| { fmx_arm_failed; return 0; }

cadence_body=$(cat <<'EOF'
# 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=$(fmx_cadence_content)
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
77 changes: 49 additions & 28 deletions bin/fm-fleet-snapshot.sh
Original file line number Diff line number Diff line change
Expand Up @@ -770,21 +770,14 @@ else
file_mode_octal() { stat -c '%a' "$1" 2>/dev/null || true; }
fi

registry_secondmates_json() {
local reg="$DATA/secondmates.md" out rc reason mode script parse_filter output_filter
if [ ! -f "$reg" ]; then
jq -n --arg path "$reg" --arg observed "$SNAPSHOT_NOW" \
'{present:false,available:true,complete:true,reason:null,provenance:"registered-table",path:$path,freshness:{status:"fresh",observed_at:$observed},records:[],input_truncated:false,records_truncated:false,reasons:[],lines_in_window:0,records_in_window:0}'
return 0
fi
mode=$(file_mode_octal "$reg")
if [ -z "$mode" ] || [ $((8#$mode & 0444)) -eq 0 ]; then
jq -n --arg path "$reg" --arg observed "$SNAPSHOT_NOW" \
--arg reason "registered secondmate table is unreadable" \
'{present:true,available:false,complete:false,reason:$reason,provenance:"registered-table",path:$path,freshness:{status:"unavailable",observed_at:$observed},records:[],input_truncated:false,records_truncated:false,reasons:[$reason],lines_in_window:0,records_in_window:0}'
return 0
fi
script=$(cat <<'BASH'
# Each bounded reader body and jq filter is emitted by a function instead of
# an inline `VAR=$(cat <<'EOF' ...)`. Stock macOS Bash 3.2 keeps tracking
# quote state through a here-document while it scans for the closing `)` of a
# command substitution, so one apostrophe in any body below would break
# parsing of this whole file (issue #166); a function body is outside that
# scan.
registry_reader_script() {
cat <<'BASH'
f=$1
max_lines=$2
max_bytes=$3
Expand Down Expand Up @@ -833,8 +826,10 @@ registry_secondmates_json() {
--argjson records_in_window "$records_in_window" \
--argjson max_records "$max_records" "$output_filter"
BASH
)
parse_filter=$(cat <<'JQ'
}

registry_parse_filter() {
cat <<'JQ'
[ inputs
| select(startswith("- "))
| (capture("^- (?<id>[^[:space:]]+)")?) as $id
Expand All @@ -845,8 +840,10 @@ BASH
| group_by(.id)
| map(if length > 1 then .[0] + {registry_error:"duplicate secondmate id in registry"} else .[0] end)
JQ
)
output_filter=$(cat <<'JQ'
}

registry_output_filter() {
cat <<'JQ'
{present:true,available:true,reason:null,provenance:"registered-table",path:$path,
freshness:{status:"fresh",observed_at:$observed},
records:(if length > $max_records then .[:$max_records] else . end),
Expand All @@ -858,7 +855,26 @@ JQ
(if $records_truncated then "record_limit" else empty end)
],lines_in_window:$lines_in_window,records_in_window:$records_in_window}
JQ
)
}

registry_secondmates_json() {
local reg="$DATA/secondmates.md" out rc reason mode script parse_filter output_filter
if [ ! -f "$reg" ]; then
jq -n --arg path "$reg" --arg observed "$SNAPSHOT_NOW" \
'{present:false,available:true,complete:true,reason:null,provenance:"registered-table",path:$path,freshness:{status:"fresh",observed_at:$observed},records:[],input_truncated:false,records_truncated:false,reasons:[],lines_in_window:0,records_in_window:0}'
return 0
fi
mode=$(file_mode_octal "$reg")
if [ -z "$mode" ] || [ $((8#$mode & 0444)) -eq 0 ]; then
jq -n --arg path "$reg" --arg observed "$SNAPSHOT_NOW" \
--arg reason "registered secondmate table is unreadable" \
'{present:true,available:false,complete:false,reason:$reason,provenance:"registered-table",path:$path,freshness:{status:"unavailable",observed_at:$observed},records:[],input_truncated:false,records_truncated:false,reasons:[$reason],lines_in_window:0,records_in_window:0}'
return 0
fi
script=$(registry_reader_script)
parse_filter=$(registry_parse_filter)
output_filter=$(registry_output_filter)

out=$(run_timed "$FM_SNAPSHOT_REGISTRY_TIMEOUT" bash -c "$script" \
fm-secondmate-registry "$reg" "$FM_SNAPSHOT_REGISTRY_LINES" \
"$FM_SNAPSHOT_REGISTRY_BYTES" "$FM_SNAPSHOT_REGISTRY_RECORDS" "$reg" "$SNAPSHOT_NOW" \
Expand All @@ -876,13 +892,9 @@ JQ
'{present:true,available:false,complete:false,reason:$reason,provenance:"registered-table",path:$path,freshness:{status:"unavailable",observed_at:$observed},records:[],input_truncated:false,records_truncated:false,reasons:[$reason],lines_in_window:0,records_in_window:0}'
}

bounded_parent_activities_json() { # <status-file>
local f=$1 out rc reason script
if [ ! -f "$f" ]; then
jq -n '{records:[],available:true,input_truncated:false,retained_truncated:false,reasons:[],lines_in_window:0,records_in_window:0}'
return 0
fi
script=$(cat <<'BASH'
# Function-wrapped for the same Bash 3.2 reason as the registry reader above.
parent_activities_script() {
cat <<'BASH'
classify=$1
f=$2
max_lines=$3
Expand Down Expand Up @@ -945,7 +957,16 @@ bounded_parent_activities_json() { # <status-file>
lines_in_window:$lines_in_window,
records_in_window:$records_in_window}'
BASH
)
}

bounded_parent_activities_json() { # <status-file>
local f=$1 out rc reason script
if [ ! -f "$f" ]; then
jq -n '{records:[],available:true,input_truncated:false,retained_truncated:false,reasons:[],lines_in_window:0,records_in_window:0}'
return 0
fi
script=$(parent_activities_script)

out=$(run_timed "$FM_SNAPSHOT_PARENT_ACTIVITY_TIMEOUT" bash -c "$script" \
fm-parent-activities "$SCRIPT_DIR/fm-classify-lib.sh" "$f" \
"$FM_SNAPSHOT_PARENT_ACTIVITY_LINES" "$FM_SNAPSHOT_PARENT_ACTIVITY_BYTES" \
Expand Down
46 changes: 24 additions & 22 deletions bin/fm-lint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
# fm-lint.sh --jobs <1|2> [path]... override bounded worker count
# fm-lint.sh --telemetry <path> ... 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 --list print the canonical file set, one per line
# fm-lint.sh --help print this usage
set -u

Expand All @@ -32,6 +32,12 @@ SELF="$SELF_DIR/fm-lint.sh"
ROOT="$(cd "$SELF_DIR/.." && pwd)"
cd "$ROOT" || exit 1

# The canonical file set, spelled exactly once. Both consumers below - `--list`
# for every other gate, and the lint run's own roots - expand this array, so the
# published set and the linted set cannot drift by construction. Every adapter
# and test shell stays an independent root.
CANONICAL_ROOTS=(bin/*.sh bin/backends/*.sh tests/*.sh)

FM_LINT_WORKER_SHELLCHECK_PID=
# shellcheck disable=SC2329 # Registered by the private worker's signal traps.
fm_lint_worker_stop() {
Expand Down Expand Up @@ -83,13 +89,21 @@ if [ "${1:-}" = "--required-version" ]; then
exit 0
fi

# Publish the canonical file set without needing ShellCheck installed, so every
# other gate that must iterate firstmate's shell scripts consumes this owner's
# definition instead of spelling its own. tests/fm-lint.test.sh asserts this
# output matches the set the lint run below executes.
if [ "${1:-}" = "--list" ]; then
printf '%s\n' "${CANONICAL_ROOTS[@]}"
exit 0
fi

fm_lint_usage() {
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)
Expand All @@ -110,10 +124,6 @@ while [ "$#" -gt 0 ]; do
TELEMETRY=${1#*=}
shift
;;
--list-files)
LIST_FILES=1
shift
;;
--help|-h)
fm_lint_usage
exit 0
Expand All @@ -131,22 +141,7 @@ 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

# Enforce the pin so local and CI resolve the identical rule set.
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
Expand All @@ -166,6 +161,13 @@ if [ "$resolved" != "$REQUIRED_SHELLCHECK" ]; then
exit 1
fi

if [ "$#" -gt 0 ]; then
ROOTS=("$@")
else
ROOTS=("${CANONICAL_ROOTS[@]}")
fi
ROOT_COUNT=${#ROOTS[@]}

if [ -n "$TELEMETRY" ]; then
telemetry_parent=$(dirname "$TELEMETRY")
[ -d "$telemetry_parent" ] || {
Expand Down
13 changes: 11 additions & 2 deletions bin/fm-spawn.sh
Original file line number Diff line number Diff line change
Expand Up @@ -1605,7 +1605,7 @@ fi

META_WINDOW=$T
[ "$BACKEND" = orca ] && META_WINDOW=$W
{
write_spawn_metadata() {
echo "window=$META_WINDOW"
echo "endpoint_task_id=$ID"
echo "worktree=$WT"
Expand Down Expand Up @@ -1645,7 +1645,16 @@ META_WINDOW=$T
echo "home=$PROJ_ABS"
echo "projects=$SECONDMATE_PROJECTS"
fi
} > "$STATE/$ID.meta"
}
# Bash 3.2 does not reliably apply `set -e` when a brace-group redirection
# fails. Render the metadata first, then publish it with a single write whose
# status covers both a failed open and a failed (short) write, so metadata
# publication failure always aborts the spawn and preserves the cleanup path.
META_BODY=$(write_spawn_metadata)
printf '%s\n' "$META_BODY" > "$STATE/$ID.meta" || {
echo "error: could not publish task metadata at $STATE/$ID.meta" >&2
exit 1
}
[ "$BACKEND" = orca ] && ORCA_ABORT_CLEANUP=0

sq_brief=$(shell_quote "$BRIEF")
Expand Down
14 changes: 14 additions & 0 deletions bin/fm-x-lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,20 @@ fmx_poll_shim_content() {
"exec $(printf '%q' "$root/bin/fm-x-poll.sh")"
}

# The generated config/x-mode.env body. It lives in a function rather than an
# inline `VAR=$(cat <<EOF ...)` because stock macOS Bash 3.2 mis-parses a
# here-document inside a command substitution as soon as the body gains an
# apostrophe, which would break the whole file (issue #166).
fmx_cadence_content() {
cat <<'EOF'
# 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
}

fmx_poll_shim_v1_content() {
local home=$1 root=$2
printf '%s\n' \
Expand Down
2 changes: 2 additions & 0 deletions tests/fm-backend-orca.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -687,6 +687,8 @@ test_spawn_releases_orca_resources_when_metadata_write_fails() {
status=$?
[ "$status" -ne 0 ] || fail "Orca spawn should fail when metadata cannot be written"
assert_contains "$out" "Is a directory" "spawn should fail at metadata publication"
assert_contains "$out" "could not publish task metadata" "spawn should report the failed metadata publication"
assert_not_contains "$out" "spawned $id" "spawn must not report success after metadata publication fails"
assert_contains "$(cat "$LOG")" $'orca\x1f''terminal'$'\x1f''close'$'\x1f''--terminal'$'\x1f''term-meta-fail'$'\x1f''--json' \
"Orca spawn should close the recorded terminal when a later abort occurs"
assert_contains "$(cat "$LOG")" $'orca\x1f''worktree'$'\x1f''rm'$'\x1f''--worktree'$'\x1f''id:wt-meta-fail'$'\x1f''--force'$'\x1f''--json' \
Expand Down
Loading