---
name: ayf:operator
description: |
  Never-stop tick loop that supervises the agent fleet: reads .ay/fleet.json,
  auto-approves safe read-only permission prompts from .ay/permission-rules.json,
  nudges STALLED agents, escalates BLOCKED ones, and repeats every 60s.
allowed-tools:
  - Bash
  - Read
  - Write
---

# /operator -- Fleet Supervisor Tick Loop

Run an autonomous supervisor over the running agent fleet. Each **tick** (every
60s) reads the fleet's machine-readable state, keeps healthy agents moving by
auto-approving only *safe, read-only* permission prompts, nudges agents that have
stalled, and escalates ones that are genuinely blocked. It never stops on its own.

## The Iron Rule

The operator's power is deliberately asymmetric:

- It may **auto-approve only prompts whose `permission-rules.json` decision is
  `allow`** (read-only: `git status/log/diff`, `find/grep/cat`, file reads...).
- It **must never** approve a `deny` rule (outbound: `git push`, `npm publish`,
  `gh pr create`...) and **must never** approve a command that matches **no** rule.
  Both are left untouched for a human. Fail closed, always.
- It never runs an outbound command itself. Supervising is not building.

If you cannot read a surface, a file, or a rule -- do nothing for that agent this
tick and log it. Silence beats a wrong approval.

## Inputs

| File | Role |
|------|------|
| `.ay/fleet.json` | Fleet manifest -- one entry per agent surface (state + heartbeat) |
| `.ay/permission-rules.json` | Static decision cache -- `allow`/`deny` by command pattern |

