Skip to content
Merged
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 @@ -118,7 +118,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.
- 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 pinned actionlint workflow lint) that CI and the no-mistakes pre-push gate both invoke, and it refuses to run under any other version of either linter.
- When a task names a specific tool, implement the work with that tool, or explicitly flag the substitution and its new dependency footprint for review before shipping.
- 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
28 changes: 25 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ permissions:

jobs:
lint:
name: Lint shell scripts
name: Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
Expand All @@ -20,8 +20,15 @@ jobs:
set -eu
bin/fm-install-shellcheck.sh "$RUNNER_TEMP/bin"
echo "$RUNNER_TEMP/bin" >> "$GITHUB_PATH"
# Single owner of the lint definition (file set + config + version). Do not
# re-spell the shellcheck command here; keep CI and the pre-push gate on it.
- name: Install pinned actionlint
run: |
set -eu
bin/fm-install-actionlint.sh "$RUNNER_TEMP/bin"
echo "$RUNNER_TEMP/bin" >> "$GITHUB_PATH"
# Single owner of the lint definition (shell file set, config, version,
# and GitHub workflow lint). Do not re-spell the checks here; keep CI
# and the pre-push gate on this script so a self-broken ci.yml still
# fails locally before merge.
- run: bin/fm-lint.sh

# Deterministic proof that portable parallel shards + portable serial + Herdr
Expand Down Expand Up @@ -52,6 +59,11 @@ jobs:
set -eu
bin/fm-install-shellcheck.sh "$RUNNER_TEMP/bin"
echo "$RUNNER_TEMP/bin" >> "$GITHUB_PATH"
- name: Install pinned actionlint
run: |
set -eu
bin/fm-install-actionlint.sh "$RUNNER_TEMP/bin"
echo "$RUNNER_TEMP/bin" >> "$GITHUB_PATH"
- name: Install tasks-axi
run: |
set -eu
Expand Down Expand Up @@ -84,6 +96,11 @@ jobs:
set -eu
bin/fm-install-shellcheck.sh "$RUNNER_TEMP/bin"
echo "$RUNNER_TEMP/bin" >> "$GITHUB_PATH"
- name: Install pinned actionlint
run: |
set -eu
bin/fm-install-actionlint.sh "$RUNNER_TEMP/bin"
echo "$RUNNER_TEMP/bin" >> "$GITHUB_PATH"
- name: Install tasks-axi
run: |
set -eu
Expand Down Expand Up @@ -130,6 +147,11 @@ jobs:
set -eu
bin/fm-install-shellcheck.sh "$RUNNER_TEMP/bin"
echo "$RUNNER_TEMP/bin" >> "$GITHUB_PATH"
- name: Install pinned actionlint
run: |
set -eu
bin/fm-install-actionlint.sh "$RUNNER_TEMP/bin"
echo "$RUNNER_TEMP/bin" >> "$GITHUB_PATH"
- name: Require tmux for e2e tests
run: |
set -eu
Expand Down
5 changes: 3 additions & 2 deletions .no-mistakes.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,10 @@ document:

