diff --git a/bin/fm-tasklist-view.sh b/bin/fm-tasklist-view.sh new file mode 100755 index 0000000..1aa5679 --- /dev/null +++ b/bin/fm-tasklist-view.sh @@ -0,0 +1,290 @@ +#!/usr/bin/env bash +# fm-tasklist-view.sh - live, watchable board of this home's fleet task list. +# +# A read-only glanceable projection meant to sit in a persistent split pane +# (cmux/herdr) so the captain can SEE, continuously, what the fleet is doing: +# what is IN FLIGHT right now (and how much of it runs in parallel), what is +# QUEUED and ready, what is UPCOMING (waiting on a dependency or a captain +# hold), and what was recently DONE - in the backlog's own priority order. +# +# This command parses no fleet state itself. Like bin/fm-fleet-view.sh, it is a +# renderer over bin/fm-fleet-snapshot.sh --json (schema fm-fleet-snapshot.v1), +# so the board can never drift from the real backlog/state contract. Live +# workers come from that snapshot's tasks[] (each backed by a state/.meta), +# queue/priority/done from its backlog.records[]. +# +# Read-only: it never acquires the session lock, drains wakes, arms watchers, +# spawns, sends, or writes any data/ or state/ file. Its only child process is +# fm-fleet-snapshot.sh --json, itself read-only. +# +# Usage: +# fm-tasklist-view.sh [--once] [--interval N] [--width N] [--done N] +# [--no-clear] [--no-color] +# +# --once, -1 Render one frame and exit (no loop). Used for testing. +# --interval N, -n Seconds between redraws in the live loop +# (default 4, env FM_TASKLIST_INTERVAL). Ignored with --once. +# --width N Target column width for wrapping/truncation +# (default: $COLUMNS, else 80; minimum 56). +# --done N Max DONE-RECENT rows to show (default 8, env +# FM_TASKLIST_DONE). +# --no-clear Do not clear the screen each frame (stream frames instead). +# --no-color Disable ANSI color (also honored via NO_COLOR). +# -h, --help Show this help. +# +# FM_HOME selects which home's list is shown and is REQUIRED: like other fleet +# scripts, this refuses to guess a home rather than silently show the wrong one. +# The board degrades gracefully - a briefly absent or locked source keeps the +# last good frame with an "updating" footer instead of crashing the loop. +set -u + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +usage() { + sed -n '20,38p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' +} + +require_option_value() { + local opt=$1 value=${2:-} + if [ -z "$value" ]; then + echo "fm-tasklist-view: $opt requires a value" >&2 + usage >&2 + exit 2 + fi + case "$value" in + -*) echo "fm-tasklist-view: $opt requires a value" >&2; usage >&2; exit 2 ;; + esac +} + +if [ "${FM_TASKLIST_INTERVAL+x}" = x ]; then + INTERVAL=$FM_TASKLIST_INTERVAL +else + INTERVAL=4 +fi +DONE_LIMIT="${FM_TASKLIST_DONE:-8}" +WIDTH="" +WIDTH_PROVIDED=0 +ONCE=0 +NO_CLEAR=0 +USE_COLOR=1 +[ -n "${NO_COLOR:-}" ] && USE_COLOR=0 +[ -t 1 ] || USE_COLOR=0 + +while [ "$#" -gt 0 ]; do + case "$1" in + -h|--help) usage; exit 0 ;; + --once|-1) ONCE=1 ;; + --interval|-n) + require_option_value "$1" "${2:-}" + shift + INTERVAL=$1 + ;; + --width) require_option_value "$1" "${2:-}"; shift; WIDTH=$1; WIDTH_PROVIDED=1 ;; + --done) require_option_value "$1" "${2:-}"; shift; DONE_LIMIT=$1 ;; + --no-clear) NO_CLEAR=1 ;; + --no-color) USE_COLOR=0 ;; + *) echo "fm-tasklist-view: unknown argument: $1" >&2; usage >&2; exit 2 ;; + esac + shift +done + +invalid_interval() { + echo "fm-tasklist-view: --interval must be a positive number" >&2 + exit 2 +} + +case "$INTERVAL" in ''|*[!0-9.]*) invalid_interval ;; esac +[[ "$INTERVAL" =~ ^([0-9]+([.][0-9]+)?|[.][0-9]+)$ ]] || invalid_interval +[[ "$INTERVAL" =~ ^0*([.]0*)?$ ]] && invalid_interval +case "$DONE_LIMIT" in ''|*[!0-9]*) echo "fm-tasklist-view: --done must be a non-negative integer" >&2; exit 2 ;; esac +if [ -z "$WIDTH" ]; then WIDTH="${COLUMNS:-80}"; fi +case "$WIDTH" in + ''|*[!0-9]*|0) + if [ "$WIDTH_PROVIDED" -eq 1 ]; then + echo "fm-tasklist-view: --width must be a positive integer" >&2 + exit 2 + fi + WIDTH=80 + ;; +esac +[ "$WIDTH" -lt 56 ] && WIDTH=56 + +if [ -z "${FM_HOME+x}" ] || [ -z "${FM_HOME:-}" ]; then + echo "error: FM_HOME is not set; fm-tasklist-view refuses to guess a firstmate home" >&2 + exit 1 +fi + +command -v jq >/dev/null 2>&1 || { echo "fm-tasklist-view: jq not found" >&2; exit 1; } + +# Color palette: empty strings when color is off, so the same jq program renders +# either way. These are the only ANSI this script emits. +if [ "$USE_COLOR" -eq 1 ]; then + C_RST=$'\033[0m'; C_BOLD=$'\033[1m'; C_DIM=$'\033[2m' + C_RUN=$'\033[32m'; C_WARN=$'\033[33m'; C_ERR=$'\033[31m'; C_HDR=$'\033[36m' +else + C_RST=''; C_BOLD=''; C_DIM=''; C_RUN=''; C_WARN=''; C_ERR=''; C_HDR='' +fi + +# The board program. Pure formatting over the fm-fleet-snapshot.v1 contract; +# it reads only fields that contract owns and never re-derives fleet state. +read -r -d '' BOARD_JQ <<'JQ' || true + def s: (. // "") | tostring; + def trunc($n): s | if (length > $n) then (.[:($n-1)] + "…") else . end; + def pad($n): s | . as $x | if (length >= $n) then $x else ($x + " " * ($n - length)) end; + def cell($n): trunc($n) | pad($n); + def hrule: ("\($hdr)" + ("─" * ($width)) + "\($rst)"); + def glyph($state): + if $state == "working" then "\($run)▶\($rst)" + elif $state == "parked" or $state == "paused" then "\($warn)⏸\($rst)" + elif $state == "blocked" then "\($err)■\($rst)" + elif $state == "done" then "\($run)✓\($rst)" + elif $state == "failed" then "\($err)✗\($rst)" + else "\($dim)·\($rst)" end; + + # Column budget for the IN FLIGHT rows. + (9) as $stw + | (16) as $idw + | (12) as $rpw + | (($width - ($stw + $idw + $rpw + 7)) | if . < 12 then 12 else . end) as $tiw + | (.tasks // []) as $tasks + | (.backlog.records // []) as $recs + | (.main_inventory // {}) as $main_inventory + | ([$recs[] | select(.state == "in_flight" and .structured)]) as $inflight_bl + | ($inflight_bl | map({key:.id, value:.}) | from_entries) as $bl_by_id + # Live workers are the truth for "what is being worked on now" (each has a + # meta). Secondmates are persistent supervisors, not task-list work items. + | ([$tasks[] | select(.kind != "secondmate")]) as $live + | ($live | map({key:.id, value:.}) | from_entries) as $live_by_id + | ([$live[] | select(.current_state.state == "working") | .id]) as $working_ids + | ([$inflight_bl[] + | . as $record + | select($record.current_role != "held" or (($working_ids | index($record.id)) != null)) + | $record.id]) as $bl_order + # Priority order: backlog in-flight order first, then any live worker without + # a backlog in-flight row (appended by id, snapshot already sorted them). + | ( [ $bl_order[] | select($live_by_id[.] != null) | $live_by_id[.] ] + + [ $live[] + | . as $task + | .id as $lid + | select(($bl_order | index($lid)) == null) + | select((($bl_by_id[$lid].current_role // "") != "held") or $task.current_state.state == "working") ] ) as $inflight + | ($inflight | map(select(.current_state.state == "working")) | length) as $parallel + + | ([$recs[] | select(.state == "queued" and .structured)]) as $queued + | (($main_inventory.orphan_in_flight // [])) as $orphan_ids + | ([$inflight_bl[] + | . as $record + | select(.current_role == "held" and (($working_ids | index($record.id)) == null))]) as $held_inflight + | ([$inflight_bl[] + | . as $record + | select(($orphan_ids | index($record.id)) != null) + | . + {tasklist_reason:"missing child metadata"}]) as $orphan_inflight + | (if (($main_inventory.unstructured_current_count // 0) > 0) then + [{id:"(main-inventory)", + title:($main_inventory.reason // "main inventory invalid"), + tasklist_reason:"unstructured rows: \($main_inventory.unstructured_current_count)"}] + else [] end) as $inventory_gates + | ([$queued[] | select(((.unresolved_blocker_ids // []) | length) == 0 and (.hold_reason == null))]) as $ready + | ($inventory_gates + + $held_inflight + + $orphan_inflight + + [$queued[] | select(((.unresolved_blocker_ids // []) | length) > 0 or (.hold_reason != null))]) as $upcoming + | ([$recs[] | select(.state == "done" and .structured)]) as $done + + | [ + "\($bold)\($hdr)FLEET · \(.fm_home)\($rst)", + "\($dim)as of \($now)\($rst)", + "", + ( "\($bold)IN FLIGHT\($rst) \($dim)(\($inflight | length) live" + + (if $parallel > 1 then " · \($parallel) in parallel" else "" end) + ")\($rst)" ), + hrule, + ( if ($inflight | length) == 0 then " \($dim)nothing under way\($rst)" + else ($inflight[] + | ($bl_by_id[.id]) as $bl + | (glyph(.current_state.state)) as $g + | ((.current_state.state) | cell($stw)) as $st + | (.id | cell($idw)) as $id + | ((($bl.repo // .project // "-")) | cell($rpw)) as $rp + | (($bl.title // .hints.last_event_text // .id) | cell($tiw)) as $ti + | (if .hints.pending_decision then " \($warn)⚠ needs decision\($rst)" + elif .hints.blocked_event then " \($err)⚠ blocked\($rst)" + elif (.endpoint.exists == false) then " \($dim)(no live worker)\($rst)" + else "" end) as $flag + # A review-ready PR link goes on its own line so it stays clickable. + | (if ((.pr.url // "") != "") then "\n \($dim)└ \(.pr.url)\($rst)" else "" end) as $prline + | " \($g) \($st) \($id) \($rp) \($ti)\($flag)\($prline)" + ) end ), + "", + "\($bold)QUEUED\($rst) \($dim)(ready, priority order)\($rst)", + hrule, + ( if ($ready | length) == 0 then " \($dim)none ready\($rst)" + else ($ready[] + | " \(.id | cell($idw)) \((.repo // "-") | cell($rpw)) \(.title | cell($tiw))" + ) end ), + "", + "\($bold)UPCOMING\($rst) \($dim)(waiting on a dependency or a hold)\($rst)", + hrule, + ( if ($upcoming | length) == 0 then " \($dim)none\($rst)" + else ($upcoming[] + | (if (.tasklist_reason != null) then .tasklist_reason + elif (.hold_reason != null) then "hold: \(.hold_reason)" + else "blocked-by: \(((.unresolved_blocker_ids // []) | join(", ")))" end) as $why + | " \(.id | cell($idw)) \(.title | cell($tiw)) \($dim)\($why | trunc($width - $idw - $tiw - 6))\($rst)" + ) end ), + "", + "\($bold)DONE · recent\($rst)", + hrule, + ( if ($done | length) == 0 then " \($dim)none\($rst)" + else ($done[:$donelimit][] + | ((.completion.date // "") | cell(10)) as $dt + | ((.pr_url // .report_path // .local_note // "")) as $art + | (if $art == "" then "" else "\n \($dim)└ \($art)\($rst)" end) as $artline + | " \($dim)\($dt)\($rst) \(.id | cell($idw)) \(.title | cell($tiw))\($artline)" + ) end ) + ] + | .[] +JQ + +# Render exactly one board frame to stdout. Returns non-zero (printing nothing) +# when the snapshot is unavailable, so the caller can keep the last good frame. +render_once() { + local json + json=$("$SCRIPT_DIR/fm-fleet-snapshot.sh" --json 2>/dev/null) || return 1 + [ -n "$json" ] || return 1 + printf '%s' "$json" | jq -r \ + --arg now "$(date '+%Y-%m-%d %H:%M:%S')" \ + --argjson width "$WIDTH" \ + --argjson donelimit "$DONE_LIMIT" \ + --arg rst "$C_RST" --arg bold "$C_BOLD" --arg dim "$C_DIM" \ + --arg run "$C_RUN" --arg warn "$C_WARN" --arg err "$C_ERR" --arg hdr "$C_HDR" \ + "$BOARD_JQ" +} + +if [ "$ONCE" -eq 1 ]; then + render_once || { echo "fm-tasklist-view: unable to read the fleet snapshot" >&2; exit 1; } + exit 0 +fi + +# Live loop. Restore the cursor on exit; never let a transient read crash it. +cleanup() { printf '\033[?25h'; } +trap 'cleanup; exit 0' INT TERM +trap cleanup EXIT +[ "$USE_COLOR" -eq 1 ] && printf '\033[?25l' + +LAST_FRAME="" +while :; do + if frame=$(render_once); then + LAST_FRAME=$frame + footer="${C_DIM}refreshing every ${INTERVAL}s · Ctrl-C to quit${C_RST}" + else + footer="${C_WARN}updating… showing last good frame${C_RST}" + fi + [ "$NO_CLEAR" -eq 0 ] && printf '\033[H\033[2J' + if [ -n "$LAST_FRAME" ]; then + printf '%s\n' "$LAST_FRAME" + else + printf '%s\n' "${C_DIM}waiting for the first fleet snapshot…${C_RST}" + fi + printf '\n%b\n' "$footer" + sleep "$INTERVAL" +done diff --git a/docs/architecture.md b/docs/architecture.md index d1bcb83..c7f4bb2 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -37,7 +37,7 @@ Decision-only events such as `resolved` never become current state or leak their In that status-log fallback, a declared external wait reports the distinct `paused` state with its reason. For herdr, that pane fallback trusts a native `busy` verdict outright, but corroborates native `idle` or unknown verdicts against the recorded harness's rendered busy signature before deciding the crew is not working. For whole-fleet read-only review, `bin/fm-fleet-snapshot.sh --json` emits schema `fm-fleet-snapshot.v1` from the backlog, task metadata, current crew state, endpoint probes, PR/report pointers, scout reports, bounded current summaries from registered secondmate homes, and secondmate return-channel guidance. -`bin/fm-fleet-view.sh` renders that snapshot as Markdown for humans, while `bin/fm-bearings-snapshot.sh` provides the bounded bearings projection, so both views consume one structured contract instead of reparsing raw fleet files. +`bin/fm-fleet-view.sh` renders that snapshot as Markdown for humans, `bin/fm-tasklist-view.sh` renders the live read-only task-list board, and `bin/fm-bearings-snapshot.sh` provides the bounded bearings projection, so these consumers share one structured contract instead of reparsing raw fleet files. The script header owns the exact JSON schema. ### Registered secondmate current state diff --git a/docs/scripts.md b/docs/scripts.md index 23f7d34..fa7bda3 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -14,6 +14,7 @@ The shared no-mistakes gate refusal for fleet lifecycle entrypoints is summarize | `fm-fleet-sync.sh` | Refresh project clones with safe fast-forwards, self-heals, `STUCK:` reports, branch pruning, and bounded recovery from an orphaned `.git/packed-refs.lock` | | `fm-fleet-snapshot.sh` | Print the read-only structured fleet snapshot JSON (schema `fm-fleet-snapshot.v1`) | | `fm-fleet-view.sh` | Render the fleet snapshot as a human Markdown view | +| `fm-tasklist-view.sh` | Render the fleet snapshot as a live read-only task-list board | | `fm-bearings-snapshot.sh` | Project the fleet snapshot to the compact TOON bearings view; local-only unless `--include-prs` | | `fm-inbox-view.sh` | Generate the read-only captain decision-and-review board HTML | | `fm-inbox-render.py` | Render the inbox-board HTML from snapshot, card, full-text, and PR-state inputs | diff --git a/tests/fm-backend.test.sh b/tests/fm-backend.test.sh index 922b227..54f8e33 100755 --- a/tests/fm-backend.test.sh +++ b/tests/fm-backend.test.sh @@ -138,10 +138,11 @@ resolve_permissive_tmux_kill_ref() { # fm-backend.sh (and its bin/backends/ adapters) is the dispatcher every one # of the five REFACTORED scripts sources; it must be a real, reachable file in # the old bin/ too or `. "$SCRIPT_DIR/fm-backend.sh"` aborts under set -eu - -# hence the dispatcher is a copied sibling, while the tmux adapter is extracted -# from BASE_REF so conformance tests retain the exact historical behavior even -# when this branch changes tmux dispatch semantics. -OLD_BIN_UNCHANGED_SIBLINGS="fm-gate-refuse-lib.sh fm-guard.sh fm-lock-lib.sh fm-tasks-axi-lib.sh fm-pr-lib.sh fm-tangle-lib.sh fm-tmux-lib.sh fm-composer-lib.sh fm-wake-lib.sh fm-classify-lib.sh fm-supervision-lib.sh fm-ff-lib.sh fm-config-inherit-lib.sh fm-project-mode.sh fm-harness.sh fm-crew-state.sh fm-decision-hold.sh fm-backend.sh fm-operational-input.sh" +# hence the dispatcher is a copied sibling, not an extracted-from-BASE_REF file; +# for a tmux-only conformance run the tmux adapter's behavior is what is under +# test, and that adapter is extracted from BASE_REF so later non-tmux backend +# additions do not change the historical tmux dispatch surface. +OLD_BIN_UNCHANGED_SIBLINGS="fm-gate-refuse-lib.sh fm-guard.sh fm-lock-lib.sh fm-tasks-axi-lib.sh fm-pr-lib.sh fm-tangle-lib.sh fm-tmux-lib.sh fm-composer-lib.sh fm-wake-lib.sh fm-classify-lib.sh fm-supervision-lib.sh fm-ff-lib.sh fm-config-inherit-lib.sh fm-project-mode.sh fm-harness.sh fm-crew-state.sh fm-decision-hold.sh fm-operational-input.sh fm-backend.sh" # A pull-request merge may add a new main-only dependency that the branch's older baseline does not have yet. OLD_BIN_OPTIONAL_SIBLINGS="fm-pending-reply-lib.sh" OLD_BIN_REFACTORED="fm-send.sh fm-peek.sh fm-watch.sh fm-spawn.sh fm-teardown.sh fm-marker-lib.sh" diff --git a/tests/fm-tasklist-view.test.sh b/tests/fm-tasklist-view.test.sh new file mode 100755 index 0000000..3264b04 --- /dev/null +++ b/tests/fm-tasklist-view.test.sh @@ -0,0 +1,276 @@ +#!/usr/bin/env bash +# Behavior tests for the live task-list board renderer over fm-fleet-snapshot.sh. +# Covers band composition (in-flight/queued/upcoming/done), priority ordering, +# the parallel-in-flight marker, secondmate exclusion, graceful empty rendering, +# the fail-closed FM_HOME contract, the --done/--width knobs, and a proof that a +# render mutates no state/ or data/ file (it only reads through the snapshot). +set -u + +# shellcheck source=tests/lib.sh +# shellcheck disable=SC1091 +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +VIEW="$ROOT/bin/fm-tasklist-view.sh" +TMP_ROOT=$(fm_test_tmproot fm-tasklist) + +command -v jq >/dev/null 2>&1 || { echo "skip: jq not found"; exit 0; } + +# A tmux stub so the snapshot can read live-pane current state for the fixture. +# ship-api and ship-ui read as actively working; everything else is quiet. +make_fakebin() { # + local fb + fb=$(fm_fakebin "$1") + cat > "$fb/no-mistakes" <<'SH' +#!/usr/bin/env bash +exit 0 +SH + cat > "$fb/tmux" <<'SH' +#!/usr/bin/env bash +set -u +target=""; prev="" +for arg in "$@"; do + if [ "$prev" = "-t" ]; then target=$arg; fi + prev=$arg +done +case "${1:-}" in + display-message) + case "$*" in *pane_current_command*) printf 'codex\n' ;; *) printf '%%1\n' ;; esac ;; + capture-pane) + case "$target" in + *ship-api*|*ship-search*) printf 'work in progress\nesc to interrupt\n' ;; + *) printf 'all quiet\n> \n' ;; + esac ;; +esac +exit 0 +SH + chmod +x "$fb/no-mistakes" "$fb/tmux" + printf '%s\n' "$fb" +} + +make_home() { # + local home=$TMP_ROOT/$1 + mkdir -p "$home/state" "$home/data" "$home/projects" "$home/config" + printf '%s\n' "$home" +} + +# A fixture with all four bands, a priority order, two parallel workers, a +# blocked queued item, a captain hold, a done PR and a done scout report, plus a +# persistent secondmate that must NOT appear on the task board. +write_fixture() { # + local home=$1 + mkdir -p "$home/projects/a" "$home/projects/b" "$home/secondmate-home" "$home/data/scout-task" + cat > "$home/data/backlog.md" <<'EOF' +## In flight +- [ ] scout-task - Investigate flaky login redirect data/scout-task/report.md (repo: webapp) (kind: scout) (priority: 1) (since 2026-07-20) +- [ ] ship-api - Add pagination to the events API https://github.com/acme/repo/pull/9 (repo: api) (kind: ship) (priority: 2) (since 2026-07-21) +- [ ] ship-search - Speed up the search index (repo: api) (kind: ship) (priority: 3) (since 2026-07-22) +- [ ] ship-ui - Rework the settings sidebar (repo: webapp) (kind: ship) (since 2026-07-22) + +## Queued +- [ ] queued-cache - Add a caching layer to the search endpoint (repo: api) (kind: ship) +- [ ] queued-report - Weekly usage report generator (repo: analytics) (kind: ship) +- [ ] blocked-migrate - Migrate to the new pagination shape blocked-by: ship-api - waits on the API change (repo: api) (kind: ship) +- [ ] held-canary - Run the production canary (repo: api) (kind: captain) (hold: captain runs the canary) (hold-kind: captain) + +## Done +- [x] done-auth - Fix OAuth token refresh https://github.com/acme/repo/pull/7 (repo: api) (kind: ship) (merged 2026-07-19) +- [x] done-scout - Audit bundle size data/done-scout/report.md (repo: webapp) (kind: scout) (reported 2026-07-18) +EOF + printf '# Scout\n' > "$home/data/scout-task/report.md" + fm_write_meta "$home/state/scout-task.meta" \ + "window=firstmate:fm-scout-task" "worktree=$home/projects/a" "project=webapp" \ + "harness=codex" "kind=scout" "mode=scout" + printf 'done: report ready\n' > "$home/state/scout-task.status" + fm_write_meta "$home/state/ship-api.meta" \ + "window=firstmate:fm-ship-api" "worktree=$home/projects/b" "project=$home/projects/b" \ + "harness=codex" "kind=ship" "mode=ship" \ + "pr=https://github.com/acme/repo/pull/9" + printf 'working: building endpoint\n' > "$home/state/ship-api.status" + fm_write_meta "$home/state/ship-search.meta" \ + "window=firstmate:fm-ship-search" "worktree=$home/projects/b" "project=api" \ + "harness=codex" "kind=ship" "mode=ship" + printf 'working: reindexing\n' > "$home/state/ship-search.status" + fm_write_meta "$home/state/ship-ui.meta" \ + "window=firstmate:fm-ship-ui" "worktree=$home/projects/a" "project=webapp" \ + "harness=codex" "kind=ship" "mode=ship" + printf 'needs-decision: pick sidebar layout A or B\n' > "$home/state/ship-ui.status" + fm_write_meta "$home/state/domain-second.meta" \ + "window=firstmate:fm-domain-second" "worktree=$home/secondmate-home" \ + "project=$home/secondmate-home" "harness=codex" "kind=secondmate" "mode=secondmate" \ + "home=$home/secondmate-home" "projects=api, webapp" + printf 'working: watching delegated scope\n' > "$home/state/domain-second.status" +} + +test_empty_fleet_renders_gracefully() { + local home out + home=$(make_home empty) + out=$(FM_HOME="$home" "$VIEW" --once --no-color --width 72) + assert_contains "$out" "IN FLIGHT" "empty board still shows the IN FLIGHT band" + assert_contains "$out" "nothing under way" "empty board marks in-flight as empty" + assert_contains "$out" "none ready" "empty board marks queued as empty" + pass "empty fleet renders every band without crashing" +} + +test_board_bands_and_parallel() { + local home fakebin out ship_line + home=$(make_home fixture) + write_fixture "$home" + fakebin=$(make_fakebin "$home") + out=$(PATH="$fakebin:$PATH" FM_HOME="$home" "$VIEW" --once --no-color --width 96) + assert_contains "$out" "2 in parallel" "two actively-working workers are marked parallel" + assert_contains "$out" "ship-api" "in-flight shows the working ship task" + ship_line=$(printf '%s\n' "$out" | grep 'ship-api' | head -1) + assert_contains "$ship_line" "ship-api api" "in-flight repo prefers backlog repo over meta project" + assert_contains "$out" "Add pagination to the events API" "in-flight shows the backlog title" + assert_contains "$out" "needs decision" "a parked worker with an open decision is flagged" + assert_contains "$out" "queued-cache" "queued-ready shows a ready item" + assert_contains "$out" "blocked-by: ship-api" "upcoming shows the unresolved blocker" + assert_contains "$out" "hold: captain runs" "upcoming shows a captain hold" + assert_contains "$out" "https://github.com/acme/repo/pull/7" "done shows the merged PR artifact" + assert_not_contains "$out" "domain-second" "a persistent secondmate must not appear on the task board" + pass "board composes all four bands with parallel, blocker, hold, and PR detail" +} + +test_current_backlog_gates_are_visible() { + local home fakebin out held_count + home=$(make_home current-gates) + mkdir -p "$home/projects/held" + cat > "$home/data/backlog.md" <<'EOF' +## In flight +free-form current note +- [ ] held-observation - Held observation (repo: alpha) (kind: scout) (hold: watch production) (hold-kind: external) +- [ ] held-paused - Held task with metadata (repo: alpha) (kind: ship) (hold: upstream release) (hold-kind: external) +- [ ] orphan-ship - Structured worker without metadata (repo: alpha) (kind: ship) + +## Queued + +## Done +EOF + fm_write_meta "$home/state/held-paused.meta" \ + "window=firstmate:fm-held-paused" "worktree=$home/projects/held" "project=alpha" \ + "harness=codex" "kind=ship" "mode=ship" + printf 'paused: waiting on upstream release\n' > "$home/state/held-paused.status" + fakebin=$(make_fakebin "$home") + out=$(PATH="$fakebin:$PATH" FM_HOME="$home" "$VIEW" --once --no-color --width 96) + assert_contains "$out" "nothing under way" "current gates without live workers must not fake in-flight work" + assert_contains "$out" "(main-inventory)" "unstructured current backlog rows are surfaced" + assert_contains "$out" "unstructured rows: 1" "inventory gate names the unstructured row count" + assert_contains "$out" "held-observation" "in-flight held records without metadata are surfaced" + assert_contains "$out" "hold: watch production" "in-flight held records retain their hold reason" + held_count=$(printf '%s\n' "$out" | grep -c 'held-paused' || true) + [ "$held_count" -eq 1 ] || fail "held task with metadata should appear once as an upcoming hold, got $held_count" + assert_contains "$out" "orphan-ship" "orphan in-flight records are surfaced" + assert_contains "$out" "missing child metadata" "orphan in-flight records name the missing metadata" + pass "current backlog gates remain visible without child metadata" +} + +test_priority_ordering() { + local home fakebin out cache_line report_line + home=$(make_home priority) + write_fixture "$home" + fakebin=$(make_fakebin "$home") + out=$(PATH="$fakebin:$PATH" FM_HOME="$home" "$VIEW" --once --no-color --width 96) + cache_line=$(printf '%s\n' "$out" | grep -n 'queued-cache' | head -1 | cut -d: -f1) + report_line=$(printf '%s\n' "$out" | grep -n 'queued-report' | head -1 | cut -d: -f1) + [ -n "$cache_line" ] && [ -n "$report_line" ] && [ "$cache_line" -lt "$report_line" ] \ + || fail "queued items must render in backlog priority order (cache before report)" + pass "queued band preserves backlog priority order" +} + +test_done_limit_and_width() { + local home fakebin out rule_len + home=$(make_home limits) + write_fixture "$home" + fakebin=$(make_fakebin "$home") + out=$(PATH="$fakebin:$PATH" FM_HOME="$home" "$VIEW" --once --no-color --width 96 --done 1) + assert_contains "$out" "done-auth" "the most recent done row is shown under --done 1" + assert_not_contains "$out" "done-scout" "--done 1 caps the done band to a single row" + out=$(PATH="$fakebin:$PATH" FM_HOME="$home" "$VIEW" --once --no-color --width 56) + # The tabular band rows must fit the pane. Three kinds of line are intentional + # full-length annotations exempt from the column bound: the home-path header, + # full-URL sub-lines (indented "└ "), and the short "⚠" decision/blocked flag. + # Measure display width in CHARACTERS (box-drawing and glyphs are multibyte), + # counting with wc -m under a UTF-8 locale, not byte-counting awk. + local longest line n + longest=0 + while IFS= read -r line; do + case "$line" in ''|FLEET*|"as of "*|*└*|*⚠*) continue ;; esac + n=$(printf '%s' "$line" | LC_ALL=en_US.UTF-8 wc -m | tr -d ' ') + [ "$n" -gt "$longest" ] && longest=$n + done < 56)" + out=$(PATH="$fakebin:$PATH" FM_HOME="$home" "$VIEW" --once --no-color --width 48) + rule_len=$(printf '%s\n' "$out" | grep '^─' | head -1 | tr -d '\n' | LC_ALL=en_US.UTF-8 wc -m | tr -d ' ') + [ "$rule_len" -eq 56 ] || fail "--width below 56 must clamp to a 56-column rule" + pass "--done caps the done band and --width bounds line length for narrow panes" +} + +test_fail_closed_without_home() { + local rc out + out=$(env -u FM_HOME "$VIEW" --once 2>&1) + rc=$? + expect_code 1 "$rc" "unset FM_HOME must fail closed" + assert_contains "$out" "FM_HOME is not set" "the refusal names the missing home" + pass "the board refuses to guess a home when FM_HOME is unset" +} + +test_interval_validation() { + local home bad rc out + home=$(make_home interval) + for bad in 0 1..2 1.2.3; do + out=$(FM_HOME="$home" "$VIEW" --interval "$bad" 2>&1) + rc=$? + expect_code 2 "$rc" "invalid --interval $bad must fail" + assert_contains "$out" "--interval must be a positive number" "invalid --interval $bad explains the refusal" + done + out=$(FM_TASKLIST_INTERVAL='' FM_HOME="$home" "$VIEW" --once 2>&1) + rc=$? + expect_code 2 "$rc" "empty FM_TASKLIST_INTERVAL must fail" + assert_contains "$out" "--interval must be a positive number" "empty FM_TASKLIST_INTERVAL explains the refusal" + pass "interval validation rejects nonpositive and malformed values" +} + +test_option_value_validation() { + local home rc out + home=$(make_home option-values) + out=$(FM_HOME="$home" "$VIEW" --width --once --interval 0 2>&1) + rc=$? + expect_code 2 "$rc" "--width followed by a flag must fail" + assert_contains "$out" "--width requires a value" "--width must not consume --once as its value" + out=$(FM_HOME="$home" "$VIEW" --width nope --once 2>&1) + rc=$? + expect_code 2 "$rc" "non-numeric --width must fail" + assert_contains "$out" "--width must be a positive integer" "--width must not silently default malformed user input" + out=$(FM_HOME="$home" "$VIEW" --done --once 2>&1) + rc=$? + expect_code 2 "$rc" "--done followed by a flag must fail" + assert_contains "$out" "--done requires a value" "--done must not consume --once as its value" + pass "value-taking options reject missing flag-like values" +} + +# Provably read-only: a full render must not create, delete, or modify any file +# under state/ or data/. The only child process is fm-fleet-snapshot.sh, itself +# read-only, so a byte-level snapshot of both trees is identical afterward. +test_render_mutates_nothing() { + local home fakebin before after + home=$(make_home readonly) + write_fixture "$home" + fakebin=$(make_fakebin "$home") + before=$(cd "$home" && find state data -type f -exec cksum {} \; | sort) + PATH="$fakebin:$PATH" FM_HOME="$home" "$VIEW" --once --no-color --width 80 >/dev/null + after=$(cd "$home" && find state data -type f -exec cksum {} \; | sort) + [ "$before" = "$after" ] || fail "a render changed state/ or data/ files" + pass "rendering the board mutates no state or data file" +} + +test_empty_fleet_renders_gracefully +test_board_bands_and_parallel +test_current_backlog_gates_are_visible +test_priority_ordering +test_done_limit_and_width +test_fail_closed_without_home +test_interval_validation +test_option_value_validation +test_render_mutates_nothing