diff --git a/bin/fm-fleet-snapshot.sh b/bin/fm-fleet-snapshot.sh index bc7f1a3c47..435bae5cf8 100755 --- a/bin/fm-fleet-snapshot.sh +++ b/bin/fm-fleet-snapshot.sh @@ -25,6 +25,12 @@ # state, source, detail, and raw line separately. # paths.status_log.last_event is historical wake-event data only, never # current state. +# paths.status_log.last_event_epoch and .age_seconds date that last append +# from the status file's modification time, so renderers can age a declared +# event without stat-ing state files themselves. Both are null when the file +# is absent or its time is unreadable, and age never goes negative. +# actions.review is the review command for a non-secondmate task +# (bin/fm-review-diff.sh, which owns base selection). # hints.open_decisions is the keyed open-decision set returned by # fm-classify-lib.sh's authoritative status_open_decisions fold and reconciled # against current_state; hints.pending_decision and hints.blocked_event are @@ -229,12 +235,21 @@ crew_state_json() { # } status_event_json() { # - local log=$1 present=0 raw='' verb='' note='' + local log=$1 present=0 raw='' verb='' note='' epoch='' age='' if [ -f "$log" ]; then present=1 raw=$(last_nonempty_line "$log" || true) verb=$(status_line_verb "$raw") note=$(status_line_note "$raw") + # Append time, used by renderers to age a declared event. It is the file's + # modification time, so it dates the last append rather than verifying the + # work; a clock skew that would age an event negatively clamps to zero. + epoch=$(file_mtime_epoch "$log") + case "$epoch" in ''|*[!0-9]*) epoch='' ;; esac + if [ -n "$epoch" ]; then + age=$((SNAPSHOT_EPOCH - epoch)) + [ "$age" -ge 0 ] || age=0 + fi fi jq -n \ --arg path "$log" \ @@ -242,7 +257,11 @@ status_event_json() { # --arg verb "$verb" \ --arg note "$note" \ --argjson present "$(bool_json "$present")" \ - '{path:$path,present:$present,kind:"event_history",last_event:{state:$verb,note:$note,raw:$raw}}' + --argjson epoch "${epoch:-null}" \ + --argjson age "${age:-null}" \ + '{path:$path,present:$present,kind:"event_history", + last_event_epoch:$epoch,age_seconds:$age, + last_event:{state:$verb,note:$note,raw:$raw}}' } first_pr_url_in_file() { # @@ -597,6 +616,7 @@ task_json_lines() { else {watch:"bin/fm-peek.sh fm-\($id)", steer:"bin/fm-send.sh fm-\($id) \u0027\u0027", + review:"bin/fm-review-diff.sh \($id)", return_channel_note:null} end) }' diff --git a/bin/fm-fleet-view.sh b/bin/fm-fleet-view.sh index 909c792b29..ff5770c049 100755 --- a/bin/fm-fleet-view.sh +++ b/bin/fm-fleet-view.sh @@ -1,34 +1,294 @@ #!/usr/bin/env bash # fm-fleet-view.sh - human renderer over fm-fleet-snapshot.sh. # +# Two renderings, one data source. The default is the captain sidebar: a narrow +# needs-you-first surface for a split pane beside a firstmate session. `--wide` +# keeps the original Markdown table rendering for a full-width read. +# # This command intentionally does not parse fleet state itself. # It shells out to fm-fleet-snapshot.sh --json and renders that stable # structured contract for humans. +# +# Read-only: it acquires no lock, drains no wakes, arms no watcher, sends to no +# worker, and writes nothing under state/ or data/. It is safe to run in a loop +# beside a live session and watcher. See docs/fleet-view.md. +# +# Usage: +# fm-fleet-view.sh captain sidebar, rendered once +# fm-fleet-view.sh --watch [secs] redraw loop, default 5s, Ctrl-C to stop +# fm-fleet-view.sh --wide full-width Markdown tables +# fm-fleet-view.sh --json the underlying snapshot +# +# WezTerm split pane: +# wezterm cli split-pane --right --percent 30 -- \ +# wsl bash -lc 'cd && bin/fm-fleet-view.sh --watch' +# +# Environment: +# NO_COLOR set to any value to disable ANSI color. +# FM_FLEET_VIEW_WIDTH render width; defaults to the terminal width, or 40. +# FM_FLEET_VIEW_TODAY YYYY-MM-DD used for "DONE TODAY"; defaults to today. set -u SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" usage() { cat <<'EOF' -usage: fm-fleet-view.sh [--json] +usage: fm-fleet-view.sh [--watch [seconds]] [--wide] [--json] -Render a human fleet view from fm-fleet-snapshot.sh. -Use --json to print the underlying snapshot. +Render the fleet from fm-fleet-snapshot.sh. +Default is the narrow captain sidebar, needs-you first. + --watch [seconds] redraw loop (default 5s); Ctrl-C exits. + --wide full-width Markdown tables. + --json print the underlying snapshot. EOF } +MODE=sidebar +INTERVAL=5 + case "${1:-}" in -h|--help) usage; exit 0 ;; - --json) "$SCRIPT_DIR/fm-fleet-snapshot.sh" --json; exit $? ;; + --json) shift; [ $# -eq 0 ] || { usage >&2; exit 2; } + "$SCRIPT_DIR/fm-fleet-snapshot.sh" --json; exit $? ;; + --wide) MODE=wide; shift ;; + --watch) + MODE=watch; shift + if [ $# -gt 0 ]; then + INTERVAL=$1; shift + case "$INTERVAL" in + ''|*[!0-9]*) echo "fm-fleet-view: refresh seconds must be a whole number" >&2; exit 2 ;; + esac + [ "$INTERVAL" -ge 1 ] || { echo "fm-fleet-view: refresh seconds must be at least 1" >&2; exit 2; } + fi + ;; "") ;; *) usage >&2; exit 2 ;; esac +[ $# -eq 0 ] || { usage >&2; exit 2; } + +command -v jq >/dev/null 2>&1 || { echo "fm-fleet-view: jq is not installed" >&2; exit 1; } + +# Color is opt-out (NO_COLOR) and only ever applies to a terminal, so a piped or +# captured render stays plain text. +COLOR=0 +if [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; then COLOR=1; fi + +C_HEAD='1' +C_NEEDS='33' +C_FLIGHT='32' +C_WAIT='2' +C_DONE='2' +C_WARN='31' + +resolve_width() { + local w=${FM_FLEET_VIEW_WIDTH:-} + if [ -z "$w" ] && [ -t 1 ]; then w=$(tput cols 2>/dev/null || true); fi + case "$w" in ''|*[!0-9]*) w=40 ;; esac + [ "$w" -ge 24 ] || w=24 + [ "$w" -le 100 ] || w=100 + printf '%s\n' "$w" +} + +paint() { # + if [ "$COLOR" = 1 ]; then printf '\033[%sm%s\033[0m\n' "$1" "$2"; else printf '%s\n' "$2"; fi +} + +# Frame buffer of "\t\t" lines. Wrapping and coloring +# happen once at flush time so a slow render never paints a half-drawn pane. +FRAME='' + +emit() { # + FRAME="$FRAME$1"$'\t1\t'"$2$3"$'\n' +} + +# A command or URL the captain copies is never folded: it goes out whole and +# lets the terminal soft-wrap it, so a double-click still selects all of it. +emit_copyable() { # + FRAME="$FRAME$1"$'\t0\t'"$2$3"$'\n' +} + +# Wrap on codepoints, not bytes: the glyphs and the em dash are multibyte, and a +# byte-counting wrap folds a 40-column line several characters early. A word +# longer than the line (a PR URL) is left whole so it stays copyable. +# shellcheck disable=SC2016 # jq owns every $ expression in this literal program. +WRAP=' + def wrap($w): + (capture("^(?[ ]*)(?.*)$")) as $m + | (($w - ($m.ind | length)) | if . < 8 then 8 else . end) as $first + # Continuation lines carry the two-space hanging indent below, so their own + # budget is that much smaller or they overrun the pane by exactly that much. + | (($first - 2) | if . < 8 then 8 else . end) as $rest + | ($m.rest | split(" ") | map(select(. != ""))) + | (if length == 0 then [""] else + reduce .[] as $word ([]; + if length == 0 then [$word] + else (if length == 1 then $first else $rest end) as $budget + | if ((.[-1] | length) + 1 + ($word | length)) <= $budget + then .[0:-1] + ["\(.[-1]) \($word)"] + else . + [$word] end + end) + end) + # Continuation lines hang under the entry glyph so one entry reads as one + # block in a narrow pane. + | to_entries | map($m.ind + (if .key == 0 then "" else " " end) + .value)[]; + (split("\t")) as $p + | ($p[0]) as $code + | ($p[1]) as $do_wrap + | ($p[2:] | join("\t")) as $text + | (if $do_wrap == "0" then $text else ($text | wrap($w)) end) + | "\($code)\t\(.)" +' + +flush_frame() { + local code text + [ -n "$FRAME" ] || return 0 + while IFS=$'\t' read -r code text; do + paint "$code" "$text" + done < <(printf '%s' "$FRAME" | jq -Rr --argjson w "$WIDTH" "$WRAP") + FRAME='' +} -command -v jq >/dev/null 2>&1 || { echo "fm-fleet-view: jq not found" >&2; exit 1; } +# Classify every live task and today's landed work into one flat record stream: +# "
\t\t". All classification lives here so the shell +# below only formats, colors, and wraps. +# shellcheck disable=SC2016 # jq owns every $ expression in this literal program. +CLASSIFY=' + def clean: (. // "") | tostring | gsub("[\\t\\r\\n]"; " ") | gsub(" +"; " ") + | sub("^ +"; "") | sub(" +$"; ""); + # A decision carries an internal routing key; the captain reads the question. + def unkey: sub("^\\[key=[^]]*\\] *"; ""); + # One glance-sized line each: a runaway note must not push the pane around. + def cap($n): if (length > $n) then (.[0:$n - 1] + "…") else . end; + def humanize($s): + if $s == null then "a moment" + elif $s < 60 then "\($s)s" + elif $s < 3600 then "\(($s / 60) | floor)m" + elif $s < 86400 then "\(($s / 3600) | floor)h" + else "\(($s / 86400) | floor)d" end; + def short($t): ($t.backlog.title // "") | clean; + def name($t): + short($t) as $s + | if $s == "" then ($t.id | tostring) else "\($t.id) \($s)" end; + def pr_name($t; $url): + ($url | capture("/pull/(?[0-9]+)") | .n) as $n + | short($t) as $s + | if $n == null then name($t) + elif $s == "" then "PR #\($n) \($t.id)" + else "PR #\($n) \($s)" end; + def age($t): humanize($t.paths.status_log.age_seconds); + def decisions($t): ($t.hints.open_decisions // []); + def decision_line($t): + (decisions($t)) as $d + | ($d | map(select(.verb == "blocked")) | first) as $blocked + | (($blocked // $d[0]).summary | clean | unkey | cap(90)) as $note + | if $blocked != null then + (if $note == "" then "blocked, needs your help" else "blocked: \($note)" end) + else + (if $note == "" then "a decision is waiting on you" else "decision: \($note)" end) + end; + def row($section; $headline; $action): "\($section)\t\($headline | clean)\t\($action | clean)"; -SNAPSHOT=$("$SCRIPT_DIR/fm-fleet-snapshot.sh" --json) || exit $? + ([.tasks[]? + | . as $t + | ($t.current_state.state // "unknown") as $state + | ($t.kind // "ship") as $kind + | ($t.mode // "") as $mode + | ($t.pr.url) as $pr + | ((decisions($t) | length) > 0) as $open + | ($t.hints.scout_report_present == true) as $report + | (($state == "done") or ($state == "failed")) as $terminal + | ($t.endpoint.exists == false) as $endpoint_gone + | if $kind == "secondmate" then + # A second mate is persistent and idles by design, so it earns a line + # only when it is actually holding something for the captain. + (if $open then row("NEEDS"; "\(name($t)) — \(decision_line($t))"; "") else empty end) + elif $endpoint_gone and ($terminal | not) then + row("NEEDS"; "\(name($t)) — worker stopped responding"; "") + elif $report and $open then + row("WAIT"; "\(name($t)) — report ready, decisions open"; $t.paths.report.path // "") + elif $open then + row("NEEDS"; "\(name($t)) — \(decision_line($t))"; "") + elif $terminal and ($pr != null) then + row("NEEDS"; "\(pr_name($t; $pr)) — checks green, awaiting merge word"; $pr) + elif $terminal and ($mode == "local-only") then + row("NEEDS"; "\(name($t)) — ready to review on your local copy"; $t.actions.review // "") + elif $terminal and $report then + row("NEEDS"; "\(name($t)) — investigation finished, findings ready"; $t.paths.report.path // "") + elif $state == "failed" then + row("NEEDS"; "\(name($t)) — work failed"; "") + elif $terminal then + row("WAIT"; "\(name($t)) — finished, wrapping up"; "") + elif $state == "paused" then + ((($t.hints.last_event_text | clean | sub("^[a-z-]+: *"; "") | unkey | cap(70))) as $why + | row("WAIT"; "\(name($t)) — \(if $why == "" then "waiting on something outside" else $why end)"; "")) + elif $state == "working" then + row("FLIGHT"; "\(name($t)) — working \(age($t))"; "") + else + row("FLIGHT"; "\(name($t)) — under way, no word for \(age($t))"; "") + end + ] + + + [.backlog.records[]? + | select(.structured == true and .state == "done") + | select(((.completion.date // "") | clean) == $today) + | row("DONE"; "\(.id) \(.title | clean)"; "") + ])[] +' + +render_sidebar() { # + local snapshot=$1 records live needs today + today=${FM_FLEET_VIEW_TODAY:-$(date +%F)} + records=$(printf '%s' "$snapshot" | jq -r --arg today "$today" "$CLASSIFY" 2>/dev/null) || records='' + + live=$(printf '%s' "$records" | grep -c -v '^DONE ' 2>/dev/null || true) + [ -n "$records" ] || live=0 + needs=$(printf '%s' "$records" | grep -c '^NEEDS ' 2>/dev/null || true) + + local header + header="⚓ FLEET · $(date +%H:%M) · $live $(plural "$live" task) · $needs need you" + emit "$C_HEAD" "" "$header" + emit "$C_WAIT" "" "$(repeat_char '━' "$WIDTH")" + + if [ -z "$records" ]; then + emit "$C_WAIT" "" "All quiet — nothing needs you." + return 0 + fi + + section "$records" NEEDS "NEEDS YOU" '●' "$C_NEEDS" + section "$records" FLIGHT "IN FLIGHT" '◐' "$C_FLIGHT" + section "$records" WAIT "WAITING" '○' "$C_WAIT" + section "$records" DONE "DONE TODAY" '✓' "$C_DONE" + + if [ "$needs" -eq 0 ]; then + emit "$C_FLIGHT" "" "Nothing needs you right now." + fi +} + +section() { # <glyph> <color> + local records=$1 key=$2 title=$3 glyph=$4 code=$5 rows count headline action + rows=$(printf '%s\n' "$records" | grep "^$key " 2>/dev/null || true) + [ -n "$rows" ] || return 0 + count=$(printf '%s\n' "$rows" | grep -c . || true) + emit "$C_HEAD" "" "$title ($count)" + while IFS=$'\t' read -r _ headline action; do + [ -n "$headline" ] || continue + emit "$code" "" "$glyph $headline" + [ -z "$action" ] || emit_copyable "$C_WAIT" " " "↳ $action" + done <<< "$rows" +} + +plural() { # <count> <singular> + if [ "$1" = 1 ]; then printf '%s\n' "$2"; else printf '%ss\n' "$2"; fi +} + +repeat_char() { # <char> <count> + local i out='' + for ((i = 0; i < $2; i++)); do out="$out$1"; done + printf '%s\n' "$out" +} -printf '%s\n' "$SNAPSHOT" | jq -r ' +render_wide() { # <snapshot-json> + printf '%s\n' "$1" | jq -r ' def dash($v): if $v == null or $v == "" then "-" else $v end; def endpoint_exists($t): if $t.endpoint.exists == null then "unknown" @@ -94,3 +354,43 @@ printf '%s\n' "$SNAPSHOT" | jq -r ' "## Secondmates", .secondmate_guidance.note ' +} + +render_once() { + local snapshot rc=0 + if ! snapshot=$("$SCRIPT_DIR/fm-fleet-snapshot.sh" --json 2>/dev/null); then + emit "$C_WARN" "" "⚠ Could not read the fleet's records just now." + flush_frame + return 1 + fi + if [ "$MODE" = wide ]; then + render_wide "$snapshot" + return $? + fi + render_sidebar "$snapshot" || rc=$? + flush_frame + return "$rc" +} + +WIDTH=$(resolve_width) + +if [ "$MODE" != watch ]; then + render_once + exit $? +fi + +cleanup_watch() { + [ -t 1 ] && printf '\033[?25h' + exit 0 +} +trap cleanup_watch INT TERM +[ -t 1 ] && printf '\033[?25l' + +while :; do + # Render into a buffer first so a slow snapshot never leaves a half-drawn pane. + WIDTH=$(resolve_width) + frame=$(render_once) + if [ -t 1 ]; then printf '\033[H\033[2J'; fi + printf '%s\n' "$frame" + sleep "$INTERVAL" +done diff --git a/bin/fm-test-run.sh b/bin/fm-test-run.sh index b1867c5328..62eb9b43db 100755 --- a/bin/fm-test-run.sh +++ b/bin/fm-test-run.sh @@ -206,7 +206,7 @@ family_for_basename() { fm-afk-inject-e2e.test.sh|fm-afk-return.test.sh) printf '%s\n' afk ;; - fm-bearings-snapshot.test.sh|fm-fleet-snapshot-view.test.sh) + fm-bearings-snapshot.test.sh|fm-fleet-snapshot-view.test.sh|fm-fleet-view.test.sh) printf '%s\n' snapshot-bearings ;; fm-backend-cmux.test.sh|fm-backend-cmux-smoke.test.sh) diff --git a/docs/architecture.md b/docs/architecture.md index b696ccccd4..9f58b63bac 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -40,7 +40,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. The semantic branch reports working only on an exact busy verdict and names the source that produced it; an unknown verdict never becomes working, never permits the status-log fallback, and never becomes a silent idle. 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 for humans - the narrow needs-you-first captain sidebar by default and Markdown tables under `--wide` ([`docs/fleet-view.md`](fleet-view.md)) - while `bin/fm-bearings-snapshot.sh` provides the bounded bearings projection, so both views consume 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/documentation-audiences.json b/docs/documentation-audiences.json index d48b545b51..04ded4ff84 100644 --- a/docs/documentation-audiences.json +++ b/docs/documentation-audiences.json @@ -256,6 +256,10 @@ "path": "docs/examples/wedge-alarm", "audience": "operator-example" }, + { + "path": "docs/fleet-view.md", + "audience": "operator-current" + }, { "path": "docs/fm-test-isolation-proof.md", "audience": "maintainer-verification" diff --git a/docs/fleet-view.md b/docs/fleet-view.md new file mode 100644 index 0000000000..d8017c6a5b --- /dev/null +++ b/docs/fleet-view.md @@ -0,0 +1,56 @@ +# Fleet view + +`bin/fm-fleet-view.sh` renders the fleet for a person to read. +Its default rendering is the captain sidebar: a narrow, needs-you-first surface meant for a split pane next to a firstmate session, so a glance answers "what is waiting on me, and what is still moving?". +`--wide` keeps the full-width Markdown tables for a whole-fleet read. + +Run `bin/fm-fleet-view.sh --help` for the exact flags; this page covers what the surface is for and what it promises. + +## Read-only + +The view never changes fleet state. +It takes no session lock, drains no queued notifications, starts no monitoring, sends nothing to any worker, cleans nothing up, and writes no file under `state/` or `data/`. +That makes it safe to leave redrawing in a pane while a live session and its monitoring operate on the same home. + +It gets there by not parsing fleet state at all. +Every field it renders comes from `bin/fm-fleet-snapshot.sh --json`, which is itself read-only and owns the schema. +`tests/fm-fleet-view.test.sh` pins the guarantee by comparing every file in a fixture home before and after a render, including modification times. + +## Usage + +```sh +bin/fm-fleet-view.sh # render once and exit +bin/fm-fleet-view.sh --watch # redraw every 5s; Ctrl-C exits +bin/fm-fleet-view.sh --watch 15 # redraw on your own interval +bin/fm-fleet-view.sh --wide # full-width Markdown tables +bin/fm-fleet-view.sh --json # the underlying snapshot +``` + +Open it beside a session in a WezTerm split pane: + +```sh +wezterm cli split-pane --right --percent 30 -- \ + wsl bash -lc 'cd <fm home> && bin/fm-fleet-view.sh --watch' +``` + +Each redraw takes a fresh snapshot of the whole home, so prefer a calm interval over a tight one on a busy fleet. + +## What the sections mean + +`NEEDS YOU` is the only section that asks for anything. +It holds decisions waiting on an answer, blockers, work that is finished and waiting for your word to merge or your eyes on a review, and any worker that has stopped responding. +A pull request entry always carries its full URL, a finished local-only task carries a runnable review command, and a stopped worker is reported rather than repaired. + +`IN FLIGHT` is work under way, with how long it has been since the worker last said anything. + +`WAITING` is work that is neither moving nor yours yet: a declared wait on something outside, or an investigation whose findings are delivered while its decisions are still being routed. + +`DONE TODAY` lists work that landed today. + +Anything the view says about a worker's own state is that worker's most recent word, not an independent verification. + +## Environment + +`NO_COLOR` disables color, which is otherwise used only when writing to a terminal. +`FM_FLEET_VIEW_WIDTH` fixes the render width instead of measuring the terminal. +`FM_FLEET_VIEW_TODAY` overrides the date used by `DONE TODAY`, which exists so tests are deterministic. diff --git a/docs/scripts.md b/docs/scripts.md index 0fa1a2c075..edd3d9aaa9 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -15,7 +15,7 @@ The shared no-mistakes gate refusal for fleet lifecycle entrypoints is summarize | `fm-startup-network.sh` | Run session start's network checks off its blocking path in a bounded detached worker, and publish the result inline or as a wake | | `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-fleet-view.sh` | Render the fleet snapshot for a person: the narrow needs-you-first captain sidebar by default, `--wide` for Markdown tables ([`docs/fleet-view.md`](fleet-view.md)) | | `fm-bearings-snapshot.sh` | Project the fleet snapshot to the compact TOON bearings view; local-only unless `--include-prs` | | `fm-update.sh` | Fast-forward-only self-update of firstmate and local or remote secondmate homes | | `fm-on.sh` | Execute one tracked Firstmate command in a configured remote secondmate home, using its job worker except for the doctor bootstrap | diff --git a/tests/fm-fleet-snapshot-view.test.sh b/tests/fm-fleet-snapshot-view.test.sh index f47c70f2fa..1ec24032eb 100755 --- a/tests/fm-fleet-snapshot-view.test.sh +++ b/tests/fm-fleet-snapshot-view.test.sh @@ -1,5 +1,6 @@ #!/usr/bin/env bash -# Behavior tests for the read-only fleet snapshot and its human renderer. +# Behavior tests for the read-only fleet snapshot and its wide Markdown +# rendering. The default captain sidebar is covered by tests/fm-fleet-view.test.sh. set -u # shellcheck source=tests/lib.sh @@ -146,7 +147,7 @@ test_empty_fleet_json() { and .main_inventory.unstructured_current_count == 0 ' >/dev/null \ || fail "empty snapshot schema or absence markers wrong: $out" - view=$(FM_HOME="$home" "$VIEW") + view=$(FM_HOME="$home" "$VIEW" --wide) assert_contains "$view" "No live task metadata found." "empty fleet view should say no live metadata" pass "empty fleet snapshot and view use explicit absence markers" } @@ -550,7 +551,7 @@ EOF and .paths.report.path == ($data + "/bold-task/report.md") and .paths.report.present == true ' >/dev/null || fail "bold task did not join to override-backed backlog and report" - view=$(PATH="$fakebin:$PATH" FM_HOME="$home" FM_DATA_OVERRIDE="$data" FM_PROJECTS_OVERRIDE="$projects" "$VIEW") + view=$(PATH="$fakebin:$PATH" FM_HOME="$home" FM_DATA_OVERRIDE="$data" FM_PROJECTS_OVERRIDE="$projects" "$VIEW" --wide) assert_contains "$view" "| bold-task | done / status-log | scout | alpha | tmux | present | $data/bold-task/report.md" \ "view should render bold in-flight row from snapshot" assert_contains "$view" "| blocked-reason | Blocked Reason | beta | ship | queued-comma - waits on queued-comma | - |" \ @@ -567,7 +568,7 @@ test_view_renders_snapshot() { home=$(make_home view) write_fixture "$home" fakebin=$(make_fakebin "$home") - view=$(PATH="$fakebin:$PATH" FM_HOME="$home" "$VIEW") + view=$(PATH="$fakebin:$PATH" FM_HOME="$home" "$VIEW" --wide) assert_contains "$view" "| ship-task | working / pane | ship | alpha | tmux | present | https://github.com/kunchenguid/firstmate/pull/9" \ "view should render ship row from snapshot" assert_contains "$view" "| queued-task | Queued Task | alpha | ship | ship-task | -" \ @@ -596,7 +597,7 @@ test_view_renders_dead_secondmate_agent_status() { "projects=alpha, beta" printf 'working: watching delegated scope\n' > "$home/state/dead-secondmate.status" fakebin=$(make_fakebin "$home") - view=$(PATH="$fakebin:$PATH" FM_HOME="$home" "$VIEW") + view=$(PATH="$fakebin:$PATH" FM_HOME="$home" "$VIEW" --wide) assert_contains "$view" "| dead-secondmate | unknown / none | secondmate | $home/secondmate-home | tmux | present / dead |" \ "view should distinguish a present secondmate endpoint from a dead agent" assert_contains "$view" "| dead-secondmate | unknown / none | secondmate | $home/secondmate-home | tmux | present / dead | - | $home/secondmate-home (absent) |" \ diff --git a/tests/fm-fleet-view.test.sh b/tests/fm-fleet-view.test.sh new file mode 100755 index 0000000000..edac8161e7 --- /dev/null +++ b/tests/fm-fleet-view.test.sh @@ -0,0 +1,322 @@ +#!/usr/bin/env bash +# Behavior tests for the captain sidebar rendering of bin/fm-fleet-view.sh. +# +# The wide Markdown rendering and the snapshot contract it consumes are covered +# by tests/fm-fleet-snapshot-view.test.sh; this file owns the needs-you-first +# sidebar: classification, age, the read-only guarantee, and the watch loop. +set -u + +# shellcheck source=tests/lib.sh +# shellcheck disable=SC1091 +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +VIEW="$ROOT/bin/fm-fleet-view.sh" +TMP_ROOT=$(fm_test_tmproot fm-fleet-view) + +command -v jq >/dev/null 2>&1 || { echo "skip: jq not found"; exit 0; } + +TODAY=2026-08-10 + +# Renders are compared as plain text, and the fixture pane width is fixed so a +# wrap regression is visible rather than terminal-dependent. +view() { # <home> [args...] + local home=$1 + shift + PATH="$FAKEBIN:$PATH" NO_COLOR=1 FM_FLEET_VIEW_WIDTH=40 FM_FLEET_VIEW_TODAY="$TODAY" \ + FM_HOME="$home" "$VIEW" "$@" +} + +# Classification assertions read the render with its narrow-pane wrapping undone, +# so a headline's wording is tested independently of where the pane folds it. +# A continuation line is exactly the two-space hanging indent, never an action +# line, which carries the copyable marker at that same indent. +unwrap() { + awk ' + NR > 1 && substr($0, 1, 2) == " " && index($0, "\342\206\263") != 3 { + printf " %s", substr($0, 3); next + } + NR > 1 { printf "\n" } + { printf "%s", $0 } + END { if (NR > 0) printf "\n" } + ' +} + +make_home() { # <name> + local home=$TMP_ROOT/$1 + mkdir -p "$home/state" "$home/data" "$home/projects" + printf '%s\n' "$home" +} + +# One fake terminal for every fixture: a task whose id contains "gone" has no +# endpoint, which is how a worker that stopped responding is modeled. +make_fakebin() { + local fb + fb=$(fm_fakebin "$TMP_ROOT/fakebin") + 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 + list-windows) sed -n 's/^window=[^:]*://p' "${FM_HOME:?}"/state/*.meta 2>/dev/null ;; + display-message) + case "$target" in + *gone*) exit 1 ;; + esac + case "$*" in + *pane_current_command*) printf 'claude\n' ;; + *) printf '%%1\n' ;; + esac + ;; + capture-pane) + case "$target" in + *gone*) exit 1 ;; + *) printf 'all quiet\n> \n' ;; + esac + ;; +esac +exit 0 +SH + chmod +x "$fb/tmux" + printf '%s\n' "$fb" +} +FAKEBIN=$(make_fakebin) + +# A crew's current state comes from its semantic busy record (bin/fm-busy-lib.sh), +# and only an idle record lets the declared status event decide the state. +record_idle() { # <home> <id> + local gen + gen=$("$ROOT/bin/fm-busy-event.sh" arm "$1/state" "$2") + "$ROOT/bin/fm-busy-event.sh" apply "$1/state" "$2" idle --gen "$gen" \ + --source claude-hook --event stop > /dev/null +} + +record_busy() { # <home> <id> + local gen + gen=$("$ROOT/bin/fm-busy-event.sh" arm "$1/state" "$2") + "$ROOT/bin/fm-busy-event.sh" apply "$1/state" "$2" busy --gen "$gen" \ + --source claude-hook --event user-prompt-submit > /dev/null +} + +add_task() { # <home> <id> <mode> <kind> <status-line> [extra-meta...] + local home=$1 id=$2 mode=$3 kind=$4 status=$5 + shift 5 + mkdir -p "$home/projects/$id" + fm_write_meta "$home/state/$id.meta" \ + "window=firstmate:fm-$id" \ + "worktree=$home/projects/$id" \ + "project=$home/projects/$id" \ + "harness=claude" \ + "kind=$kind" \ + "mode=$mode" \ + "yolo=off" \ + "$@" + printf '%s\n' "$status" > "$home/state/$id.status" +} + +test_empty_fleet_is_all_quiet() { + local home out + home=$(make_home empty) + out=$(view "$home") + expect_code 0 "$?" "an empty fleet must still render successfully" + assert_contains "$out" "All quiet" "an empty fleet should say all quiet" + assert_contains "$out" "0 tasks" "an empty fleet should count zero tasks" + assert_not_contains "$out" "NEEDS YOU" "an empty fleet should print no sections" + pass "empty fleet renders a short all-quiet view and exits 0" +} + +test_needs_you_classification() { + local home out + home=$(make_home needs) + cat > "$home/data/backlog.md" <<'EOF' +## In flight +- [ ] 3412 - contracts (repo: alpha) (kind: ship) (since 2026-08-09) +- [ ] 1905 - pane-naming (repo: alpha) (kind: ship) (since 2026-08-09) +- [ ] 7002 - vault sync (repo: alpha) (kind: ship) (since 2026-08-10) +- [ ] 7009gone - lost worker (repo: alpha) (kind: ship) (since 2026-08-10) +EOF + add_task "$home" 3412 ship ship 'needs-decision: [key=api-shape] pick REST or gRPC' + add_task "$home" 1905 ship ship 'done: PR is up' \ + "pr=https://github.com/kunchenguid/firstmate/pull/1905" + add_task "$home" 7002 local-only ship 'done: ready branch' + add_task "$home" 7009gone ship ship 'working: mid-implementation' + record_idle "$home" 3412 + record_idle "$home" 1905 + record_idle "$home" 7002 + record_idle "$home" 7009gone + + out=$(view "$home" | unwrap) + assert_contains "$out" "NEEDS YOU (4)" "all four captain-actionable tasks belong in NEEDS YOU" + + assert_contains "$out" "3412 contracts — decision: pick REST or gRPC" \ + "an open decision should surface with its question" + assert_not_contains "$out" "key=api-shape" \ + "the internal decision key must not reach the captain surface" + + assert_contains "$out" "PR #1905 pane-naming — checks green" \ + "a task holding a PR should read as awaiting the merge word" + assert_contains "$out" "https://github.com/kunchenguid/firstmate/pull/1905" \ + "a PR entry must carry its full URL, never a bare number" + + assert_contains "$out" "7002 vault sync — ready to review on" \ + "a finished local-only task should read as ready to review" + assert_contains "$out" "bin/fm-review-diff.sh 7002" \ + "a local-only entry must carry a runnable review command" + + assert_contains "$out" "worker stopped responding" \ + "a task whose worker is gone must be surfaced, not repaired" + pass "needs-you covers open decisions, PR merge word, local-only review, and a gone worker" +} + +test_in_flight_shows_age() { + local home out + home=$(make_home flight) + cat > "$home/data/backlog.md" <<'EOF' +## In flight +- [ ] 3406 - merge-strategy (repo: alpha) (kind: ship) (since 2026-08-10) +EOF + add_task "$home" 3406 ship ship 'working: rebasing onto the new base' + record_busy "$home" 3406 + touch -d '45 minutes ago' "$home/state/3406.status" + + out=$(view "$home" | unwrap) + assert_contains "$out" "IN FLIGHT (1)" "a working task belongs in flight" + assert_contains "$out" "3406 merge-strategy — working 45m" \ + "in-flight work should render a humanized age" + assert_not_contains "$out" "NEEDS YOU" "work under way must not be reported as needing the captain" + pass "in-flight work renders with a humanized age" +} + +test_paused_and_scout_wait() { + local home out + home=$(make_home waiting) + cat > "$home/data/backlog.md" <<'EOF' +## In flight +- [ ] 7001 - release gate (repo: alpha) (kind: ship) (since 2026-08-10) +- [ ] 2909 - review scout (repo: alpha) (kind: scout) (since 2026-08-08) +EOF + add_task "$home" 7001 ship ship 'paused: upstream release lands Thursday' + add_task "$home" 2909 scout scout 'needs-decision: two options for the rollout' + mkdir -p "$home/data/2909" + printf '# findings\n' > "$home/data/2909/report.md" + record_idle "$home" 7001 + record_idle "$home" 2909 + + out=$(view "$home" | unwrap) + assert_contains "$out" "WAITING (2)" "a declared wait and a delivered report both wait" + assert_contains "$out" "7001 release gate — upstream release lands Thursday" \ + "a declared external wait should render its reason" + assert_contains "$out" "2909 review scout — report ready, decisions open" \ + "a delivered report with open decisions waits rather than paging the captain" + assert_not_contains "$out" "NEEDS YOU" "neither entry should be reported as needing the captain" + pass "declared waits and delivered-report scouts render as WAITING" +} + +test_done_today_filters_by_date() { + local home out + home=$(make_home done-today) + cat > "$home/data/backlog.md" <<'EOF' +## Done +- [x] 3410 - re-verify (repo: alpha) (kind: ship) (merged 2026-08-10) +- [x] 3409 - last week's work (repo: alpha) (kind: ship) (merged 2026-08-01) +EOF + out=$(view "$home" | unwrap) + assert_contains "$out" "DONE TODAY (1)" "only today's landed work counts as done today" + assert_contains "$out" "3410 re-verify" "today's landed work should be listed" + assert_not_contains "$out" "last week" "older landed work must not appear" + pass "done-today lists only work that landed today" +} + +test_render_writes_nothing() { + local home before after + home=$(make_home readonly) + cat > "$home/data/backlog.md" <<'EOF' +## In flight +- [ ] 3412 - contracts (repo: alpha) (kind: ship) (since 2026-08-09) +EOF + add_task "$home" 3412 ship ship 'needs-decision: pick a shape' + record_idle "$home" 3412 + + # Baseline AFTER the fixture is fully built, so only the render is measured. + before=$(find "$home" -printf '%p|%y|%s|%T@\n' | sort) + view "$home" > /dev/null + view "$home" --wide > /dev/null + after=$(find "$home" -printf '%p|%y|%s|%T@\n' | sort) + [ "$before" = "$after" ] || { + printf '%s\n' "$(diff <(printf '%s\n' "$before") <(printf '%s\n' "$after") || true)" >&2 + fail "rendering the fleet must not create, remove, or touch any file in the home" + } + pass "a render performs no writes anywhere in the home" +} + +test_watch_redraws_and_exits_on_interrupt() { + local home out rc + home=$(make_home watch) + out=$TMP_ROOT/watch.out + # The loop must run in the FOREGROUND to be interruptible at all: a shell + # ignores SIGINT in the jobs it backgrounds, and a disposition inherited as + # ignored can no longer be trapped, so a backgrounded run would prove nothing. + # A helper interrupts it by pid once it is up. + ( sleep 3; pkill -INT -P $$ -f 'fm-fleet-view\.sh --watch' ) & + PATH="$FAKEBIN:$PATH" NO_COLOR=1 FM_FLEET_VIEW_WIDTH=40 FM_FLEET_VIEW_TODAY="$TODAY" \ + FM_HOME="$home" timeout 20 "$VIEW" --watch 1 > "$out" 2>&1 + rc=$? + wait + expect_code 0 "$rc" "an interrupted watch loop must exit cleanly" + [ "$(grep -c 'FLEET' "$out")" -ge 2 ] \ + || fail "a watch loop should redraw at least twice in three seconds at a 1s interval" + pass "watch redraws on its interval and exits cleanly on interrupt" +} + +test_watch_rejects_a_bad_interval() { + local home out rc + home=$(make_home badinterval) + out=$(view "$home" --watch nonsense 2>&1) && rc=0 || rc=$? + expect_code 2 "$rc" "a non-numeric refresh interval must be refused" + assert_contains "$out" "whole number" "the refusal should name the concrete requirement" + pass "an invalid refresh interval is refused rather than silently defaulted" +} + +test_missing_backlog_still_renders_live_work() { + local home out + home=$(make_home nobacklog) + add_task "$home" 3412 ship ship 'working: no backlog entry exists' + record_busy "$home" 3412 + out=$(view "$home") + expect_code 0 "$?" "an absent backlog must not crash the render" + assert_contains "$out" "3412" "a task with no backlog title should still render by its number" + pass "an absent backlog degrades to task numbers instead of failing" +} + +test_narrow_pane_never_overflows() { + local home wide + home=$(make_home narrow) + cat > "$home/data/backlog.md" <<'EOF' +## In flight +- [ ] 3412 - a deliberately long work item title that will not fit a narrow pane (repo: alpha) (kind: ship) (since 2026-08-09) +EOF + add_task "$home" 3412 ship ship 'needs-decision: choose between the queue-backed design and the direct call path' + record_idle "$home" 3412 + + # Width is counted in characters, not bytes: the section glyphs and the em + # dash are multibyte, so a byte-counting wrap folds a 40-column pane early. + # jq measures codepoints regardless of locale, which awk and wc do not. + wide=$(view "$home" | jq -Rr 'select(length > 40) | "\(length): \(.)"') + [ -z "$wide" ] || fail "no rendered line may exceed the pane width: $wide" + pass "a narrow pane wraps prose without overflowing its width" +} + +test_empty_fleet_is_all_quiet +test_needs_you_classification +test_narrow_pane_never_overflows +test_in_flight_shows_age +test_paused_and_scout_wait +test_done_today_filters_by_date +test_render_writes_nothing +test_watch_redraws_and_exits_on_interrupt +test_watch_rejects_a_bad_interval +test_missing_backlog_still_renders_live_work