# Pin lint to the same owner CI runs instead of leaving it to no-mistakes'
# default handling, which does not invoke the repository's canonical lint gate.
# `bin/fm-lint.sh` owns the complete lint definition and
# `bin/fm-lint.sh` owns the complete lint definition, including GitHub workflow
# lint via pinned actionlint in `bin/fm-lint-workflows.sh`, and
# `.github/workflows/ci.yml` invokes it directly, with parity asserted by
# `tests/fm-lint.test.sh`.
# `tests/fm-lint.test.sh` and `tests/fm-lint-workflows.test.sh`.
#
# Do not set commands.test to a complete tests/*.test.sh walk. Local no-mistakes
# Test is intent-targeted validation of whether the change meets its brief;
Expand Down
8 changes: 5 additions & 3 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,10 @@ 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.
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.
`bin/fm-lint.sh` must pass: it is the single owner of the lint definition (the shellcheck file set, config, pinned shellcheck version, and pinned actionlint workflow lint), and both CI and the no-mistakes pre-push gate run it, so local and CI can never diverge.
A malformed `.github/workflows/*.yml`, including a self-broken `ci.yml`, fails that local lint path before merge because a broken workflow cannot report its own breakage.
It pins one exact shellcheck version and one exact actionlint version and refuses to run under any other.
Print the shellcheck pin with `bin/fm-lint.sh --required-version` and the actionlint pin with `bin/fm-lint-workflows.sh --required-version`, then install those builds locally.
- Harness-adapter ownership spans 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-composer-lib.sh`, cleanup in `bin/fm-teardown.sh`, and facts in `.agents/skills/harness-adapters/SKILL.md`; the `firstmate-coding-guidelines` skill owns the validation policy for checks that depend on those harnesses.
- 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 @@ -72,7 +74,7 @@ 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 shell surface fm-lint.sh will cover (changed files locally, full set in CI/on main)
bin/fm-lint.sh # lint that same surface; the single owner CI and the no-mistakes gate both run, full set in CI
bin/fm-lint.sh # lint that shell surface plus GitHub workflows via pinned actionlint; 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)
bin/fm-test-run.sh --changed # conservative changed-file-informed set (never silent full suite)
Expand Down
36 changes: 36 additions & 0 deletions bin/fm-install-actionlint.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
#!/usr/bin/env bash
# fm-install-actionlint.sh - install CI's pinned, verified actionlint build.
#
# Usage:
# fm-install-actionlint.sh <destination-directory>
set -eu

ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
VERSION="$("$ROOT/bin/fm-lint-workflows.sh" --required-version)"
SHA256=8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8
ARCHIVE="actionlint_${VERSION}_linux_amd64.tar.gz"
URL="https://github.com/rhysd/actionlint/releases/download/v${VERSION}/${ARCHIVE}"
DESTINATION=${1:?usage: fm-install-actionlint.sh <destination-directory>}
TMP=$(mktemp -d "${RUNNER_TEMP:-${TMPDIR:-/tmp}}/fm-actionlint.XXXXXX")
trap 'rm -rf "$TMP"' EXIT

DOWNLOAD_ATTEMPTS=6
download_attempt=1
while ! curl -fsSL "$URL" -o "$TMP/$ARCHIVE"; do
[ "$download_attempt" -lt "$DOWNLOAD_ATTEMPTS" ] || {
printf 'fm-install-actionlint.sh: download failed after %s attempts\n' "$DOWNLOAD_ATTEMPTS" >&2
exit 1
}
printf 'fm-install-actionlint.sh: download attempt %s failed; retrying\n' "$download_attempt" >&2
sleep $((1 << (download_attempt - 1)))
download_attempt=$((download_attempt + 1))
done
ACTUAL_SHA256=$(sha256sum "$TMP/$ARCHIVE" | awk '{print $1}')
[ "$ACTUAL_SHA256" = "$SHA256" ] || {
printf 'fm-install-actionlint.sh: checksum mismatch for %s\n' "$ARCHIVE" >&2
exit 1
}
tar -xzf "$TMP/$ARCHIVE" -C "$TMP"
mkdir -p "$DESTINATION"
install -m 0755 "$TMP/actionlint" "$DESTINATION/actionlint"
"$DESTINATION/actionlint" -version
137 changes: 137 additions & 0 deletions bin/fm-lint-workflows.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
#!/usr/bin/env bash
# fm-lint-workflows.sh - owner of firstmate's GitHub workflow lint.
#
# Runs pinned actionlint on every .github/workflows/*.{yml,yaml} so a malformed
# workflow, including a self-broken ci.yml, fails in the local and no-mistakes
# lint lane before merge. A broken ci.yml cannot report its own breakage, so
# this check must not live only as a step inside that workflow. bin/fm-lint.sh
# invokes this owner on its default (no explicit-path) path, which CI and
# commands.lint both use.
#
# Usage:
# fm-lint-workflows.sh lint workflows under this repo
# fm-lint-workflows.sh --root <dir> lint workflows under <dir>
# fm-lint-workflows.sh <path>... lint explicit workflow files
# fm-lint-workflows.sh --required-version
# fm-lint-workflows.sh --help
set -eu

REQUIRED_ACTIONLINT=1.7.12
SELF_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SELF="$SELF_DIR/fm-lint-workflows.sh"
ROOT="$(cd "$SELF_DIR/.." && pwd)"

if [ "${1:-}" = "--required-version" ]; then
printf '%s\n' "$REQUIRED_ACTIONLINT"
exit 0
fi

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

EXPLICIT_ROOT=
while [ "$#" -gt 0 ]; do
case "$1" in
--root)
[ "$#" -ge 2 ] || {
printf 'fm-lint-workflows.sh: --root requires a directory.\n' >&2
exit 2
}
EXPLICIT_ROOT=$2
shift 2
;;
--root=*)
EXPLICIT_ROOT=${1#*=}
shift
;;
--help|-h)
fm_lint_workflows_usage
exit 0
;;
--)
shift
break
;;
-*)
printf 'fm-lint-workflows.sh: unknown option: %s\n' "$1" >&2
exit 2
;;
*)
break
;;
esac
done

if [ -n "$EXPLICIT_ROOT" ]; then
[ -d "$EXPLICIT_ROOT" ] || {
printf 'fm-lint-workflows.sh: --root is not a directory: %s\n' "$EXPLICIT_ROOT" >&2
exit 2
}
ROOT="$(cd "$EXPLICIT_ROOT" && pwd)"
fi

collect_workflow_files() {
local dir=$1
[ -d "$dir" ] || return 0
find "$dir" -maxdepth 1 \( -name '*.yml' -o -name '*.yaml' \) -type f \
| LC_ALL=C sort
}

FILES=()
if [ "$#" -gt 0 ]; then
for path in "$@"; do
case "$path" in
*.yml|*.yaml) ;;
*)
printf 'fm-lint-workflows.sh: not a workflow YAML file: %s\n' "$path" >&2
exit 2
;;
esac
[ -f "$path" ] || {
printf 'fm-lint-workflows.sh: workflow file not found: %s\n' "$path" >&2
exit 2
}
FILES+=("$path")
done
else
workflow_dir="$ROOT/.github/workflows"
while IFS= read -r path; do
[ -n "$path" ] || continue
FILES+=("$path")
done < <(collect_workflow_files "$workflow_dir")
if [ "${#FILES[@]}" -eq 0 ]; then
printf 'fm-lint-workflows.sh: no GitHub workflow files found under %s\n' \
"$workflow_dir" >&2
exit 1
fi
fi

if ! command -v actionlint >/dev/null 2>&1; then
printf 'fm-lint-workflows.sh: actionlint not found; install actionlint %s for CI parity.\n' \
"$REQUIRED_ACTIONLINT" >&2
exit 127
fi
ACTIONLINT_BIN=$(command -v actionlint)
resolved=$("$ACTIONLINT_BIN" -version | awk 'NR==1 {print; exit}')
printf 'fm-lint-workflows.sh: actionlint %s (pinned %s)\n' "$resolved" "$REQUIRED_ACTIONLINT" >&2
if [ "$resolved" != "$REQUIRED_ACTIONLINT" ]; then
printf 'fm-lint-workflows.sh: actionlint %s required for CI parity, found %s. Install %s.\n' \
"$REQUIRED_ACTIONLINT" "$resolved" "$REQUIRED_ACTIONLINT" >&2
exit 1
fi

# fm-lint.sh owns ShellCheck of the canonical shell set. Disable actionlint's
# extra shell and Python subprocess linters so this gate is the named workflow
# linter, not a second shell lint of `run:` blocks.
set +e
"$ACTIONLINT_BIN" -no-color -shellcheck= -pyflakes= -- "${FILES[@]}"
rc=$?
set -e

if [ "$rc" -ne 0 ]; then
exit "$rc"
fi

printf 'fm-lint-workflows.sh: %s workflow files valid\n' "${#FILES[@]}"
exit 0
32 changes: 26 additions & 6 deletions bin/fm-lint.sh
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
#!/usr/bin/env bash
# fm-lint.sh - the single owner of firstmate's shell-lint definition.
# fm-lint.sh - the single owner of firstmate's lint definition.
#
# Runs its file set with ShellCheck's default severity, extended analysis,
# ambient configuration disabled, and one exact ShellCheck version. CI and
# no-mistakes both invoke this script with no arguments, so the rule set,
# version, bounded execution, and diagnostics ordering cannot drift.
# Tests stop source analysis at imported production modules because every
# production shell is already a canonical, source-aware root of this same run.
# The default (no explicit-path) path also runs bin/fm-lint-workflows.sh so a
# malformed GitHub workflow, including a self-broken ci.yml, fails locally
# before merge instead of only failing to run as CI.
#
# With no explicit paths, the file set depends on context:
# - In CI (GITHUB_ACTIONS=true or CI=true), on the main branch, or when no
Expand All @@ -16,10 +19,10 @@
# - Otherwise (an ordinary local branch with a real merge-base) it lints
# only the canonical-set files changed since that merge-base, including
# uncommitted local edits, via plain local `git diff` (no network, no
# `gh`). A branch with zero matching changed files exits 0 and prints a
# "no changed lint targets" note instead of running ShellCheck.
# `gh`). A branch with zero matching changed files skips ShellCheck and
# prints a "no changed lint targets" note, then still validates workflows.
# Explicit paths always bypass this file-set selection and lint exactly the
# given paths, matching the same config.
# given paths, matching the same config, without the workflow YAML check.
#
# Canonical lint defaults to two bounded workers over two stable logical shards.
# Each shard writes separate diagnostics, and the parent replays those outputs in
Expand Down Expand Up @@ -97,7 +100,14 @@ if [ "${1:-}" = "--required-version" ]; then
fi

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

# Default no-args lint also validates GitHub workflows. Explicit paths stay a
# ShellCheck-only override so callers can target one shell root.
fm_lint_run_workflows() {
[ "$EXPLICIT_PATHS" -eq 0 ] || return 0
"$SELF_DIR/fm-lint-workflows.sh"
}

JOBS=${FM_LINT_JOBS:-2}
Expand Down Expand Up @@ -180,7 +190,9 @@ fm_lint_is_canonical_root() {
}

CHANGED_MODE=0
EXPLICIT_PATHS=0
if [ "$#" -gt 0 ]; then
EXPLICIT_PATHS=1
ROOTS=("$@")
else
full_lint=1
Expand Down Expand Up @@ -238,7 +250,9 @@ fi

if [ "$CHANGED_MODE" -eq 1 ] && [ "$ROOT_COUNT" -eq 0 ]; then
printf 'fm-lint.sh: no changed lint targets\n'
exit 0
overall_rc=0
fm_lint_run_workflows || overall_rc=$?
exit "$overall_rc"
fi

if [ -n "$TELEMETRY" ]; then
Expand Down Expand Up @@ -538,4 +552,10 @@ EOF
fi
fi

if [ "$overall_rc" -eq 0 ]; then
fm_lint_run_workflows || overall_rc=$?
else
fm_lint_run_workflows || true
fi

exit "$overall_rc"
4 changes: 3 additions & 1 deletion bin/fm-test-run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ family_for_basename() {
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-muse-harness.test.sh|fm-herdr-lab.test.sh|fm-lint.test.sh|\
fm-lint-workflows.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-subagent-pretool-check.test.sh|\
Expand Down Expand Up @@ -960,7 +961,8 @@ families_for_changed_path() {
# lane's contract coverage re-runs.
printf '%s\n' real-herdr-gated
;;
bin/fm-lint.sh|bin/fm-install-shellcheck.sh|\
bin/fm-lint.sh|bin/fm-lint-workflows.sh|bin/fm-install-shellcheck.sh|\
bin/fm-install-actionlint.sh|\
bin/fm-brief.sh|bin/fm-ensure-agents-md.sh|bin/fm-crew-state.sh|\
bin/fm-decision-hold.sh|bin/fm-supervision*|bin/fm-transition-lib.sh|\
bin/fm-tmux-lib.sh|bin/fm-marker-lib.sh|bin/fm-operational-input.sh|bin/fm-tasks-axi-lib.sh|\
Expand Down
Loading
Loading