From 746cae01cc92619f9a61cd387a74cdab7c09526e Mon Sep 17 00:00:00 2001 From: JTInventory Date: Thu, 25 Jun 2026 01:17:39 +0000 Subject: [PATCH] feat: add read-only supervision command --- AGENTS.md | 10 +- bin/fm-supervise.sh | 55 ++ bin/fm-supervision-model.sh | 730 ++++++++++++++++++ docs/architecture.md | 4 + docs/configuration.md | 4 + .../2026-06-25-fm-supervise-readonly-plan.md | 133 ++++ docs/scripts.md | 19 + tests/fm-supervision-model.test.sh | 271 +++++++ 8 files changed, 1225 insertions(+), 1 deletion(-) create mode 100755 bin/fm-supervise.sh create mode 100755 bin/fm-supervision-model.sh create mode 100644 docs/plans/2026-06-25-fm-supervise-readonly-plan.md create mode 100755 tests/fm-supervision-model.test.sh diff --git a/AGENTS.md b/AGENTS.md index 696c8d4..87d6556 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -427,9 +427,17 @@ Empty polls, elapsed waiting time, and "still no change" are tool bookkeeping, n bin/fm-watch-arm.sh # safe verified re-arm; run as harness-tracked background; no-ops if healthy bin/fm-watch-arm.sh --restart # home-scoped forced restart; never a broad pkill bin/fm-watch.sh # the watcher itself; exits with: signal|stale|check|heartbeat +bin/fm-supervise.sh # read-only operational checklist and firstmate.supervision.v1 JSON model bin/fm-wake-drain.sh # drain queued wake records at turn start ``` +`bin/fm-supervise.sh` is passive and may be used during heartbeat review or recovery when current next actions are unclear. +It reads state/status files, tmux liveness, treehouse status, git worktree state, and GitHub PR/status data through `gh-axi`, then emits either text, `--json`, or `--schema`. +It must not be treated as approval or automation: it never drains wakes, edits backlog, sends tmux input, changes git or treehouse state, arms the watcher, tears down work, merges PRs, or changes GitHub. +Use `--include-ok` only when routine watch items are useful, `--no-default-reminders` to omit the built-in Firstmate PR `https://github.com/kunchenguid/firstmate/pull/68`, and repeat `--external-pr ` for extra external PR reminders. +The JSON contract is `firstmate.supervision.v1` with `read_only: true`, source health, summary counts, checklist items, task records, worktree records, and external reminders. +GitHub read failures are model data, not command failure: affected PRs become unknown and `sources.github.ok=false`. + On wake, in order of cheapness: 1. Read the reason line and drain queued wake records with `bin/fm-wake-drain.sh`. @@ -437,7 +445,7 @@ On wake, in order of cheapness: 3. `stale:` the crewmate stopped without reporting; peek the pane (`bin/fm-peek.sh `) to diagnose. If the pane is waiting, looping, confused, or unresponsive, load `stuck-crewmate-recovery`. 4. `check:` a per-task poll fired (usually a merge); act on it. -5. `heartbeat:` review the whole fleet: skim each window's status file, peek panes that look off, check PR-ready tasks for merge, reconcile data/backlog.md, then re-arm the watcher. +5. `heartbeat:` review the whole fleet: skim each window's status file, run `bin/fm-supervise.sh` when a read-only checklist would clarify next actions, peek panes that look off, check PR-ready tasks for merge, reconcile data/backlog.md, then re-arm the watcher. A heartbeat with no captain-relevant change is internal; do not report that the fleet is unchanged. 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. diff --git a/bin/fm-supervise.sh b/bin/fm-supervise.sh new file mode 100755 index 0000000..1d7b057 --- /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 0000000..7b7f47f --- /dev/null +++ b/bin/fm-supervision-model.sh @@ -0,0 +1,730 @@ +#!/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 such as Firstmate PR #68 + --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" }, + "summary": { "type": "object" }, + "checklist": { "type": "array" }, + "tasks": { "type": "array" }, + "worktrees": { "type": "array" }, + "external_reminders": { "type": "array" } + }, + "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_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_treehouse_status() { + local project=$1 timeout_cmd= + [ -d "$project" ] || return 1 + command -v treehouse >/dev/null 2>&1 || return 1 + if command -v timeout >/dev/null 2>&1; then + timeout_cmd=timeout + elif command -v gtimeout >/dev/null 2>&1; then + timeout_cmd=gtimeout + fi + 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 + if command -v timeout >/dev/null 2>&1; then + timeout_cmd=timeout + elif command -v gtimeout >/dev/null 2>&1; then + timeout_cmd=gtimeout + fi + 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 + if [ "$merged" = "true" ]; then + state=merged + fi + ci_state=unknown + total_count= + 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 + if [ "$total_count" = "0" ]; then + ci_state=none + fi + fi + fi + printf '%s\t%s\t%s\t%s\t%s\t%s\n' "$state" "$ci_state" "${mergeable_state:-unknown}" "$repo" "$sha" "${total_count:-unknown}" +} + +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 skips validation, the PR is open, 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 + local referenced_worktrees="|" + local meta id project kind mode yolo window worktree 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 + window=$(fm_supervision_meta_value "$meta" window) + worktree=$(fm_supervision_meta_value "$meta" worktree) + [ -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' \ + "$(fm_supervision_field "$id")" "$(fm_supervision_field "$project")" "$(fm_supervision_field "$kind")" \ + "$(fm_supervision_field "$mode")" "$(fm_supervision_field "$yolo")" "$(fm_supervision_field "$window")" \ + "$window_live" "$(fm_supervision_field "$worktree")" "$(fm_supervision_field "$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 + + 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:-https://github.com/kunchenguid/firstmate/pull/68} + 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' \ + "$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") + 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" + printf '%s\n' "$records" +} + +fm_supervision_emit_json() { + fm_supervision_paths + local generated_at + generated_at=$(date -u +%Y-%m-%dT%H:%M:%SZ) + local source_lines= task_lines= worktree_lines= external_lines= checklist_lines= summary_line= + local line kind + 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 + if [ -n "$summary_line" ]; then + IFS=$'\t' read -r _ s_level s_tasks s_actions s_high s_medium s_github <&2; exit 1; } +pass() { printf 'ok - %s\n' "$1"; } + +assert_contains() { + local haystack=$1 needle=$2 label=$3 + printf '%s\n' "$haystack" | grep -Fq "$needle" || fail "$label" +} + +new_home() { + local tmp + tmp=$(mktemp -d "${TMPDIR:-/tmp}/fm-supervise.XXXXXX") || exit 1 + mkdir -p "$tmp/state" "$tmp/data" "$tmp/projects" + printf '%s\n' "$tmp" +} + +write_fakebin() { + local dir=$1 + mkdir -p "$dir" + cat >"$dir/tmux" <<'SH' +#!/usr/bin/env bash +case "$*" in + *missing*) exit 1 ;; + *) printf 'fm-window\n' ;; +esac +SH + cat >"$dir/treehouse" <<'SH' +#!/usr/bin/env bash +if [ "${TREEHOUSE_FAIL:-0}" = 1 ]; then + exit 1 +fi +printf 'ok\n' +SH + cat >"$dir/gh-axi" <<'SH' +#!/usr/bin/env bash +if [ "$1" != api ] || [ "$2" != GET ]; then + exit 2 +fi +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) + printf 'state: open\nmerged: false\nmergeable_state: unstable\nhead:\n sha: sh-none\n' + ;; + /repos/o/r/pulls/5) + printf 'not parseable\n' + ;; + /repos/kunchenguid/firstmate/pulls/68) + printf 'state: open\nmerged: false\nmergeable_state: unstable\nhead:\n sha: sh-none\n' + ;; + /repos/o/r/commits/sh-merged/status) + printf 'state: success\ntotal_count: 1\n' + ;; + /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/kunchenguid/firstmate/commits/sh-none/status) + printf 'state: pending\ntotal_count: 0\n' + ;; + *) + exit 1 + ;; +esac +SH + chmod +x "$dir/tmux" "$dir/treehouse" "$dir/gh-axi" +} + +run_json() { + local home=$1 fakebin=$2 + PATH="$fakebin:$PATH" FM_HOME="$home" "$CLI" --json --no-default-reminders +} + +write_meta() { + local home=$1 id=$2 body=$3 status=${4:-} + printf '%s\n' "$body" >"$home/state/$id.meta" + if [ -n "$status" ]; then + printf '%s\n' "$status" >"$home/state/$id.status" + fi +} + +make_git_project() { + local home=$1 name=${2:-demo} + mkdir -p "$home/projects/$name" + git -C "$home/projects/$name" init -q +} + +# shellcheck source=bin/fm-supervision-model.sh +. "$MODEL" || fail "model should be sourceable" +pass "model is sourceable" + +out=$("$CLI" --schema) || fail "schema command failed" +assert_contains "$out" 'firstmate.supervision.v1' "schema missing id" +pass "schema prints v1 id" + +home=$(new_home) +fakebin="$home/fakebin" +write_fakebin "$fakebin" +before=$(find "$home/state" "$home/data" -type f | sort) +out=$(PATH="$fakebin:$PATH" FM_HOME="$home" "$CLI" --json --no-default-reminders) || fail "empty json failed" +after=$(find "$home/state" "$home/data" -type f | sort) +[ "$before" = "$after" ] || fail "command wrote runtime files" +assert_contains "$out" '"read_only": true' "json not marked read-only" +assert_contains "$out" '"actions_total": 0' "empty home should have no actions" +pass "empty home stays read-only" + +home=$(new_home) +fakebin="$home/fakebin" +write_fakebin "$fakebin" +write_meta "$home" live $'project=demo\nwindow=live' 'working: still running' +out=$(run_json "$home" "$fakebin") || fail "running json failed" +assert_contains "$out" '"classification": "running"' "live worker should be running" +assert_contains "$out" '"actions_total": 0' "running worker should not be high action" +pass "live working status is routine" + +home=$(new_home) +fakebin="$home/fakebin" +write_fakebin "$fakebin" +write_meta "$home" merged $'project=demo\nwindow=live\npr=https://github.com/o/r/pull/1' 'done: PR https://github.com/o/r/pull/1 checks green' +out=$(run_json "$home" "$fakebin") || fail "merged json failed" +assert_contains "$out" 'merged_pr_live_worker' "merged PR live worker not classified" +assert_contains "$out" '"owner": "firstmate"' "merged PR owner should be firstmate" +pass "merged PR with live worker is high action" + +home=$(new_home) +fakebin="$home/fakebin" +write_fakebin "$fakebin" +write_meta "$home" green $'project=demo\nwindow=live\npr=https://github.com/o/r/pull/2' 'done: PR https://github.com/o/r/pull/2 checks green' +out=$(run_json "$home" "$fakebin") || fail "green json failed" +assert_contains "$out" 'pr_open_ci_green' "green PR not classified" +assert_contains "$out" '"owner": "captain"' "green PR owner should be captain by default" +assert_contains "$out" '"mergeable_state": "clean"' "task PR mergeable state should be preserved" +pass "open PR with green CI is captain action" + +home=$(new_home) +fakebin="$home/fakebin" +write_fakebin "$fakebin" +write_meta "$home" failci $'project=demo\nwindow=live\npr=https://github.com/o/r/pull/3' 'working: fixing' +out=$(run_json "$home" "$fakebin") || fail "failure json failed" +assert_contains "$out" 'pr_open_ci_failing' "failing PR not classified" +assert_contains "$out" '"owner": "worker"' "failing PR owner should be worker" +pass "open PR with failing CI is worker action" + +home=$(new_home) +fakebin="$home/fakebin" +write_fakebin "$fakebin" +write_meta "$home" noci $'project=demo\nwindow=live\npr=https://github.com/o/r/pull/4' 'done: PR https://github.com/o/r/pull/4' +out=$(run_json "$home" "$fakebin") || fail "no-ci json failed" +assert_contains "$out" '"ci_state": "none"' "CI with total_count 0 should be none" +printf '%s\n' "$out" | grep -Fq 'pr_open_ci_green' && fail "ci none treated as green" +pass "open PR with no CI is not green" + +home=$(new_home) +fakebin="$home/fakebin" +write_fakebin "$fakebin" +write_meta "$home" directnoci $'project=demo\nwindow=live\nmode=direct-PR\npr=https://github.com/o/r/pull/4' 'done: PR https://github.com/o/r/pull/4' +out=$(run_json "$home" "$fakebin") || fail "direct PR no-ci json failed" +assert_contains "$out" 'direct_pr_open_no_ci_ready' "direct-PR no-CI PR should be ready for review" +assert_contains "$out" '"owner": "captain"' "direct-PR no-CI PR owner should be captain by default" +assert_contains "$out" '"mergeable_state": "unstable"' "direct-PR no-CI mergeable state should be preserved" +pass "direct-PR open PR with no CI is captain action" + +home=$(new_home) +fakebin="$home/fakebin" +write_fakebin "$fakebin" +write_meta "$home" done $'project=demo\nwindow=live\nmode=no-mistakes' 'done: implementation complete' +out=$(run_json "$home" "$fakebin") || fail "done no PR json failed" +assert_contains "$out" 'worker_done_no_pr' "done with no PR not classified" +pass "done status without PR is action" + +home=$(new_home) +fakebin="$home/fakebin" +write_fakebin "$fakebin" +make_git_project "$home" +printf 'dirty\n' >"$home/projects/demo/dirty.txt" +out=$(run_json "$home" "$fakebin") || fail "dirty worktree json failed" +assert_contains "$out" 'dirty_worktree_no_active_task' "dirty worktree not classified" +pass "dirty worktree without meta is action" + +home=$(new_home) +fakebin="$home/fakebin" +write_fakebin "$fakebin" +write_meta "$home" missing $'project=demo\nwindow=missing' 'working: vanished' +out=$(run_json "$home" "$fakebin") || fail "missing window json failed" +assert_contains "$out" 'missing_window_existing_meta' "missing tmux window not classified" +pass "missing tmux window is action" + +home=$(new_home) +fakebin="$home/fakebin" +write_fakebin "$fakebin" +write_meta "$home" stale $'project=demo\nwindow=live\nworktree=/tmp/definitely-missing-firstmate-worktree' 'working: started' +out=$(run_json "$home" "$fakebin") || fail "stale json failed" +assert_contains "$out" 'stale_treehouse_state' "missing recorded worktree not classified stale" +pass "missing recorded worktree is stale" + +home=$(new_home) +fakebin="$home/fakebin" +write_fakebin "$fakebin" +make_git_project "$home" +write_meta "$home" th $'project=demo\nwindow=live' 'working: started' +out=$(TREEHOUSE_FAIL=1 PATH="$fakebin:$PATH" FM_HOME="$home" "$CLI" --json --no-default-reminders) || fail "treehouse failure json failed" +assert_contains "$out" '"treehouse": { "ok": false' "treehouse failure should mark source false" +pass "treehouse failure is surfaced" + +home=$(new_home) +write_meta "$home" ghmiss $'project=demo\nwindow=live\npr=https://github.com/o/r/pull/2' 'working: started' +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" +pass "missing gh-axi is unknown data" + +home=$(new_home) +fakebin="$home/fakebin" +write_fakebin "$fakebin" +write_meta "$home" invalid $'project=demo\nwindow=live\npr=https://github.com/o/r/pull/5' 'working: started' +out=$(run_json "$home" "$fakebin") || fail "invalid GitHub output should not fail" +assert_contains "$out" '"github_state": "partial"' "invalid GitHub output should be partial" +assert_contains "$out" '"state": "unknown"' "invalid GitHub output should be unknown" +pass "invalid GitHub output does not crash" + +home=$(new_home) +fakebin="$home/fakebin" +write_fakebin "$fakebin" +out=$(PATH="$fakebin:$PATH" FM_HOME="$home" "$CLI" --json) || fail "external reminder json failed" +assert_contains "$out" 'external_open_ci_none' "default external PR 68 not classified" +pass "default external reminder is present" + +home=$(new_home) +fakebin="$home/fakebin" +write_fakebin "$fakebin" +write_meta "$home" compat $'project=demo\nwindow=live\npr=https://github.com/o/r/pull/3\npr=https://github.com/o/r/pull/2' 'done: PR ready checks green' +out=$(run_json "$home" "$fakebin") || fail "compat json failed" +assert_contains "$out" '"kind": "ship"' "missing kind should default to ship" +assert_contains "$out" '"mode": "no-mistakes"' "missing mode should default to no-mistakes" +assert_contains "$out" '"yolo": "off"' "missing yolo should default to off" +assert_contains "$out" '"url": "https://github.com/o/r/pull/2"' "duplicate pr lines should use last value" +assert_contains "$out" 'pr_open_ci_green' "duplicate PR last value should drive classification" +pass "older meta defaults and duplicate PR are compatible" + +home=$(new_home) +fakebin="$home/fakebin" +write_fakebin "$fakebin" +out=$(PATH="$fakebin:$PATH" FM_HOME="$home" "$CLI" --text --no-default-reminders) || fail "text output failed" +assert_contains "$out" 'Firstmate supervision - read-only' "text should show read-only posture" +assert_contains "$out" 'No changes made.' "text should end with no changes made" +pass "text output makes read-only posture obvious" + +home=$(new_home) +fakebin="$home/fakebin" +write_fakebin "$fakebin" +write_meta "$home" live $'project=demo\nwindow=live' 'working: still running' +out=$(PATH="$fakebin:$PATH" FM_HOME="$home" "$CLI" --text --include-ok --no-default-reminders) || fail "include-ok text failed" +assert_contains "$out" 'worker(s) are running normally' "include-ok should show routine running workers" +pass "include-ok shows low-priority watch items" + +printf 'all fm-supervision-model tests passed\n'