`fleet.json` shape (read the file's own header if present):

```json
{ "version": 1, "updated_at": "<iso>",
  "agents": [ { "surface_id": "core-platform-81", "task_id": "81",
               "status": "BUILDING", "last_seen": "<iso>",
               "current_task": "..." } ] }
```

Statuses: `IDLE`, `PLANNING`, `BUILDING`, `STALLED`, `BLOCKED`, `DONE`.

## Prerequisites

```bash
cmux ping            # surfaces are cmux workspaces; must return OK
command -v jq        # tick logic parses JSON with jq
```

If `cmux` is unavailable, the operator can still read `fleet.json` and report, but
it cannot read screens or nudge -- say so and fall back to a read-only report.

## Tick Logic (bash pseudocode -- follow this each tick)

```bash
# Resolve .ay from any worktree (never hardcode $(pwd)/.ay)
AY="$(dirname "$(git rev-parse --git-common-dir 2>/dev/null)")/.ay"
FLEET="$AY/fleet.json"
RULES="$AY/permission-rules.json"
STALL_SECONDS=300            # heartbeat older than this => treat as STALLED

# decide <command-string> -> prints: allow | deny | prompt
# Rules are ordered deny-first, so `first(...)` fails closed on any overlap.
# Note: bind the rule's `.pattern` as $p *before* piping $c into test(), or `.`
# inside test() would refer to the command string, not the rule.
decide() {
  jq -r --arg c "$1" \
    'first(.rules[] | select(.pattern as $p | $c | test($p)) | .decision) // "prompt"' \
    "$RULES" 2>/dev/null || echo prompt
}

# age_seconds <iso-timestamp> -> integer seconds since then (0 if unparseable)
age_seconds() {
  local t; t=$(date -u -d "$1" +%s 2>/dev/null || date -u -jf %Y-%m-%dT%H:%M:%SZ "$1" +%s 2>/dev/null)
  [ -n "$t" ] && echo $(( $(date -u +%s) - t )) || echo 0
}

# ── one tick ──────────────────────────────────────────────────────────────
operator_tick() {
  local ts; ts=$(date -u +%Y-%m-%dT%H:%M:%SZ)
  [ -f "$FLEET" ] || { echo "[$ts] no fleet.json -- nothing to supervise"; return; }

  jq -c '.agents[]' "$FLEET" | while read -r a; do
    sid=$(echo "$a"    | jq -r '.surface_id')
    status=$(echo "$a" | jq -r '.status')
    task=$(echo "$a"   | jq -r '.task_id // "-"')
    seen=$(echo "$a"   | jq -r '.last_seen // empty')

    # Stale heartbeat overrides a stale status: a "BUILDING" agent no one has
    # heard from in STALL_SECONDS is really STALLED (ties in the #83 heartbeat).
    if [ -n "$seen" ] && [ "$(age_seconds "$seen")" -gt "$STALL_SECONDS" ]; then
      case "$status" in DONE|IDLE) : ;; *) status=STALLED ;; esac
    fi

    case "$status" in
      DONE|IDLE)
        : ;;                                   # healthy & quiet -- leave alone

      PLANNING|BUILDING)
        # Healthy but may be paused on a permission prompt. Auto-approve ONLY
        # if a read-only allow rule matches; never touch deny/unmatched.
        screen=$(cmux read-screen --workspace "$sid" --lines 25 2>/dev/null) || continue
        cmd=$(extract_pending_command "$screen")     # the command being asked about
        [ -z "$cmd" ] && continue
        case "$(decide "$cmd")" in
          allow) approve "$sid" "$cmd" "$ts" "$task" ;;
          deny)  log "$ts" DENY  "$sid" "$task" "$cmd" "outbound -- left for human" ;;
          *)     log "$ts" HOLD  "$sid" "$task" "$cmd" "no rule -- left for human" ;;
        esac ;;

      STALLED)
        # First see if it is stuck on an approvable prompt; if so, approve and
        # that unblocks it. Otherwise send a gentle nudge to continue.
        screen=$(cmux read-screen --workspace "$sid" --lines 40 2>/dev/null) || continue
        cmd=$(extract_pending_command "$screen")
        if [ -n "$cmd" ] && [ "$(decide "$cmd")" = allow ]; then
          approve "$sid" "$cmd" "$ts" "$task"
        else
          nudge "$sid" "$task" "$ts"
        fi ;;

      BLOCKED)
        # The operator does NOT auto-unblock. Escalate to a human, once.
        log "$ts" BLOCKED "$sid" "$task" "agent blocked" "escalating to human"
        cmux notify --workspace "$sid" \
          --title "Operator: task $task BLOCKED" \
          --body "Needs human input -- see .ay/tracking/BLOCKERS.md" 2>/dev/null || true ;;
    esac
  done
}

# approve a pending prompt on a surface (send the affirmative option, then Enter)
approve() {  # <sid> <cmd> <ts> <task_id>
  cmux send     --workspace "$1" "1"       2>/dev/null   # "1) Yes / allow"
  cmux send-key --workspace "$1" enter     2>/dev/null
  log "$3" APPROVE "$1" "$4" "$2" "matched allow rule"
}

# nudge a stalled agent back into motion (no work is done for it)
nudge() {    # <sid> <task> <ts>
  cmux send     --workspace "$1" \
    "You appear stalled on task $2. Re-read your task file and continue, or write a BLOCKER if truly stuck." 2>/dev/null
  cmux send-key --workspace "$1" enter 2>/dev/null
  log "$3" NUDGE "$1" "$2" "stall nudge" "sent continue"
}

# structured action log -- one line per action (jq-queryable; feeds task #85).
# Field order + names match the .ay/operator-log.jsonl schema (task 86) EXACTLY:
#   ts, action, surface, task_id, subject, detail.
# task_id is emitted as a JSON string, or null when not task-scoped (empty or "-").
log() {      # <ts> <action> <surface> <task_id> <subject> <detail>
  local tid="$4"
  if [ -z "$tid" ] || [ "$tid" = "-" ]; then tid=null; else tid="\"$tid\""; fi
  printf '{"ts":"%s","action":"%s","surface":"%s","task_id":%s,"subject":"%s","detail":"%s"}\n' \
    "$1" "$2" "$3" "$tid" "$5" "$6" | tee -a "$AY/operator-log.jsonl"
}

# ── never-stop loop ───────────────────────────────────────────────────────
while true; do
  operator_tick
  sleep 60
done
```

`extract_pending_command` is the one bit you fill in by *reading* the surface: when
`cmux read-screen` shows a permission prompt (e.g. *"Claude wants to run `git log
--oneline`  1) Yes  2) No"*), pull out the command string being asked about so
`decide` can rule on it. If you cannot clearly identify the command, treat it as
`prompt` (leave it for the human) -- never guess an approval.

## How the loop actually runs

One `operator_tick` is the unit of work. Two ways to make it "never stop":

1. **Dedicated surface (foreground):** run the `while true; sleep 60` loop in an
   operator workspace. Simple, but ties up a surface.
2. **Scheduled re-invocation (preferred):** run a single tick, then re-arm with
   `/loop 60s /operator` (or the runtime scheduler) so each wake-up performs
   exactly one tick. This survives context limits -- state lives in `fleet.json`,
   not in the loop's memory.

Either way, every action is appended to `.ay/operator-log.jsonl`, so a human can audit
what the operator approved, nudged, or escalated:

```bash
jq -r 'select(.action=="APPROVE") | "\(.ts) \(.surface) \(.task_id) \(.subject)"' .ay/operator-log.jsonl
```

## Report each tick

Print a one-line summary so a watching human sees the operator is alive:

```
[<ts>] operator tick: 3 agents  |  approved 1 (git log)  nudged 1 (task 55)  blocked 0  healthy 1
```

## Stopping

The operator runs until the human stops it (`Ctrl-C` on the foreground loop, or
cancelling the `/loop`). It never marks itself done. A companion watchdog (task
#88) alerts if the operator *itself* goes silent.

## Notes

- **Fail closed:** unmatched or `deny` prompts are never auto-approved. The worst a
  bad rule can do is leave a safe command for the human -- never approve an unsafe one.
- **Heartbeat-aware:** an agent whose `last_seen` is older than `STALL_SECONDS` is
  treated as STALLED regardless of its self-reported status.
- **No building:** the operator only reads state and sends approvals/nudges. It
  writes no code, runs no outbound command, and pushes nothing.
