From 4b5ed0d0904e581da78f75de1a8a544c552bd97d Mon Sep 17 00:00:00 2001 From: Idris Hassan <294209834+idrishassan-ai@users.noreply.github.com> Date: Sat, 4 Jul 2026 16:27:37 -0400 Subject: [PATCH 1/4] fix(brief): make DOD heredocs immune to bash-3.2 quote tracking The three ship-mode Definition-of-done blocks in bin/fm-brief.sh were assigned via DOD=$(cat < unexpected EOF while looking for matching `'' syntax error: unexpected end of file exit=2 PR #173 fixed the immediate failure by rewording the apostrophe away, but left the fragile construct in place: any future apostrophe in a DOD body re-breaks every ship brief on macOS, and Linux CI (bash 4+) parses the construct fine, so `bash -n` there can never catch a regression. Fix per the brief-wording contract (wording is deliberate; prefer a structural fix over rewording): assign each DOD with read -r -d '' DOD < file < file <&1 || fail "bin/fm-brief.sh fails bash -n (heredoc/quote regression)" + if [ -x /bin/bash ]; then + /bin/bash -n "$ROOT/bin/fm-brief.sh" 2>&1 || fail "bin/fm-brief.sh fails /bin/bash -n (bash 3.2 heredoc/quote regression)" + fi pass "fm-brief.sh: bash -n succeeds" } +# Structural guard for issue #166: heredocs nested inside \$(cat <<...) are a +# bash-3.2 parse trap that Linux CI's modern bash cannot detect via `bash -n`, +# so refuse the construct itself anywhere in fm-brief.sh. +test_no_heredoc_in_command_substitution() { + if grep -n '^[^#]*\$(cat <<' "$ROOT/bin/fm-brief.sh"; then + fail "fm-brief.sh reintroduced \$(cat <<...) - breaks bash 3.2 parsing if the body ever gains an apostrophe; assign with read -r -d '' instead" + fi + pass "fm-brief.sh: no heredoc-in-command-substitution construct" +} + # Registry with one project per delivery mode, so each ship-mode DOD branch is # exercised. A project absent from the registry defaults to no-mistakes. write_registry() { @@ -77,5 +93,6 @@ test_no_mistakes_dod_wording() { } test_script_parses +test_no_heredoc_in_command_substitution test_ship_modes_generate_clean_briefs test_no_mistakes_dod_wording From 09e952346e91753a22281fee1f18b30c0c785557 Mon Sep 17 00:00:00 2001 From: Idris Hassan <294209834+idrishassan-ai@users.noreply.github.com> Date: Sat, 4 Jul 2026 16:44:11 -0400 Subject: [PATCH 2/4] no-mistakes(review): harden bootstrap heredocs against bash-3.2, widen guard to bin/ --- bin/fm-bootstrap.sh | 6 ++---- tests/fm-brief.test.sh | 13 ++++++++----- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/bin/fm-bootstrap.sh b/bin/fm-bootstrap.sh index c28814dd..c9b57ef8 100755 --- a/bin/fm-bootstrap.sh +++ b/bin/fm-bootstrap.sh @@ -295,25 +295,23 @@ x_mode_setup() { mkdir -p "$STATE" "$CONFIG" 2>/dev/null || { fmx_arm_failed; return 0; } - shim_body=$(cat </dev/null || { fmx_arm_failed; return 0; } - cadence_body=$(cat <<'EOF' + read -r -d '' cadence_body <<'EOF' || true # Auto-generated by fm-bootstrap.sh - X mode watcher cadence. # Source this before arming the watcher (see AGENTS.md "X mode") 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 -) write_if_changed "$cadence" "$cadence_body" || { 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" diff --git a/tests/fm-brief.test.sh b/tests/fm-brief.test.sh index 98e98b64..9c68c3c1 100755 --- a/tests/fm-brief.test.sh +++ b/tests/fm-brief.test.sh @@ -32,12 +32,15 @@ test_script_parses() { # Structural guard for issue #166: heredocs nested inside \$(cat <<...) are a # bash-3.2 parse trap that Linux CI's modern bash cannot detect via `bash -n`, -# so refuse the construct itself anywhere in fm-brief.sh. +# so refuse the construct itself anywhere in bin/ (including bin/backends/). test_no_heredoc_in_command_substitution() { - if grep -n '^[^#]*\$(cat <<' "$ROOT/bin/fm-brief.sh"; then - fail "fm-brief.sh reintroduced \$(cat <<...) - breaks bash 3.2 parsing if the body ever gains an apostrophe; assign with read -r -d '' instead" - fi - pass "fm-brief.sh: no heredoc-in-command-substitution construct" + local script + for script in "$ROOT"/bin/*.sh "$ROOT"/bin/backends/*.sh; do + if grep -n '^[^#]*\$(cat <<' "$script"; then + fail "${script#"$ROOT"/} uses \$(cat <<...) - breaks bash 3.2 parsing if the body ever gains an apostrophe; assign with read -r -d '' instead" + fi + done + pass "bin/*.sh, bin/backends/*.sh: no heredoc-in-command-substitution construct" } # Registry with one project per delivery mode, so each ship-mode DOD branch is From ef71266b3a58ed7c5ebf0be3a151f6ed763848a5 Mon Sep 17 00:00:00 2001 From: Idris Hassan <294209834+idrishassan-ai@users.noreply.github.com> Date: Sun, 5 Jul 2026 08:33:34 -0400 Subject: [PATCH 3/4] no-mistakes(document): document bash-3.2 heredoc convention and updated test guard --- .agents/skills/firstmate-coding-guidelines/SKILL.md | 3 +++ CONTRIBUTING.md | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/.agents/skills/firstmate-coding-guidelines/SKILL.md b/.agents/skills/firstmate-coding-guidelines/SKILL.md index 83ab1d2a..ef47bca5 100644 --- a/.agents/skills/firstmate-coding-guidelines/SKILL.md +++ b/.agents/skills/firstmate-coding-guidelines/SKILL.md @@ -74,6 +74,9 @@ Firstmate adds this skill's load instruction to firstmate-repo briefs by hand in - Never add an agent name as a commit co-author. - `bin/*.sh` and `bin/backends/*.sh` must pass `shellcheck`. - Run `shellcheck bin/*.sh bin/backends/*.sh tests/*.sh` before treating a script change as done. +- Scripts must parse under macOS `/bin/bash` (bash 3.2), not just modern bash on Linux CI. +- Never assign a heredoc through command substitution (`VAR=$(cat <.test.sh`, and extend an existing script rather than inventing a new runner. - A backend-verification doc (`docs/*-backend.md`) records empirical facts, not assumptions. - Include the date, version, exact commands run, and exact output. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6e066fa8..ab49e6f6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -88,7 +88,7 @@ tests/fm-grok-harness.test.sh # grok adapter spawn hook, token guard tests/fm-fleet-sync.test.sh # project clone refresh: safe detached recovery, STUCK drift reports, benign skips, and bootstrap relay tests/fm-x-mode.test.sh # X-mode poll, inbox context round-trip, reply threading, dismiss, completion follow-up counters/caps, dry-run preview, and .env-presence activation tests tests/fm-tangle-guard.test.sh # primary-checkout tangle detection, read-only remediation suppression, and spawn/brief isolation tests -tests/fm-brief.test.sh # fm-brief.sh bash -n parse regression guard (issue #166) and clean no-mistakes/direct-PR/local-only brief generation tests +tests/fm-brief.test.sh # fm-brief.sh bash -n parse regression guard (issue #166, also run under /bin/bash), a structural guard refusing $(cat <<...) across bin/*.sh and bin/backends/*.sh, and clean no-mistakes/direct-PR/local-only brief generation tests tests/fm-spawn-batch.test.sh # batch dispatch and FM_HOME project-path scoping tests tests/fm-spawn-dispatch-profile.test.sh # concrete dispatch profile flags: active-profile backstop, harness/model/effort meta, launch templates, batch forwarding, and secondmate exemption tests/fm-update.test.sh # fast-forward-only self-update, reread, nudge, dedup, and skip-safety tests From 4eb1ca49edc70bbdf41fbe3ea20bb85e7aad0068 Mon Sep 17 00:00:00 2001 From: Idris Hassan <294209834+idrishassan-ai@users.noreply.github.com> Date: Sun, 5 Jul 2026 08:38:10 -0400 Subject: [PATCH 4/4] no-mistakes(lint): fix SC2016 in fm-brief structural test --- tests/fm-brief.test.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/fm-brief.test.sh b/tests/fm-brief.test.sh index 9c68c3c1..765bb322 100755 --- a/tests/fm-brief.test.sh +++ b/tests/fm-brief.test.sh @@ -36,6 +36,7 @@ test_script_parses() { test_no_heredoc_in_command_substitution() { local script for script in "$ROOT"/bin/*.sh "$ROOT"/bin/backends/*.sh; do + # shellcheck disable=SC2016 # literal grep pattern; expansion is not wanted if grep -n '^[^#]*\$(cat <<' "$script"; then fail "${script#"$ROOT"/} uses \$(cat <<...) - breaks bash 3.2 parsing if the body ever gains an apostrophe; assign with read -r -d '' instead" fi