diff --git a/AGENTS.md b/AGENTS.md index 4c10acf33f4..855d25dec27 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -495,6 +495,7 @@ bin/fm-watch-arm.sh --restart # home-scoped forced restart; never a broad pkill bin/fm-watch-session.sh start # durable home-scoped tmux runner for lanes without reliable tracked background tasks bin/fm-watch-session.sh --status # report whether this home's runner window is live bin/fm-watch.sh # the watcher itself; exits with: signal|stale|check|heartbeat +bin/fm-supervise.sh # read-only checklist/JSON view of current work; never mutates state, tmux, git, treehouse, or GitHub bin/fm-wake-drain.sh # drain queued wake records at turn start; asserts guard after draining bin/fm-crew-state.sh # one-line current-state read; reconciles matching run-step, pane, and status log ``` @@ -510,6 +511,8 @@ On wake, in order of cheapness: 5. `heartbeat:` a heartbeat wake now reaches you only when the watcher's bash fleet-scan caught a captain-relevant status the per-wake path missed (no-change heartbeats are absorbed in bash, never surfaced), so treat it as "something turned up" and review the whole fleet: read each crewmate's current state with `bin/fm-crew-state.sh ` (the cheap first read - it reconciles the authoritative run-step over a possibly-stale status-log line, so a crewmate whose gate you already resolved no longer reads as still parked), peek panes that look off, check PR-ready tasks for merge, reconcile data/backlog.md, then re-arm the watcher. Do not report that the fleet is unchanged. +When the picture is unclear or a display surface needs the shared decision model, run `bin/fm-supervise.sh` for a read-only checklist or `bin/fm-supervise.sh --json` for the `firstmate.supervision.v1` model. The command may report watcher proof as `unknown` when the current sandbox cannot see the watcher process; prove liveness with `bin/fm-watch-arm.sh` or `bin/fm-watch-session.sh --status` before treating that as an actual down watcher. + Heartbeats back off exponentially while they are the only wakes firing (600s doubling to a 2h cap - an idle fleet stops burning turns); any signal, stale, or check wake resets the cadence to the base interval. Due per-task checks run before signal scanning so chatty crewmate status updates cannot starve slow polls like merge detection. diff --git a/README.md b/README.md index 46034bbe821..c60005a4b5f 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,7 @@ This is.. a directory that turns any agent into your firstmate, and you the capt - **Explicit project modes** - each project ships via `no-mistakes`, `direct-PR`, or `local-only`, with an optional `+yolo` autonomy flag. - **Optional secondmates** - opt in to persistent domain supervisors that run from isolated firstmate homes with their own `FM_HOME`, state, projects, and session lock, kept on the primary firstmate version by guarded local fast-forwards. - **Event-driven, zero-token supervision** - a bash watcher sleeps on the fleet and wakes the first mate only when something needs you. +- **Read-only supervision view** - `bin/fm-supervise.sh` turns current state, tmux, git, watcher, and optional GitHub reads into a stable checklist or `firstmate.supervision.v1` JSON without changing anything. - **Optional X mode** - opt in with one local `.env` token so firstmate can answer your public `@myfirstmate` mentions, act on normal reversible mention requests through the same lifecycle as chat requests, and report public-safe outcomes without changing non-X behavior; dry-run preview records would-be replies locally before go-live. - **Guarded by construction** - the first mate is read-only over your projects outside guarded clone refreshes, safe branch pruning, and approved `local-only` fast-forward merges; crewmates make every project change behind your merge approval. - **Restart-proof** - all state lives on disk and in tmux; kill the session anytime and the next one reconciles and carries on. @@ -109,6 +110,7 @@ Outside tmux, crewmates land in a detached `firstmate` session you can attach to You chat with the first mate. It routes each request to a crewmate in its own tmux window and git worktree, supervises the fleet with a zero-token event-driven watcher, and brings you finished PRs, approved local merges, or investigation reports. +When the current fleet state is unclear, `bin/fm-supervise.sh` gives a passive read-only checklist, and `bin/fm-supervise.sh --json` exposes the same shared model for display tools such as Radar. Persistent secondmate homes are linked firstmate worktrees; startup syncs live ones and secondmate launch syncs the target home to the primary default-branch commit without fetching from origin when it is safe. When a routed request goes to a secondmate, firstmate marks it so the answer returns through status or a document pointer; direct typing into that secondmate window stays conversational. A presence-gated sub-supervisor (`/afk`) can self-handle routine events and batch only what matters while you step away. diff --git a/bin/fm-supervise.sh b/bin/fm-supervise.sh new file mode 100755 index 00000000000..1d7b057cc07 --- /dev/null +++ b/bin/fm-supervise.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# Print a read-only Firstmate operational supervision checklist. +set -eu + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=bin/fm-supervision-model.sh +. "$SCRIPT_DIR/fm-supervision-model.sh" + +MODE=text +INCLUDE_OK=0 +DEFAULT_REMINDERS=1 +EXTERNAL_PRS="" + +while [ "$#" -gt 0 ]; do + case "$1" in + --text) + MODE=text + ;; + --json) + MODE=json + ;; + --schema) + MODE=schema + ;; + --include-ok) + INCLUDE_OK=1 + ;; + --no-default-reminders) + DEFAULT_REMINDERS=0 + ;; + --external-pr) + shift + if [ "$#" -eq 0 ]; then + fm_supervision_usage >&2 + exit 2 + fi + EXTERNAL_PRS="${EXTERNAL_PRS:+$EXTERNAL_PRS }$1" + ;; + --help|-h) + fm_supervision_usage + exit 0 + ;; + *) + fm_supervision_usage >&2 + exit 2 + ;; + esac + shift +done + +export FM_SUPERVISE_INCLUDE_OK="$INCLUDE_OK" +export FM_SUPERVISE_DEFAULT_REMINDERS_ENABLED="$DEFAULT_REMINDERS" +export FM_SUPERVISE_EXTERNAL_PRS="$EXTERNAL_PRS" + +fm_supervision_collect_and_emit "$MODE" diff --git a/bin/fm-supervision-model.sh b/bin/fm-supervision-model.sh new file mode 100755 index 00000000000..af81ccc98af --- /dev/null +++ b/bin/fm-supervision-model.sh @@ -0,0 +1,927 @@ +#!/usr/bin/env bash +# Read-only Firstmate supervision model. +# Sourceable library used by bin/fm-supervise.sh and future displays. + +fm_supervision_usage() { + cat <<'USAGE' +Usage: fm-supervise.sh [--text|--json|--schema] [--include-ok] [--no-default-reminders] [--external-pr ] [--help] + +Modes: + --text print captain-facing checklist (default) + --json print firstmate.supervision.v1 JSON + --schema print the v1 JSON schema and exit + +Inputs: + --external-pr include one extra GitHub PR reminder; repeatable + --no-default-reminders omit built-in default reminders + --include-ok include low-priority OK/watch items in text output + +Other: + --help print usage +USAGE +} + +fm_supervision_schema_json() { + cat <<'JSON' +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "firstmate.supervision.v1", + "type": "object", + "required": ["schema_version", "generated_at", "home", "read_only", "sources", "summary", "checklist", "tasks", "worktrees", "external_reminders"], + "properties": { + "schema_version": { "const": "firstmate.supervision.v1" }, + "generated_at": { "type": "string", "format": "date-time" }, + "home": { "type": "string" }, + "read_only": { "const": true }, + "sources": { + "type": "object", + "required": ["state", "backlog", "tmux", "treehouse", "git", "github", "watcher"], + "additionalProperties": { "$ref": "#/$defs/source" } + }, + "summary": { + "type": "object", + "required": ["level", "tasks_total", "actions_total", "high_total", "medium_total", "github_state", "watcher_state"], + "properties": { + "level": { "enum": ["ok", "watch", "action", "blocked"] }, + "tasks_total": { "type": "integer" }, + "actions_total": { "type": "integer" }, + "high_total": { "type": "integer" }, + "medium_total": { "type": "integer" }, + "github_state": { "enum": ["ok", "partial", "unavailable", "skipped"] }, + "watcher_state": { "enum": ["running", "unknown", "skipped"] } + }, + "additionalProperties": false + }, + "checklist": { "type": "array", "items": { "$ref": "#/$defs/checklist_item" } }, + "tasks": { "type": "array", "items": { "$ref": "#/$defs/task" } }, + "worktrees": { "type": "array", "items": { "$ref": "#/$defs/worktree" } }, + "external_reminders": { "type": "array", "items": { "$ref": "#/$defs/external_reminder" } } + }, + "$defs": { + "source": { + "type": "object", + "required": ["ok", "detail"], + "properties": { + "ok": { "type": "boolean" }, + "detail": { "type": "string" } + }, + "additionalProperties": false + }, + "checklist_item": { + "type": "object", + "required": ["id", "severity", "owner", "action", "why", "task_id", "project", "pr_url", "evidence", "read_only_commands"], + "properties": { + "id": { "type": "string" }, + "severity": { "enum": ["high", "medium", "low", "info"] }, + "owner": { "enum": ["firstmate", "captain", "worker", "external", "unknown"] }, + "action": { "type": "string" }, + "why": { "type": "string" }, + "task_id": { "type": "string" }, + "project": { "type": "string" }, + "pr_url": { "type": "string" }, + "evidence": { "type": "array", "items": { "type": "string" } }, + "read_only_commands": { "type": "array", "items": { "type": "string" } } + }, + "additionalProperties": false + }, + "task": { + "type": "object", + "required": ["id", "project", "kind", "mode", "yolo", "harness", "route_profile", "route_harness", "route_model", "route_effort", "window", "window_live", "worktree", "branch", "recorded_branch", "dirty_count", "last_status", "turn_ended", "pr", "classification", "next", "evidence"], + "properties": { + "id": { "type": "string" }, + "project": { "type": "string" }, + "kind": { "type": "string" }, + "mode": { "type": "string" }, + "yolo": { "type": "string" }, + "harness": { "type": "string" }, + "route_profile": { "type": "string" }, + "route_harness": { "type": "string" }, + "route_model": { "type": "string" }, + "route_effort": { "type": "string" }, + "window": { "type": "string" }, + "window_live": { "type": "boolean" }, + "worktree": { "type": "string" }, + "branch": { "type": "string" }, + "recorded_branch": { "type": "string" }, + "dirty_count": { "type": "integer" }, + "last_status": { "type": "string" }, + "turn_ended": { "type": "boolean" }, + "pr": { "$ref": "#/$defs/pr" }, + "classification": { "type": "string" }, + "next": { "$ref": "#/$defs/next" }, + "evidence": { "type": "array", "items": { "type": "string" } } + }, + "additionalProperties": false + }, + "pr": { + "type": "object", + "required": ["url", "state", "ci_state", "mergeable_state"], + "properties": { + "url": { "type": "string" }, + "state": { "enum": ["none", "open", "merged", "closed", "unknown"] }, + "ci_state": { "enum": ["success", "failure", "error", "pending", "none", "unknown"] }, + "mergeable_state": { "type": "string" } + }, + "additionalProperties": false + }, + "next": { + "type": "object", + "required": ["owner", "action"], + "properties": { + "owner": { "enum": ["firstmate", "captain", "worker", "external", "unknown"] }, + "action": { "type": "string" } + }, + "additionalProperties": false + }, + "worktree": { + "type": "object", + "required": ["path", "project", "branch", "dirty_count", "has_active_task", "classification", "next", "evidence"], + "properties": { + "path": { "type": "string" }, + "project": { "type": "string" }, + "branch": { "type": "string" }, + "dirty_count": { "type": "integer" }, + "has_active_task": { "type": "boolean" }, + "classification": { "type": "string" }, + "next": { "$ref": "#/$defs/next" }, + "evidence": { "type": "array", "items": { "type": "string" } } + }, + "additionalProperties": false + }, + "external_reminder": { + "type": "object", + "required": ["url", "state", "ci_state", "mergeable_state", "classification", "next", "evidence"], + "properties": { + "url": { "type": "string" }, + "state": { "enum": ["open", "merged", "closed", "unknown"] }, + "ci_state": { "enum": ["success", "failure", "error", "pending", "none", "unknown"] }, + "mergeable_state": { "type": "string" }, + "classification": { "type": "string" }, + "next": { "$ref": "#/$defs/next" }, + "evidence": { "type": "array", "items": { "type": "string" } } + }, + "additionalProperties": false + } + }, + "additionalProperties": false +} +JSON +} + +fm_supervision_paths() { + local script_dir root + script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + root="${FM_ROOT_OVERRIDE:-$(cd "$script_dir/.." && pwd)}" + FM_SUPERVISION_ROOT="$root" + FM_SUPERVISION_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$root}}" + FM_SUPERVISION_STATE="${FM_STATE_OVERRIDE:-$FM_SUPERVISION_HOME/state}" + FM_SUPERVISION_DATA="${FM_DATA_OVERRIDE:-$FM_SUPERVISION_HOME/data}" + FM_SUPERVISION_PROJECTS="${FM_PROJECTS_OVERRIDE:-$FM_SUPERVISION_HOME/projects}" +} + +fm_supervision_field() { + local value=${1:-} + value=${value//$'\t'/ } + value=${value//$'\r'/ } + value=${value//$'\n'/ } + [ -n "$value" ] || value=none + printf '%s' "$value" +} + +fm_supervision_line_append() { + local __var=$1 line=$2 current + current=${!__var-} + if [ -n "$current" ]; then + printf -v "$__var" '%s\n%s' "$current" "$line" + else + printf -v "$__var" '%s' "$line" + fi +} + +fm_supervision_meta_value() { + local meta_file=$1 key=$2 + [ -f "$meta_file" ] || return 0 + awk -F= -v key="$key" '$1 == key { value = substr($0, length(key) + 2) } END { print value }' "$meta_file" 2>/dev/null +} + +fm_supervision_last_status() { + local status_file=$1 + [ -f "$status_file" ] || return 0 + awk 'NF { line = $0 } END { print line }' "$status_file" 2>/dev/null +} + +fm_supervision_status_pr_url() { + local text=${1:-} + printf '%s\n' "$text" | grep -Eo 'https://github.com/[^[:space:])"]+/[^[:space:])"]+/pull/[0-9]+' | tail -1 +} + +fm_supervision_json_escape() { + local value=${1:-} + value=${value//\\/\\\\} + value=${value//\"/\\\"} + value=${value//$'\b'/\\b} + value=${value//$'\f'/\\f} + value=${value//$'\n'/\\n} + value=${value//$'\r'/\\r} + value=${value//$'\t'/\\t} + printf '%s' "$value" +} + +fm_supervision_bool() { + case ${1:-} in + true|1|yes) printf 'true' ;; + *) printf 'false' ;; + esac +} + +fm_supervision_window_live() { + local target=$1 + [ -n "$target" ] || return 1 + command -v tmux >/dev/null 2>&1 || return 1 + tmux display-message -p -t "$target" "#{window_name}" >/dev/null 2>&1 +} + +fm_supervision_worktree_dirty_count() { + local path=$1 + [ -d "$path" ] || { + printf '0' + return 1 + } + git -C "$path" rev-parse --is-inside-work-tree >/dev/null 2>&1 || { + printf '0' + return 1 + } + git -C "$path" status --porcelain --untracked-files=all 2>/dev/null | wc -l | tr -d ' ' +} + +fm_supervision_branch() { + local path=$1 branch + [ -d "$path" ] || return 1 + branch=$(git -C "$path" branch --show-current 2>/dev/null) || branch= + if [ -n "$branch" ]; then + printf '%s' "$branch" + elif git -C "$path" rev-parse --is-inside-work-tree >/dev/null 2>&1; then + printf 'detached' + else + return 1 + fi +} + +fm_supervision_worktree_list() { + local project=$1 + [ -d "$project/.git" ] || [ -f "$project/.git" ] || return 1 + git -C "$project" worktree list --porcelain 2>/dev/null | awk '/^worktree / { sub(/^worktree /, ""); print }' +} + +fm_supervision_timeout_cmd() { + if command -v timeout >/dev/null 2>&1; then + printf 'timeout' + elif command -v gtimeout >/dev/null 2>&1; then + printf 'gtimeout' + fi +} + +fm_supervision_treehouse_status() { + local project=$1 timeout_cmd + [ -d "$project" ] || return 1 + command -v treehouse >/dev/null 2>&1 || return 1 + timeout_cmd=$(fm_supervision_timeout_cmd) + if [ -n "$timeout_cmd" ]; then + (cd "$project" && "$timeout_cmd" "${FM_SUPERVISE_TREEHOUSE_TIMEOUT:-5}" treehouse status >/dev/null 2>&1) + else + (cd "$project" && treehouse status >/dev/null 2>&1) + fi +} + +fm_supervision_pr_from_url() { + local url=$1 + printf '%s\n' "$url" | sed -n 's#^https://github.com/\([^/][^/]*\)/\([^/][^/]*\)/pull/\([0-9][0-9]*\).*#\1/\2 \3#p' +} + +fm_supervision_yaml_value() { + local key=$1 + awk -F': ' -v key="$key" '$1 == key { gsub(/^"|"$/, "", $2); print $2; exit }' +} + +fm_supervision_yaml_nested_head_sha() { + awk ' + /^head:/ { in_head = 1; next } + /^[^[:space:]]/ && in_head { in_head = 0 } + in_head && /^[[:space:]]+sha:/ { sub(/^[[:space:]]+sha:[[:space:]]*/, ""); print; exit } + ' +} + +fm_supervision_gh_api_get() { + local path=$1 timeout_cmd + command -v gh-axi >/dev/null 2>&1 || return 127 + timeout_cmd=$(fm_supervision_timeout_cmd) + if [ -n "$timeout_cmd" ]; then + "$timeout_cmd" "${FM_SUPERVISE_GH_TIMEOUT:-5}" gh-axi api GET "$path" 2>/dev/null + else + gh-axi api GET "$path" 2>/dev/null + fi +} + +fm_supervision_gh_pr() { + local url=$1 parsed repo number out state merged mergeable_state sha status_out ci_state total_count + parsed=$(fm_supervision_pr_from_url "$url") || return 1 + [ -n "$parsed" ] || return 1 + repo=${parsed% *} + number=${parsed##* } + out=$(fm_supervision_gh_api_get "/repos/$repo/pulls/$number") || return 1 + state=$(printf '%s\n' "$out" | fm_supervision_yaml_value state) + merged=$(printf '%s\n' "$out" | fm_supervision_yaml_value merged) + mergeable_state=$(printf '%s\n' "$out" | fm_supervision_yaml_value mergeable_state) + sha=$(printf '%s\n' "$out" | fm_supervision_yaml_nested_head_sha) + [ -n "$state" ] || return 1 + [ "$state" = closed ] && state=closed + [ "$merged" = true ] && state=merged + ci_state=unknown + total_count=unknown + if [ -n "$sha" ]; then + status_out=$(fm_supervision_gh_api_get "/repos/$repo/commits/$sha/status") || status_out= + if [ -n "$status_out" ]; then + ci_state=$(printf '%s\n' "$status_out" | fm_supervision_yaml_value state) + total_count=$(printf '%s\n' "$status_out" | fm_supervision_yaml_value total_count) + [ -n "$ci_state" ] || ci_state=unknown + [ "$total_count" = 0 ] && ci_state=none + fi + fi + printf '%s\t%s\t%s\t%s\t%s\t%s\n' "$state" "$ci_state" "${mergeable_state:-unknown}" "$repo" "${sha:-unknown}" "$total_count" +} + +fm_supervision_path_age() { + local path=$1 m + [ -e "$path" ] || { + printf '999999' + return 1 + } + if [ "$(uname)" = Darwin ]; then + m=$(stat -f %m "$path" 2>/dev/null) || m= + else + m=$(stat -c %Y "$path" 2>/dev/null) || m= + fi + [ -n "$m" ] || { + printf '999999' + return 1 + } + printf '%s' "$(( $(date +%s) - m ))" +} + +fm_supervision_pid_alive() { + local pid=$1 + case "$pid" in ''|*[!0-9]*) return 1 ;; esac + kill -0 "$pid" 2>/dev/null +} + +fm_supervision_watcher_status() { + local task_count=$1 lock="$FM_SUPERVISION_STATE/.watch.lock" beat="$FM_SUPERVISION_STATE/.last-watcher-beat" + local grace=${FM_GUARD_GRACE:-300} pid lock_home lock_path age state ok detail + if [ "$task_count" -eq 0 ]; then + printf 'true\tskipped\tno active task metadata; watcher proof not required' + return 0 + fi + pid=$(cat "$lock/pid" 2>/dev/null || true) + lock_home=$(cat "$lock/fm-home" 2>/dev/null || true) + lock_path=$(cat "$lock/watcher-path" 2>/dev/null || true) + age=$(fm_supervision_path_age "$beat" 2>/dev/null || printf '999999') + ok=false + state=unknown + detail="watcher not proved for this home; use bin/fm-watch-arm.sh or bin/fm-watch-session.sh --status for live proof" + if [ "$lock_home" = "$FM_SUPERVISION_HOME" ] && [ "$lock_path" = "$FM_SUPERVISION_ROOT/bin/fm-watch.sh" ] && [ "$age" -lt "$grace" ]; then + if fm_supervision_pid_alive "$pid"; then + ok=true + state=running + detail="watch lock and fresh beacon proved locally" + else + detail="fresh watcher beacon but pid not visible from this environment; treat as unknown, not proved down" + fi + elif [ "$age" -lt "$grace" ]; then + detail="fresh watcher beacon exists, but lock does not prove this home/path" + fi + printf '%s\t%s\t%s' "$ok" "$state" "$detail" +} + +fm_supervision_classify_task() { + local id=$1 kind=$2 mode=$3 yolo=$4 window_live=$5 worktree=$6 last_status=$7 pr_url=$8 pr_state=$9 ci_state=${10} + local classification=running severity=info owner=worker action="Monitor worker progress." why="Worker has no captain-facing status yet." + if [ -n "$worktree" ] && [ ! -e "$worktree" ]; then + classification=stale_treehouse_state + severity=high + owner=firstmate + action="Reconcile treehouse state before sending or closing the worker." + why="Recorded worktree path is missing." + elif [ "$window_live" = false ] && [ "$kind" = secondmate ]; then + classification=missing_window_existing_meta + severity=high + owner=firstmate + action="Respawn or retire the secondmate only after checking its home." + why="Task meta exists but the recorded tmux window is missing." + elif [ "$window_live" = false ] && [ -n "$id" ]; then + classification=missing_window_existing_meta + severity=high + owner=firstmate + action="Reconcile task from meta, status, treehouse, and git before taking next action." + why="Task meta exists but the recorded tmux window is missing." + elif [ "$pr_state" = merged ] && [ "$window_live" = true ]; then + classification=merged_pr_live_worker + severity=high + owner=firstmate + action="Close the worker after confirming the PR is merged." + why="The PR is merged and the worker window is still live." + elif [ "$pr_state" = open ] && [ "$ci_state" = success ] && printf '%s\n' "$last_status" | grep -qiE 'done:|checks green|PR ready'; then + classification=pr_open_ci_green + severity=high + if [ "$yolo" = on ]; then owner=firstmate; else owner=captain; fi + action="Review and merge when approved." + why="The PR is open, CI is green, and the worker reported done." + elif [ "$pr_state" = open ] && [ "$mode" = direct-PR ] && [ "$ci_state" = none ] && printf '%s\n' "$last_status" | grep -qiE 'done:|PR ready'; then + classification=direct_pr_open_no_ci_ready + severity=high + if [ "$yolo" = on ]; then owner=firstmate; else owner=captain; fi + action="Review and merge when approved." + why="The direct-PR task has an open PR with no CI statuses and the worker reported done." + elif [ "$pr_state" = open ] && printf '%s\n' "$ci_state" | grep -Eq '^(failure|error)$'; then + classification=pr_open_ci_failing + severity=high + owner=worker + action="Ask worker to fix failing CI." + why="The PR is open and CI is failing." + elif printf '%s\n' "$last_status" | grep -q '^done:' && [ -z "$pr_url" ] && [ "$mode" = local-only ] && printf '%s\n' "$last_status" | grep -qi 'ready in branch'; then + classification=local_only_ready_for_review + severity=medium + owner=firstmate + action="Review the local branch diff." + why="The local-only worker reported a ready branch." + elif printf '%s\n' "$last_status" | grep -q '^done:' && [ -z "$pr_url" ] && { [ "$mode" = no-mistakes ] || [ "$mode" = direct-PR ]; }; then + classification=worker_done_no_pr + severity=medium + owner=firstmate + if [ "$mode" = no-mistakes ]; then + action="Start validation or ask worker for PR evidence." + else + action="Ask worker for the PR URL or confirm local-only mode." + fi + why="The worker reported done but no PR URL is recorded." + elif printf '%s\n' "$last_status" | grep -q '^blocked:'; then + classification=worker_blocked + severity=high + owner=captain + action="Resolve the worker blocker." + why="$last_status" + elif printf '%s\n' "$last_status" | grep -q '^needs-decision:'; then + classification=worker_needs_decision + severity=high + owner=captain + action="Make the requested decision." + why="$last_status" + elif printf '%s\n' "$last_status" | grep -q '^failed:'; then + classification=worker_failed + severity=high + owner=firstmate + action="Inspect the worker failure and decide the next step." + why="$last_status" + fi + printf '%s\t%s\t%s\t%s\t%s\n' "$classification" "$severity" "$owner" "$action" "$why" +} + +fm_supervision_external_classification() { + local url=$1 state=$2 ci_state=$3 + local classification severity owner action why + if [ "$state" = open ] && [ "$ci_state" = none ]; then + classification=external_open_ci_none + severity=medium + owner=captain + action="Review when ready; do not treat as CI-green." + why="The PR is open and no CI statuses are configured." + elif [ "$state" = open ] && [ "$ci_state" = success ]; then + classification=external_open_ci_green + severity=medium + owner=captain + action="Review when ready." + why="The PR is open and CI is green." + elif [ "$state" = unknown ]; then + classification=external_pr_unknown + severity=medium + owner=firstmate + action="Use local state only; GitHub state is unknown." + why="GitHub could not be read for $url." + else + classification=external_pr_watch + severity=info + owner=external + action="No immediate action." + why="External PR state is $state." + fi + printf '%s\t%s\t%s\t%s\t%s\n' "$classification" "$severity" "$owner" "$action" "$why" +} + +fm_supervision_checklist_record() { + local id=$1 severity=$2 owner=$3 action=$4 why=$5 task_id=$6 project=$7 pr_url=$8 evidence=$9 + printf 'checklist\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \ + "$(fm_supervision_field "$id")" "$(fm_supervision_field "$severity")" "$(fm_supervision_field "$owner")" \ + "$(fm_supervision_field "$action")" "$(fm_supervision_field "$why")" "$(fm_supervision_field "$task_id")" \ + "$(fm_supervision_field "$project")" "$(fm_supervision_field "$pr_url")" "$(fm_supervision_field "$evidence")" +} + +fm_supervision_collect() { + fm_supervision_paths + local records= source_records= checklist_records= task_records= worktree_records= external_records= + local state_ok=true backlog_ok=true tmux_ok=true treehouse_ok=true git_ok=true github_ok=true github_detail="gh-axi api GET only" + local task_count=0 checklist_count=0 high_count=0 medium_count=0 github_state=ok watcher_state=skipped watcher_ok=true watcher_detail= + local referenced_worktrees="|" + local meta id project kind mode yolo harness route_profile route_harness route_model route_effort window worktree recorded_branch branch dirty_count last_status turn_ended pr_url pr_data pr_state ci_state mergeable_state + local class_data classification severity owner action why evidence line status_pr window_live treehouse_failed=false + + [ -d "$FM_SUPERVISION_STATE" ] || state_ok=false + [ -f "$FM_SUPERVISION_DATA/backlog.md" ] || backlog_ok=false + command -v tmux >/dev/null 2>&1 || tmux_ok=false + command -v treehouse >/dev/null 2>&1 || treehouse_ok=false + command -v git >/dev/null 2>&1 || git_ok=false + if ! command -v gh-axi >/dev/null 2>&1; then + github_ok=false + github_detail="gh-axi missing; PR states unknown" + github_state=unavailable + fi + + if [ -d "$FM_SUPERVISION_STATE" ]; then + for meta in "$FM_SUPERVISION_STATE"/*.meta; do + [ -e "$meta" ] || continue + treehouse_failed=false + id=$(basename "$meta" .meta) + project=$(fm_supervision_meta_value "$meta" project) + kind=$(fm_supervision_meta_value "$meta" kind); [ -n "$kind" ] || kind=ship + mode=$(fm_supervision_meta_value "$meta" mode); [ -n "$mode" ] || mode=no-mistakes + yolo=$(fm_supervision_meta_value "$meta" yolo); [ -n "$yolo" ] || yolo=off + harness=$(fm_supervision_meta_value "$meta" harness); [ -n "$harness" ] || harness=unknown + route_profile=$(fm_supervision_meta_value "$meta" route_profile); [ -n "$route_profile" ] || route_profile=unknown + route_harness=$(fm_supervision_meta_value "$meta" route_harness); [ -n "$route_harness" ] || route_harness=unknown + route_model=$(fm_supervision_meta_value "$meta" route_model); [ -n "$route_model" ] || route_model=unknown + route_effort=$(fm_supervision_meta_value "$meta" route_effort); [ -n "$route_effort" ] || route_effort=unknown + window=$(fm_supervision_meta_value "$meta" window) + worktree=$(fm_supervision_meta_value "$meta" worktree) + recorded_branch=$(fm_supervision_meta_value "$meta" branch); [ -n "$recorded_branch" ] || recorded_branch=unknown + [ -n "$worktree" ] && referenced_worktrees="$referenced_worktrees$worktree|" + last_status=$(fm_supervision_last_status "$FM_SUPERVISION_STATE/$id.status") + turn_ended=false + [ -e "$FM_SUPERVISION_STATE/$id.turn-ended" ] && turn_ended=true + pr_url=$(fm_supervision_meta_value "$meta" pr) + status_pr=$(fm_supervision_status_pr_url "$last_status") + [ -n "$pr_url" ] || pr_url=$status_pr + if fm_supervision_window_live "$window"; then window_live=true; else window_live=false; fi + branch=$(fm_supervision_branch "$worktree" 2>/dev/null) || branch=unknown + dirty_count=$(fm_supervision_worktree_dirty_count "$worktree" 2>/dev/null) || dirty_count=0 + pr_state=none + ci_state=none + mergeable_state=unknown + if [ -n "$pr_url" ]; then + if pr_data=$(fm_supervision_gh_pr "$pr_url"); then + pr_state=$(printf '%s' "$pr_data" | awk -F '\t' '{ print $1 }') + ci_state=$(printf '%s' "$pr_data" | awk -F '\t' '{ print $2 }') + mergeable_state=$(printf '%s' "$pr_data" | awk -F '\t' '{ print $3 }') + else + pr_state=unknown + ci_state=unknown + mergeable_state=unknown + github_ok=false + github_state=partial + github_detail="one or more PR reads failed; affected PR states unknown" + fi + fi + if [ -n "$project" ] && [ -d "$FM_SUPERVISION_PROJECTS/$project" ]; then + if ! fm_supervision_treehouse_status "$FM_SUPERVISION_PROJECTS/$project"; then + treehouse_failed=true + treehouse_ok=false + fi + fi + class_data=$(fm_supervision_classify_task "$id" "$kind" "$mode" "$yolo" "$window_live" "$worktree" "$last_status" "$pr_url" "$pr_state" "$ci_state") + classification=$(printf '%s' "$class_data" | awk -F '\t' '{ print $1 }') + severity=$(printf '%s' "$class_data" | awk -F '\t' '{ print $2 }') + owner=$(printf '%s' "$class_data" | awk -F '\t' '{ print $3 }') + action=$(printf '%s' "$class_data" | awk -F '\t' '{ print $4 }') + why=$(printf '%s' "$class_data" | awk -F '\t' '{ print $5 }') + if [ "$treehouse_failed" = true ] && [ "$classification" = running ]; then + classification=stale_treehouse_state + severity=high + owner=firstmate + action="Reconcile treehouse state before sending or closing the worker." + why="treehouse status failed for the project." + fi + evidence="meta=$(basename "$meta"); status=${last_status:-none}; window_live=$window_live; pr_state=$pr_state; ci_state=$ci_state; mergeable_state=$mergeable_state" + line=$(printf 'task\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s' \ + "$(fm_supervision_field "$id")" "$(fm_supervision_field "$project")" "$(fm_supervision_field "$kind")" \ + "$(fm_supervision_field "$mode")" "$(fm_supervision_field "$yolo")" "$(fm_supervision_field "$harness")" \ + "$(fm_supervision_field "$route_profile")" "$(fm_supervision_field "$route_harness")" "$(fm_supervision_field "$route_model")" "$(fm_supervision_field "$route_effort")" \ + "$(fm_supervision_field "$window")" "$window_live" "$(fm_supervision_field "$worktree")" "$(fm_supervision_field "$branch")" "$(fm_supervision_field "$recorded_branch")" "$dirty_count" \ + "$(fm_supervision_field "$last_status")" "$turn_ended" "$(fm_supervision_field "$pr_url")" \ + "$pr_state" "$ci_state" "$mergeable_state" "$classification" "$severity" "$owner" "$(fm_supervision_field "$action")" "$(fm_supervision_field "$evidence")") + fm_supervision_line_append task_records "$line" + task_count=$((task_count + 1)) + if [ "$severity" != info ]; then + line=$(fm_supervision_checklist_record "$id:$classification" "$severity" "$owner" "$action" "$why" "$id" "$project" "$pr_url" "$evidence") + fm_supervision_line_append checklist_records "$line" + checklist_count=$((checklist_count + 1)) + [ "$severity" = high ] && high_count=$((high_count + 1)) + [ "$severity" = medium ] && medium_count=$((medium_count + 1)) + fi + done + fi + + local watcher_data + watcher_data=$(fm_supervision_watcher_status "$task_count") + watcher_ok=$(printf '%s' "$watcher_data" | awk -F '\t' '{ print $1 }') + watcher_state=$(printf '%s' "$watcher_data" | awk -F '\t' '{ print $2 }') + watcher_detail=$(printf '%s' "$watcher_data" | awk -F '\t' '{ print $3 }') + if [ "$task_count" -gt 0 ] && [ "$watcher_state" = unknown ]; then + line=$(fm_supervision_checklist_record "watcher:unconfirmed" medium firstmate "Verify watcher liveness with the guarded arm/session commands before relying on silence." "$watcher_detail" "" "" "" "$watcher_detail") + fm_supervision_line_append checklist_records "$line" + checklist_count=$((checklist_count + 1)) + medium_count=$((medium_count + 1)) + fi + + if [ -d "$FM_SUPERVISION_PROJECTS" ]; then + local project_dir wt has_meta wt_branch wt_dirty wt_class wt_severity wt_owner wt_action wt_evidence + for project_dir in "$FM_SUPERVISION_PROJECTS"/*; do + [ -d "$project_dir" ] || continue + project=$(basename "$project_dir") + while IFS= read -r wt; do + [ -n "$wt" ] || continue + has_meta=false + case "$referenced_worktrees" in *"|$wt|"*) has_meta=true ;; esac + wt_branch=$(fm_supervision_branch "$wt" 2>/dev/null) || wt_branch=unknown + wt_dirty=$(fm_supervision_worktree_dirty_count "$wt" 2>/dev/null) || wt_dirty=0 + wt_class=clean_worktree + wt_severity=info + wt_owner=firstmate + wt_action="No immediate action." + if [ "$has_meta" = false ] && [ "$wt_dirty" -gt 0 ]; then + wt_class=dirty_worktree_no_active_task + wt_severity=medium + wt_action="Reconcile this worktree before cleanup." + fi + wt_evidence="dirty_count=$wt_dirty; has_active_task=$has_meta" + line=$(printf 'worktree\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s' \ + "$(fm_supervision_field "$wt")" "$(fm_supervision_field "$project")" "$(fm_supervision_field "$wt_branch")" \ + "$wt_dirty" "$has_meta" "$wt_class" "$wt_severity" "$wt_owner" "$(fm_supervision_field "$wt_action")" \ + "$(fm_supervision_field "$wt_evidence")") + fm_supervision_line_append worktree_records "$line" + if [ "$wt_severity" != info ]; then + line=$(fm_supervision_checklist_record "worktree:$project:$wt_class" "$wt_severity" "$wt_owner" "$wt_action" "A dirty worktree is not tied to active task meta." "" "$project" "" "$wt_evidence") + fm_supervision_line_append checklist_records "$line" + checklist_count=$((checklist_count + 1)) + medium_count=$((medium_count + 1)) + fi + done </dev/null) +EOF + done + fi + + local reminders reminder parsed_ext ext_data ext_state ext_ci ext_merge ext_class_data ext_class ext_severity ext_owner ext_action ext_why ext_evidence + reminders= + if [ "${FM_SUPERVISE_DEFAULT_REMINDERS_ENABLED:-1}" = 1 ]; then + reminders=${FM_SUPERVISE_DEFAULT_REMINDERS:-} + fi + reminders="${reminders:+$reminders }${FM_SUPERVISE_EXTERNAL_PRS:-}" + # shellcheck disable=SC2086 # Reminder URLs are space-separated by design. + for reminder in $reminders; do + [ -n "$reminder" ] || continue + ext_state=unknown + ext_ci=unknown + ext_merge=unknown + ext_evidence="GitHub unavailable" + if ext_data=$(fm_supervision_gh_pr "$reminder"); then + ext_state=$(printf '%s' "$ext_data" | awk -F '\t' '{ print $1 }') + ext_ci=$(printf '%s' "$ext_data" | awk -F '\t' '{ print $2 }') + ext_merge=$(printf '%s' "$ext_data" | awk -F '\t' '{ print $3 }') + parsed_ext=$(printf '%s' "$ext_data" | awk -F '\t' '{ print "repo=" $4 "; sha=" $5 "; status_total_count=" $6 }') + ext_evidence=$parsed_ext + else + github_ok=false + if [ "$github_state" = ok ]; then github_state=partial; fi + github_detail="one or more PR reads failed; affected PR states unknown" + fi + ext_class_data=$(fm_supervision_external_classification "$reminder" "$ext_state" "$ext_ci") + ext_class=$(printf '%s' "$ext_class_data" | awk -F '\t' '{ print $1 }') + ext_severity=$(printf '%s' "$ext_class_data" | awk -F '\t' '{ print $2 }') + ext_owner=$(printf '%s' "$ext_class_data" | awk -F '\t' '{ print $3 }') + ext_action=$(printf '%s' "$ext_class_data" | awk -F '\t' '{ print $4 }') + ext_why=$(printf '%s' "$ext_class_data" | awk -F '\t' '{ print $5 }') + line=$(printf 'external\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s' \ + "$(fm_supervision_field "$reminder")" "$ext_state" "$ext_ci" "$(fm_supervision_field "$ext_merge")" \ + "$ext_class" "$ext_severity" "$ext_owner" "$(fm_supervision_field "$ext_action")" "$(fm_supervision_field "$ext_evidence")") + fm_supervision_line_append external_records "$line" + if [ "$ext_severity" != info ]; then + line=$(fm_supervision_checklist_record "external:$reminder" "$ext_severity" "$ext_owner" "$ext_action" "$ext_why" "" "" "$reminder" "$ext_evidence") + fm_supervision_line_append checklist_records "$line" + checklist_count=$((checklist_count + 1)) + [ "$ext_severity" = high ] && high_count=$((high_count + 1)) + [ "$ext_severity" = medium ] && medium_count=$((medium_count + 1)) + fi + done + + if [ "$github_ok" = false ] && [ "$github_state" = ok ]; then github_state=unavailable; fi + local level=ok + if [ "$high_count" -gt 0 ]; then level=action + elif [ "$medium_count" -gt 0 ]; then level=watch + fi + source_records=$(printf 'source\tstate\t%s\t%s\nsource\tbacklog\t%s\t%s\nsource\ttmux\t%s\t%s\nsource\ttreehouse\t%s\t%s\nsource\tgit\t%s\t%s\nsource\tgithub\t%s\t%s\nsource\twatcher\t%s\t%s' \ + "$state_ok" "state/meta/status read only" \ + "$backlog_ok" "data/backlog.md read only" \ + "$tmux_ok" "tmux display-message only" \ + "$treehouse_ok" "treehouse status only" \ + "$git_ok" "git branch/status/worktree reads only" \ + "$github_ok" "$github_detail" \ + "$watcher_ok" "$watcher_detail") + records=$source_records + [ -n "$task_records" ] && records="$records"$'\n'"$task_records" + [ -n "$worktree_records" ] && records="$records"$'\n'"$worktree_records" + [ -n "$external_records" ] && records="$records"$'\n'"$external_records" + [ -n "$checklist_records" ] && records="$records"$'\n'"$checklist_records" + records="$records"$'\n'"summary $level $task_count $checklist_count $high_count $medium_count $github_state $watcher_state" + printf '%s\n' "$records" +} + +fm_supervision_emit_json() { + fm_supervision_paths + local generated_at source_lines= task_lines= worktree_lines= external_lines= checklist_lines= summary_line= line kind + generated_at=$(date -u +%Y-%m-%dT%H:%M:%SZ) + while IFS= read -r line; do + [ -n "$line" ] || continue + kind=${line%%$'\t'*} + case "$kind" in + source) fm_supervision_line_append source_lines "$line" ;; + task) fm_supervision_line_append task_lines "$line" ;; + worktree) fm_supervision_line_append worktree_lines "$line" ;; + external) fm_supervision_line_append external_lines "$line" ;; + checklist) fm_supervision_line_append checklist_lines "$line" ;; + summary) summary_line=$line ;; + esac + done + + local s_level=ok s_tasks=0 s_actions=0 s_high=0 s_medium=0 s_github=skipped s_watcher=skipped + if [ -n "$summary_line" ]; then + IFS=$'\t' read -r _ s_level s_tasks s_actions s_high s_medium s_github s_watcher </dev/null || fail "$label is not valid JSON" +} + +make_home() { + local name=$1 home + home="$TMP_ROOT/$name" + mkdir -p "$home/state" "$home/data" "$home/projects" + printf '# backlog\n' > "$home/data/backlog.md" + printf '%s\n' "$home" +} + +write_fakebin() { + local fakebin=$1 + mkdir -p "$fakebin" + cat > "$fakebin/tmux" <<'SH' +#!/usr/bin/env bash +case "$*" in + *missing*) exit 1 ;; + *list-panes*) exit 0 ;; + *) printf 'fm-window\n' ;; +esac +SH + cat > "$fakebin/treehouse" <<'SH' +#!/usr/bin/env bash +[ "${TREEHOUSE_FAIL:-0}" = 1 ] && exit 1 +printf 'ok\n' +SH + cat > "$fakebin/gh-axi" <<'SH' +#!/usr/bin/env bash +[ "$1" = api ] && [ "$2" = GET ] || exit 2 +case "$3" in + /repos/o/r/pulls/1) + printf 'state: closed\nmerged: true\nmergeable_state: clean\nhead:\n sha: sh-merged\n' + ;; + /repos/o/r/pulls/2) + printf 'state: open\nmerged: false\nmergeable_state: clean\nhead:\n sha: sh-success\n' + ;; + /repos/o/r/pulls/3) + printf 'state: open\nmerged: false\nmergeable_state: dirty\nhead:\n sha: sh-failure\n' + ;; + /repos/o/r/pulls/4|/repos/JTInventory/firstmate/pulls/76) + printf 'state: open\nmerged: false\nmergeable_state: unstable\nhead:\n sha: sh-none\n' + ;; + /repos/o/r/pulls/5) + printf 'not parseable\n' + ;; + /repos/o/r/commits/sh-merged/status|/repos/o/r/commits/sh-success/status) + printf 'state: success\ntotal_count: 1\n' + ;; + /repos/o/r/commits/sh-failure/status) + printf 'state: failure\ntotal_count: 1\n' + ;; + /repos/o/r/commits/sh-none/status|/repos/JTInventory/firstmate/commits/sh-none/status) + printf 'state: pending\ntotal_count: 0\n' + ;; + *) + exit 1 + ;; +esac +SH + chmod +x "$fakebin/tmux" "$fakebin/treehouse" "$fakebin/gh-axi" +} + +write_meta() { + local home=$1 id=$2 status=${3:-} + shift 3 || true + fm_write_meta "$home/state/$id.meta" "$@" + [ -n "$status" ] && printf '%s\n' "$status" > "$home/state/$id.status" +} + +run_json() { + local home=$1 fakebin=$2 + PATH="$fakebin:$PATH" FM_HOME="$home" "$CLI" --json --no-default-reminders +} + +test_model_is_sourceable_and_schema_is_json() { + # shellcheck source=bin/fm-supervision-model.sh + . "$MODEL" || fail "model should be sourceable" + local out + out=$("$CLI" --schema) || fail "schema command failed" + assert_contains "$out" '"$id": "firstmate.supervision.v1"' "schema id missing" + assert_json_valid "$out" "schema" + pass "model is sourceable and schema is valid JSON" +} + +test_empty_home_is_read_only_valid_json() { + local home fakebin before after out + home=$(make_home empty) + fakebin="$home/fakebin" + write_fakebin "$fakebin" + before=$(find "$home/state" "$home/data" -type f -print0 | sort -z | xargs -0 sha256sum) + out=$(run_json "$home" "$fakebin") || fail "empty home json failed" + after=$(find "$home/state" "$home/data" -type f -print0 | sort -z | xargs -0 sha256sum) + [ "$before" = "$after" ] || fail "supervise wrote state or data files" + assert_json_valid "$out" "empty home output" + assert_contains "$out" '"read_only": true' "json should mark read-only" + assert_contains "$out" '"actions_total": 0' "empty home should have no actions" + pass "empty home produces valid read-only JSON" +} + +test_task_classifications_and_route_metadata() { + local home fakebin out + home=$(make_home tasks) + fakebin="$home/fakebin" + write_fakebin "$fakebin" + write_meta "$home" live 'working: still running' \ + "project=demo" "window=live" "kind=ship" "mode=direct-PR" "yolo=off" \ + "harness=codex" "route_profile=critical" "route_harness=codex" "route_model=gpt-5.5" "route_effort=medium" \ + "branch=fm/live" + write_meta "$home" merged 'done: PR https://github.com/o/r/pull/1 checks green' \ + "project=demo" "window=live" "pr=https://github.com/o/r/pull/1" + write_meta "$home" green 'done: PR https://github.com/o/r/pull/2 checks green' \ + "project=demo" "window=live" "pr=https://github.com/o/r/pull/2" + write_meta "$home" failci 'working: fixing' \ + "project=demo" "window=live" "pr=https://github.com/o/r/pull/3" + write_meta "$home" directnoci 'done: PR https://github.com/o/r/pull/4' \ + "project=demo" "window=live" "mode=direct-PR" "pr=https://github.com/o/r/pull/4" + out=$(run_json "$home" "$fakebin") || fail "task json failed" + assert_json_valid "$out" "task output" + assert_contains "$out" '"classification": "running"' "live worker should be running" + assert_contains "$out" '"route_profile": "critical"' "route profile should be preserved" + assert_contains "$out" '"route_model": "gpt-5.5"' "route model should be preserved" + assert_contains "$out" '"recorded_branch": "fm/live"' "recorded branch should be preserved" + assert_contains "$out" 'merged_pr_live_worker' "merged PR live worker not classified" + assert_contains "$out" 'pr_open_ci_green' "green PR not classified" + assert_contains "$out" 'pr_open_ci_failing' "failing PR not classified" + assert_contains "$out" 'direct_pr_open_no_ci_ready' "direct-PR no-CI PR should be ready for review" + pass "task classifications preserve current route metadata" +} + +test_local_failure_paths_degrade_to_actions_or_unknown() { + local home fakebin out + home=$(make_home failures) + fakebin="$home/fakebin" + write_fakebin "$fakebin" + write_meta "$home" missing 'working: vanished' "project=demo" "window=missing" + write_meta "$home" stale 'working: started' "project=demo" "window=live" "worktree=/tmp/definitely-missing-firstmate-worktree" + write_meta "$home" invalid 'working: started' "project=demo" "window=live" "pr=https://github.com/o/r/pull/5" + out=$(run_json "$home" "$fakebin") || fail "failure path json failed" + assert_contains "$out" 'missing_window_existing_meta' "missing tmux window not classified" + assert_contains "$out" 'stale_treehouse_state' "missing recorded worktree not classified" + assert_contains "$out" '"github_state": "partial"' "invalid GitHub output should be partial" + assert_contains "$out" '"state": "unknown"' "invalid GitHub output should become unknown" + pass "local failure paths are surfaced without crashing" +} + +test_github_missing_and_external_reminders_do_not_fail() { + local home fakebin out + home=$(make_home github-missing) + write_meta "$home" ghmiss 'working: started' "project=demo" "window=live" "pr=https://github.com/o/r/pull/2" + out=$(PATH="/usr/bin:/bin" FM_HOME="$home" "$CLI" --json --no-default-reminders) || fail "missing gh-axi should not fail" + assert_contains "$out" '"github": { "ok": false' "missing gh-axi should mark GitHub false" + assert_contains "$out" '"state": "unknown"' "missing gh-axi should make PR unknown" + + home=$(make_home external) + fakebin="$home/fakebin" + write_fakebin "$fakebin" + out=$(PATH="$fakebin:$PATH" FM_HOME="$home" "$CLI" --json --no-default-reminders --external-pr https://github.com/JTInventory/firstmate/pull/76) || fail "external reminder json failed" + assert_contains "$out" 'external_open_ci_none' "external no-CI PR should be classified" + pass "GitHub failures degrade to unknown and external reminders work" +} + +test_text_output_and_watcher_source() { + local home fakebin out + home=$(make_home text) + fakebin="$home/fakebin" + write_fakebin "$fakebin" + write_meta "$home" live 'working: still running' "project=demo" "window=live" + out=$(PATH="$fakebin:$PATH" FM_HOME="$home" "$CLI" --text --include-ok --no-default-reminders) || fail "text output failed" + assert_contains "$out" 'Firstmate supervision - read-only' "text should show read-only posture" + assert_contains "$out" 'worker(s) are running normally' "include-ok should show routine workers" + assert_contains "$out" 'No changes made.' "text should end with no changes made" + + out=$(PATH="$fakebin:$PATH" FM_HOME="$home" "$CLI" --json --no-default-reminders) || fail "watcher source json failed" + assert_contains "$out" '"watcher_state": "unknown"' "watcher state should be explicit when sandbox/process visibility is incomplete" + assert_contains "$out" '"watcher": { "ok": false' "watcher source should be false without proof" + pass "text output and watcher source are explicit" +} + +test_model_is_sourceable_and_schema_is_json +test_empty_home_is_read_only_valid_json +test_task_classifications_and_route_metadata +test_local_failure_paths_degrade_to_actions_or_unknown +test_github_missing_and_external_reminders_do_not_fail +test_text_output_and_watcher_source + +printf 'all fm-supervision-model tests passed\n'