From 0aecd6e0f0a7751a3774a3f180e3e8f73badeb8c Mon Sep 17 00:00:00 2001 From: Martin Contreras Date: Tue, 20 Oct 2026 11:59:06 -0300 Subject: [PATCH 1/3] task-monitor 1/6: snapshot --json collector (bash) and schema: add task-monitor-snapshot New canonical root binary `bin/task-monitor-snapshot` prints one JSON document (schema 1) per repo, or with --all for every repo radio knows: in flight (worktree sidecars joined to radio sessions, PRs and the latest review intent), next (PM queue, else the tracker fallback), done (merged PRs that deliver an item, last 24h or last 10, cached once on disk), and exactly three attention signals. --local makes no forge call: `gh` is shadowed by a failing function and the run merges with the last full run's cache. Per-tracker behaviour is three new hooks with _default.sh bodies: aw_tracker_item_describe, aw_tracker_pr_item, aw_tracker_next_fallback. The parity suite checks them for every impl. Text extraction is awk (lib/monitor-text.sh), assembly is jq (lib/monitor-assemble.jq), and the whole thing runs on /bin/bash 3.2. Also: aw_task_fm moves from bin/task-board to lib/task-file.sh for reuse, aw_main_repo_root is factored out of aw_main_repo_name, and all seven installers link the new binary. Schema documented in docs/task-monitor-schema.md with an example; README, workflow templates and CHANGELOG updated. Closes #273 Co-Authored-By: Claude Opus 5.5 --- .claude/gh-workflow.md | 24 + CHANGELOG.md | 12 + README.md | 20 + bin/task-board | 33 +- bin/task-monitor-snapshot | 505 ++++++++++++++++ claude-gh/install.sh | 7 +- claude-gh/steering/gh-workflow.example.md | 24 + claude-jira/install.sh | 7 +- claude-jira/steering/jira-workflow.example.md | 24 + claude-local/install.sh | 7 +- .../steering/local-workflow.example.md | 24 + claude-notion/install.sh | 7 +- .../steering/notion-workflow.example.md | 24 + docs/task-monitor-schema.example.json | 144 +++++ docs/task-monitor-schema.md | 216 +++++++ kiro-gh/install.sh | 7 +- kiro-gh/steering/gh-workflow.example.md | 24 + kiro-local/install.sh | 7 +- kiro-local/steering/local-workflow.example.md | 24 + kiro-notion/install.sh | 7 +- .../steering/notion-workflow.example.md | 24 + lib/monitor-assemble.jq | 149 +++++ lib/monitor-text.sh | 81 +++ lib/repo-name.sh | 23 +- lib/task-file.sh | 25 + lib/trackers/_default.sh | 74 +++ lib/trackers/gh.sh | 69 +++ lib/trackers/jira.sh | 24 + lib/trackers/local.sh | 64 ++ tests/install_symlinks.bats | 12 + tests/loadout_modules.bats | 19 + tests/task_monitor_snapshot.bats | 571 ++++++++++++++++++ 32 files changed, 2236 insertions(+), 46 deletions(-) create mode 100755 bin/task-monitor-snapshot create mode 100644 docs/task-monitor-schema.example.json create mode 100644 docs/task-monitor-schema.md create mode 100644 lib/monitor-assemble.jq create mode 100644 lib/monitor-text.sh create mode 100644 lib/task-file.sh create mode 100644 tests/task_monitor_snapshot.bats diff --git a/.claude/gh-workflow.md b/.claude/gh-workflow.md index c5ce080..3c20cae 100644 --- a/.claude/gh-workflow.md +++ b/.claude/gh-workflow.md @@ -265,6 +265,30 @@ gh run list -c "$(git rev-parse HEAD)" --limit 1 --json databaseId --jq 'length' # 0 => no run for this commit; "green" is not a claim you can make ``` +`task-monitor-snapshot` — the board as JSON: in flight, next, done, and whether this repo needs you (#273) + +It prints one JSON document (`"schema": 1`, documented in task-force's +`docs/task-monitor-schema.md`) and is the data layer of task-monitor. Every +value comes from disk, the tracker or the forge, and no model is asked to write +or summarise any of it. + +- **In flight** is every worktree whose `..info` sidecar exists, joined to + its radio session, its PR and the latest review intent. +- **Next** is the PM's queue, else the tracker's backlog (gh project items in + none of the three statuses, or the local `tasks/` files). There is none on + jira or notion. +- **Done** is the merged PRs that delivered an item in the last 24 hours, or + the last 10, whichever is more. +- **Attention** fires on exactly three signals: an unread PM inbox, a finished + review on a still-open PR that the PM has not answered, and a worker in + `awaiting`. The `awaiting` signal is claude-only for now. + +```bash +task-monitor-snapshot --json # this repo +task-monitor-snapshot --all # every repo radio knows about +task-monitor-snapshot --local # no forge calls: disk + the last full run's cache +``` + ### PM ↔ worker messaging (radio) Radio is the **canonical** coordination channel between the PM and workers — every diff --git a/CHANGELOG.md b/CHANGELOG.md index 34b40ac..910b540 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -195,6 +195,18 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Added +- **`task-monitor-snapshot`: the board as one JSON document — in flight, next, done, and whether a repo needs you (#273, epic #272 1/6).** A new canonical root binary, the data layer the task-monitor front end will render. It prints `"schema": 1` for the current repo, or with `--all` for every repo a radio session file names, and every value in it is read deterministically from radio's state, task-work's `..info` sidecars, the tracker and the forge. No model is asked to write or summarise anything. The schema is documented in `docs/task-monitor-schema.md`, with an example taken from this repo. + - **In flight** is every worktree sidecar, joined to its radio session (`STATE=`, heartbeat), its PR (found by the `task/` head branch on every tracker) and a status word derived from the latest review intent about that PR. Reviewers attach to their PR's item rather than being items. + - **Titles and descriptions** come from three new tracker hooks with `_default.sh` bodies: `aw_tracker_item_describe` (gh: the issue's title and first paragraph; local: the task file; jira / notion: `ref · slug`, since the shell cannot read them), `aw_tracker_pr_item` (gh: `Closes #N`, else the head branch) and `aw_tracker_next_fallback` (gh: project items in none of the three configured statuses; local: the `tasks/` backlog; none on jira / notion). Once a PR exists, its title and `## Summary` take over, falling back to the first paragraph after `Closes`. `tests/loadout_modules.bats` checks that all three resolve on every loadout. + - **Next** reads the PM queue file. Its writer is sub-issue 3, so the read uses the format that sub-issue is expected to keep. + - **Done** is the merged PRs that deliver an item over the last 24 hours, or the last 10, whichever is more. Each merged PR is fetched once and cached under `$TASK_FORCE_HOME/monitor//prs/`. + - **Attention** has exactly three signals: an unread PM inbox, where `--also` aliases count toward the primary; a `review-complete-*` on a PR the forge lists as open, with no later `changes-requested` / `approved-and-merged`, rebuilt from message frontmatter in every mailbox; and a worker in `awaiting`, which is claude-only for now. + - **`--local`** makes no forge call at all. It shadows `gh` with a failing function and merges with the last full run's cache, so a filesystem-triggered refresh never waits on GitHub. A full run whose forge call fails keeps the previous result and marks it `stale`. + + It runs on `/bin/bash` 3.2, and its bodies go through awk and jq, never a large-string pattern substitution (#266). Repos in `--all` run in parallel. Measured on this machine's four repos: a full `--all` takes about 10s, mostly `gh`, and a single-repo `--local` 0.65s. Two small extractions came with it. `aw_task_fm`, the task-file frontmatter reader, moved from `bin/task-board` to `lib/task-file.sh` so the local tracker module can share it. `aw_main_repo_root` now carries the git-common-dir logic in `lib/repo-name.sh`, and `aw_main_repo_name` calls it. + + New `tests/task_monitor_snapshot.bats` (39 tests, all run under `/bin/bash`) covers each source, each tracker's titles, each status, each signal and its negatives, `--local`, the forge-failure path, `--all` discovery and the text extraction. Each assertion was mutation-checked. **Upgrading: no `task-init` re-run needed for the command itself.** Re-run the loadout's `install.sh` to get the `~/.local/bin/task-monitor-snapshot` link, which the shared install stanza now creates on all seven loadouts. Re-run `task-init ` to refresh the workflow doc, which now describes the command. + - **Radio auto-submit is its own flag on claude, no longer a side effect of `--permission-mode auto` (#246).** `task-work --auto` set both, so a worker could not get its mail submitted without also auto-accepting edits. The reverse was worse: a worker kept off `--auto` because its task is destructive (a release cut that tags and publishes) lost auto-submit as a side effect, so the worker you were supervising most carefully was the one whose handoffs stalled. The coupling stays out of sight while workers are busy, because a busy recipient's mail is drained by the Stop hook with no keystroke. It shows up the moment they go idle waiting on a review, which is exactly the handoff that matters. **The split, matching kiro (#206) and `task-pm` (#189):** every `task-work` copy now takes `--auto-submit` and `--no-auto-submit`. `--auto` keeps its meaning and becomes shorthand for auto permission mode plus `--auto-submit`, so existing invocations and docs behave as before. `--no-auto-submit` wins over `--auto` in either order, and `--auto-submit` composes with `--plan`. The flag arms live in a new `auto-flags` drift region shared by all seven copies, and the resolution sits in the existing `radio-env-injection` region, so the claude and kiro copies cannot drift apart again. On `task-reviewer`, `--no-auto` now drops **only** the permission mode: auto-submit stays on by default, with `--no-auto-submit` as its own opt-out. `task-recreate-worker` gains the same pair. diff --git a/README.md b/README.md index 9aff72b..0f87581 100644 --- a/README.md +++ b/README.md @@ -398,6 +398,26 @@ It opens a fresh tab on the **existing** worktree and launches the agent with th --- +## `task-monitor-snapshot` — the board as JSON + +With task-force a ticket takes minutes, and a session runs ten or more of them, all referred to by number. The PM and the workers keep up; the human driving the PM loses track of what `#262` was. `task-monitor-snapshot` is the data layer of task-monitor (epic #272): it prints one JSON document saying what is **in flight**, what is **next** and what is **done** in a repo, each as a title plus a plain one-paragraph description, and whether the repo **needs you**. + +```bash +task-monitor-snapshot --json # this repo +task-monitor-snapshot --all # every repo radio knows about, one entry each +task-monitor-snapshot --local # disk only: no forge calls, merged with the last full run +``` + +**No model writes any of it.** Every value is read from radio's state, task-work's sidecars, the tracker or the forge. A gh or local item is described by its issue or task-file title and the first paragraph of its body; a jira or notion item is `PROJ-42 · ` until its PR exists, because the shell cannot read those trackers. Once a PR exists, its title and `## Summary` take over on every tracker. + +**In flight** is every worktree whose `..info` sidecar exists — not every fresh heartbeat, so a resumed or roleless worker still shows — with its session state, its PR and a status word (`implementing`, `pr-open`, `under-review`, `changes-requested`, `re-review`, `waiting-merge`, `merged`) derived from the latest review intent. **Next** is the PM's queue, else the tracker's backlog. **Done** is the merged PRs that delivered an item in the last 24 hours, or the last 10, whichever is more; merged PRs are fetched once and cached for good. + +**Attention** fires on exactly three signals and nothing else: an unread PM inbox (`--also` aliases count toward the primary), a finished review on a still-open PR the PM has not answered, and a worker in `awaiting`. The third is claude-only for now: only the claude loadouts wire `radio awaiting`. + +`--local` exists so a front end can refresh on every filesystem event without waiting on GitHub: it shadows `gh` with a function that fails, so every forge read falls back to the cache under `$TASK_FORCE_HOME/monitor//`. The schema (`"schema": 1`) is documented in [`docs/task-monitor-schema.md`](docs/task-monitor-schema.md), with a full example beside it. + +--- + ## Per-combo setup Pick the section that matches what you installed. diff --git a/bin/task-board b/bin/task-board index 93d3aca..c7bba20 100755 --- a/bin/task-board +++ b/bin/task-board @@ -148,25 +148,10 @@ is_in_flight() { [[ -n "$IN_FLIGHT_SLUGS" ]] && printf '%s' "$IN_FLIGHT_SLUGS" | grep -qxF "$1" } -# Read a single field from YAML frontmatter (very small grammar — keys are -# simple lowercase, values are scalar; strips surrounding quotes). -# Usage: read_fm -read_fm() { - local file="$1" key="$2" - awk -v key="$key" ' - /^---[[:space:]]*$/ { - if (in_fm) { exit } - in_fm = 1; next - } - in_fm && $0 ~ "^" key "[[:space:]]*:" { - sub("^" key "[[:space:]]*:[[:space:]]*", "") - gsub(/^"|"$/, "") - gsub(/^'\''|'\''$/, "") - print - exit - } - ' "$file" -} +# The frontmatter reader is shared with lib/trackers/local.sh's task-monitor +# hooks (#273), so it lives in a lib rather than here. +# shellcheck source=lib/task-file.sh +source "$AW_ROOT/lib/task-file.sh" # Collect tasks into three temp files; each line is "\t". # We use temp files (instead of `declare -n` namerefs) because bash 3.2 has @@ -186,11 +171,11 @@ for tf in "$TASKS_DIR"/*.md; do # Skip the board itself and the README [[ "$base" == "_board.md" || "$base" == "README.md" ]] && continue - fm_id=$(read_fm "$tf" "id") - fm_title=$(read_fm "$tf" "title") - fm_status=$(read_fm "$tf" "status") - fm_priority=$(read_fm "$tf" "priority") - fm_pr=$(read_fm "$tf" "pr") + fm_id=$(aw_task_fm "$tf" "id") + fm_title=$(aw_task_fm "$tf" "title") + fm_status=$(aw_task_fm "$tf" "status") + fm_priority=$(aw_task_fm "$tf" "priority") + fm_pr=$(aw_task_fm "$tf" "pr") # Derive slug from filename: strip leading NNN- and trailing .md slug="${base%.md}" diff --git a/bin/task-monitor-snapshot b/bin/task-monitor-snapshot new file mode 100755 index 0000000..c65a31c --- /dev/null +++ b/bin/task-monitor-snapshot @@ -0,0 +1,505 @@ +#!/usr/bin/env bash +set -euo pipefail + +# task-monitor-snapshot — one JSON document describing what is in flight, next +# and done in a repo, plus the attention signals that say whether it needs you +# (#273, epic #272). +# +# This is the data layer of task-monitor. The ratatui front end renders it and +# owns change detection; everything it shows is read here, deterministically, +# from disk, the tracker and the forge. No model is ever asked to produce or +# summarise anything (#272's first anti-goal). The schema is documented in +# docs/task-monitor-schema.md, and `"schema": 1` is the contract. +# +# It reuses the modules that own each format instead of re-deriving them: +# - lib/trackers/.sh describes items, links PRs to items and lists +# the Todo fallback, through hooks with _default.sh bodies; +# - radio's on-disk state under ${TASK_FORCE_HOME:-~/.task-force}/radio is +# read directly: session files (KEY=VALUE), message frontmatter; +# - task-work's /..info sidecars define what is in +# flight. A sidecar, not a fresh heartbeat: a resumed or roleless session is +# still in flight. +# +# Two modes. A full run asks the forge (`gh`) and caches what it learned under +# ${TASK_FORCE_HOME}/monitor//. `--local` asks nothing: `gh` is shadowed +# by a function that fails, so every forge-backed hook takes its "could not ask" +# path and the run merges with the last full run's cache. That is what lets the +# front end refresh on a filesystem event without ever waiting on GitHub. +# +# Must run on /bin/bash 3.2: no associative arrays, no mapfile, and no pattern +# substitution over large strings (#266). Bodies go through awk and jq. + +# Resolve own path through symlinks -> AW_ROOT. +src="${BASH_SOURCE[0]}" +while [ -L "$src" ]; do + dir=$(cd -P "$(dirname "$src")" && pwd) + src=$(readlink "$src") + [[ $src != /* ]] && src="$dir/$src" +done +BIN_DIR=$(cd -P "$(dirname "$src")" && pwd) +AW_ROOT=$(dirname "$BIN_DIR") + +# shellcheck source=lib/detect-impl.sh +source "$AW_ROOT/lib/detect-impl.sh" +# shellcheck source=lib/repo-name.sh +source "$AW_ROOT/lib/repo-name.sh" +# shellcheck source=lib/tracker-settings.sh +source "$AW_ROOT/lib/tracker-settings.sh" +# shellcheck source=lib/monitor-text.sh +source "$AW_ROOT/lib/monitor-text.sh" + +usage() { + cat <<'EOF' +Usage: task-monitor-snapshot [--json] [--all] [--local] [--repo DIR] + +Print one JSON document (schema 1) describing a repo's in-flight, next and done +work and its attention signals. See docs/task-monitor-schema.md. + + --json the output format (the only one; accepted for clarity) + --all every repo radio knows about (session files' REPO=), plus this one + --local no forge calls: read disk only and merge with the last full run + --repo DIR describe the repo containing DIR instead of $PWD + +Reads ${TASK_FORCE_HOME:-~/.task-force}. Writes only its own cache, under +${TASK_FORCE_HOME}/monitor//. +EOF + exit "${1:-0}" +} + +ALL=0 +LOCAL_ONLY=0 +REPO_ARG="" +while [[ $# -gt 0 ]]; do + case "$1" in + --json) shift ;; + --all) ALL=1; shift ;; + --local) LOCAL_ONLY=1; shift ;; + --repo) + [[ $# -ge 2 ]] || { echo "Error: --repo requires a value" >&2; exit 2; } + REPO_ARG="$2"; shift 2 ;; + -h|--help) usage 0 ;; + *) echo "Error: unknown argument: $1" >&2; usage 2 >&2 ;; + esac +done + +command -v jq >/dev/null 2>&1 || { echo "Error: jq is required" >&2; exit 1; } + +TF_HOME="${TASK_FORCE_HOME:-$HOME/.task-force}" +SESSIONS_DIR="$TF_HOME/radio/sessions" +MAILBOX_DIR="$TF_HOME/radio/mailbox" +DEAD_LETTER_DIR="$TF_HOME/radio/dead-letter" +MONITOR_HOME="$TF_HOME/monitor" + +# How many items Next and Done show at least. Done shows the last 24 hours or +# the last DONE_MIN merges, whichever is more (#272's builder default). +NEXT_LIMIT=10 +DONE_MIN=10 +MERGED_INDEX_LIMIT=50 + +if [[ "$LOCAL_ONLY" == 1 ]]; then + # The whole of --local's "no forge calls" guarantee. Every forge read in this + # file and in the tracker hooks goes through `gh`, so shadowing it makes each + # one fail fast and fall back to the cache; nothing has to remember to check + # a flag. + gh() { return 1; } +fi + +WORK=$(mktemp -d) +trap 'rm -rf "$WORK"' EXIT + +NOW=$(date -u +%Y-%m-%dT%H:%M:%SZ) +CUTOFF=$(jq -rn --arg now "$NOW" '($now | fromdateiso8601) - 86400 | todate') + +# ----- helpers -------------------------------------------------------------- + +# One KEY=VALUE field of a session or .info file. Never `source` either: both +# are written by other processes. +_field() { + local line + [[ -f "$1" ]] || return 0 + line=$(grep -E "^$2=" "$1" 2>/dev/null | head -1) || return 0 + printf '%s' "${line#*=}" +} + +_atomic_write() { + local target="$1" tmp + tmp="$target.tmp.$$" + mkdir -p "$(dirname "$target")" + cat > "$tmp" && mv -f "$tmp" "$target" +} + +# Every radio session file as one object, {role: {KEY: value}}. +_sessions_json() { + local f + for f in "$SESSIONS_DIR"/*.info; do + [[ -f "$f" ]] || continue + awk -v r="$(basename "$f" .info)" ' + index($0, "=") > 1 { print r "\t" substr($0, 1, index($0, "=") - 1) "\t" substr($0, index($0, "=") + 1) } + ' "$f" + done | jq -Rsc ' + split("\n") | map(select(. != "") | split("\t")) + | reduce .[] as $l ({}; .[$l[0]][$l[1]] = ($l[2:] | join("\t")))' +} + +# Every radio message's frontmatter, one object per line: inbox, processed and +# dead-letter alike. The radio log is no use here — its `send` lines carry no +# `pr=` field — so the PR timeline is rebuilt from the messages themselves. +_messages_ndjson() { + local dirs=() d + for d in "$MAILBOX_DIR" "$DEAD_LETTER_DIR"; do + [[ -d "$d" ]] && dirs+=("$d") + done + [[ ${#dirs[@]} -gt 0 ]] || return 0 + find "${dirs[@]}" -type f -name '*.md' -exec awk ' + function flush() { + if (fname != "" && !printed) { + print fname "\t" id "\t" from "\t" to "\t" intent "\t" pr "\t" created + printed = 1 + } + } + FNR == 1 { flush(); fname = FILENAME; printed = 0; fence = 0 + id = ""; from = ""; to = ""; intent = ""; pr = ""; created = "" } + /^---[ \t\r]*$/ { fence++; if (fence >= 2) { flush(); nextfile } ; next } + fence != 1 { next } + index($0, "id: ") == 1 { id = substr($0, 5) } + index($0, "from: ") == 1 { from = substr($0, 7) } + index($0, "to: ") == 1 { to = substr($0, 5) } + index($0, "intent: ") == 1 { intent = substr($0, 9) } + index($0, "pr: ") == 1 { pr = substr($0, 5) } + index($0, "created_at: ") == 1 { created = substr($0, 13) } + END { flush() } + ' {} + | jq -Rc --arg dl "$DEAD_LETTER_DIR/" ' + split("\t") as $f | ($f[0] | split("/")) as $p + | {id: $f[1], from: $f[2], to: $f[3], intent: $f[4], + pr: (if $f[5] == "" then null else $f[5] end), created_at: $f[6], + box: (if ($f[0] | startswith($dl)) then "dead-letter" else $p[-2] end), + mailbox: (if ($f[0] | startswith($dl)) then $p[-2] else $p[-3] end)}' +} + +# The repos to describe: this one (or --repo's), and with --all every repo a +# session file names. A worker's REPO= is its worktree, so each is resolved to +# its main root; a worktree that is gone resolves to nothing and is skipped. +_repo_roots() { + local here="" f r + if [[ -n "$REPO_ARG" ]]; then + [[ -d "$REPO_ARG" ]] || { echo "Error: --repo '$REPO_ARG' is not a directory" >&2; return 1; } + here=$(aw_main_repo_root "$REPO_ARG") || { echo "Error: --repo '$REPO_ARG' is not in a git repo" >&2; return 1; } + else + here=$(aw_main_repo_root "$PWD" 2>/dev/null) || here="" + fi + if [[ -z "$here" && "$ALL" != 1 ]]; then + echo "Error: not in a git repo (pass --repo DIR, or --all)" >&2 + return 1 + fi + { + [[ -n "$here" ]] && printf '%s\n' "$here" + if [[ "$ALL" == 1 ]]; then + for f in "$SESSIONS_DIR"/*.info; do + [[ -f "$f" ]] || continue + r=$(_field "$f" REPO) + [[ -n "$r" && -d "$r" ]] || continue + aw_main_repo_root "$r" || true + done | sort -u + fi + } | awk 'NF && !seen[$0]++' +} + +# The loadout a repo runs: its one workflow doc, else what its PM registered +# with. Empty when neither says — the repo is still described, with the default +# tracker hooks. +_repo_impl() { + local root="$1" name="$2" matches + matches=$(aw_detect_matches "$root") + if [[ -n "$matches" && $(printf '%s\n' "$matches" | wc -l) -eq 1 ]]; then + printf '%s' "$matches" + return 0 + fi + _field "$SESSIONS_DIR/pm-$name.info" LOADOUT +} + +# An item object for : live from the tracker, else the last full run's +# cache, else the default `ref · slug`. Prints "\t" so the caller +# knows whether there is anything new to cache. +_describe() { + local ref="$1" slug="$2" root="$3" cache_json="$4" item + if item=$(aw_tracker_item_describe "$ref" "$slug" "$root" 2>/dev/null) && [[ -n "$item" ]]; then + printf 'live\t%s\n' "$item" + return 0 + fi + item=$(jq -c --arg ref "$ref" '.issues[$ref] // empty' <<<"$cache_json") + if [[ -n "$item" ]]; then + printf 'cache\t%s\n' "$item" + return 0 + fi + printf 'default\t%s\n' "$(_aw_tracker_item_describe_default "$ref" "$slug")" +} + +# Fetch merged PR into the on-disk cache, once ever: a merged PR never +# changes. A no-op under --local, and when it is already cached. +_cache_merged_pr() { + local root="$1" dir="$2" n="$3" json + [[ -s "$dir/$n.json" ]] && return 0 + json=$(cd "$root" && gh pr view "$n" --json number,title,body,url,headRefName,mergedAt,mergeCommit,state 2>/dev/null) || return 0 + [[ -n "$json" ]] || return 0 + printf '%s\n' "$json" | _atomic_write "$dir/$n.json" +} + +# PR object for the monitor: the forge's fields plus the plain-text summary. +_pr_object() { + local pr_json="$1" summary + summary=$(jq -r '.body // ""' <<<"$pr_json" | tm_pr_summary) + jq -c --arg s "$summary" '{ + number, url, title, state: (.state // null), is_draft: (.isDraft // false), + head: (.headRefName // null), summary: (if $s == "" then null else $s end), + merged_at: (.mergedAt // null), merge_sha: (.mergeCommit.oid // null)}' <<<"$pr_json" +} + +# ----- one repo ------------------------------------------------------------- + +# Writes the repo object to stdout. Runs in a subshell per repo, so the tracker +# module it sources never leaks into the next repo's. +_snapshot_repo() { + local root="$1" names_json="$2" name wtbase impl tracker agent doc cache prev + local w="$WORK/$3" + mkdir -p "$w" + name=$(aw_main_repo_name "$root") + # task-work builds the base from the raw basename, not the sanitized name. + wtbase="$(dirname "$root")/$(basename "$root")-worktrees" + impl=$(_repo_impl "$root" "$name") + tracker=""; agent=""; doc="" + if [[ -n "$impl" ]]; then + tracker="${impl##*-}" + agent="${impl%%-*}" + doc=$(aw_impl_workflow_doc "$root" "$impl" || true) + fi + + # shellcheck source=lib/trackers/_default.sh + source "$AW_ROOT/lib/trackers/_default.sh" + if [[ -n "$tracker" && -r "$AW_ROOT/lib/trackers/$tracker.sh" ]]; then + # shellcheck source=/dev/null + source "$AW_ROOT/lib/trackers/$tracker.sh" + fi + + cache="$MONITOR_HOME/$name" + prev='{}' + [[ -s "$cache/forge.json" ]] && prev=$(jq -c . "$cache/forge.json" 2>/dev/null || echo '{}') + [[ "$LOCAL_ONLY" == 1 ]] || mkdir -p "$cache/prs" + + # --- forge: the PR lists. A failed call keeps the previous result rather + # than blanking it, and fetched_at moves only when the forge answered. + local open_prs merged_index fetched_at forge_ok=0 + fetched_at=$(jq -r '.fetched_at // empty' <<<"$prev") + if open_prs=$(cd "$root" && gh pr list --state open --limit 100 \ + --json number,title,body,url,headRefName,isDraft,state 2>/dev/null) && [[ -n "$open_prs" ]]; then + fetched_at="$NOW" + forge_ok=1 + else + open_prs=$(jq -c '.open_prs // null' <<<"$prev") + fi + if ! { merged_index=$(cd "$root" && gh pr list --state merged --limit "$MERGED_INDEX_LIMIT" \ + --json number,mergedAt,headRefName 2>/dev/null) && [[ -n "$merged_index" ]]; }; then + merged_index=$(jq -c '.merged // null' <<<"$prev") + fi + merged_index=$(jq -c 'if . == null then null else sort_by(.mergedAt) | reverse end' <<<"$merged_index") + + # --- in flight: one sidecar per worktree. Reviewers' sidecars carry + # PR_NUMBER=; they attach to their PR's item instead of being one. + # issues_new collects what the tracker said this run; refs_seen every ref this + # run looked up, so the cache keeps only descriptions something still shows. + local info slug ref key described src item pr_json n issues_new='{}' refs_seen='' + : > "$w/inflight.ndjson" + : > "$w/reviewers.ndjson" + for info in "$wtbase"/.*.info; do + [[ -f "$info" ]] || continue + slug=$(_field "$info" SLUG) + [[ -n "$slug" ]] || { slug=$(basename "$info" .info); slug="${slug#.}"; } + n=$(_field "$info" PR_NUMBER) + if [[ -n "$n" ]]; then + jq -cn --arg slug "$slug" --arg pr "$n" '{slug: $slug, pr: $pr}' >> "$w/reviewers.ndjson" + continue + fi + ref="" + key=$(aw_tracker_info_key 2>/dev/null || true) + [[ -n "$key" ]] && ref=$(_field "$info" "$key") + if [[ -z "$ref" ]]; then + for key in GH_URL JIRA_REF NOTION_URL TASK_FILE; do + ref=$(_field "$info" "$key") + [[ -n "$ref" ]] && break + done + fi + described=$(_describe "$ref" "$slug" "$root" "$prev") + refs_seen+="$ref"$'\n' + src="${described%%$'\t'*}" + item="${described#*$'\t'}" + if [[ "$src" == live && -n "$ref" ]]; then + issues_new=$(jq -c --arg ref "$ref" --argjson item "$item" '.[$ref] = $item' <<<"$issues_new") + fi + + # The PR is found by its head branch, which is task/ on every tracker. + pr_json=$(jq -c --arg h "task/$slug" '[(. // [])[] | select(.headRefName == $h)][0] // empty' <<<"$open_prs") + if [[ -z "$pr_json" ]]; then + n=$(jq -r --arg h "task/$slug" '[(. // [])[] | select(.headRefName == $h)][0].number // empty' <<<"$merged_index") + if [[ -n "$n" ]]; then + [[ "$LOCAL_ONLY" == 1 ]] || _cache_merged_pr "$root" "$cache/prs" "$n" + if [[ -s "$cache/prs/$n.json" ]]; then + pr_json=$(jq -c . "$cache/prs/$n.json") + else + pr_json=$(jq -cn --argjson n "$n" '{number: $n, state: "MERGED"}') + fi + fi + fi + [[ -n "$pr_json" ]] && pr_json=$(_pr_object "$pr_json") || pr_json=null + + jq -cn --arg slug "$slug" --arg ref "$ref" --arg base "$(_field "$info" BASE_BRANCH)" \ + --arg tab_id "$(_field "$info" TAB_ID)" --arg wt "$wtbase/$slug" \ + --argjson item "$item" --argjson pr "$pr_json" ' + {slug: $slug, ref: (if $ref == "" then null else $ref end), branch: ("task/" + $slug), + base: (if $base == "" then null else $base end), worktree: $wt, + tab_id: (if $tab_id == "" then null else $tab_id end), item: $item, pr: $pr}' \ + >> "$w/inflight.ndjson" + done + + # --- next: the PM's queue, else the tracker's fallback. + # + # The queue file's format is owned by the `task-monitor next` command (#272 + # sub-issue 3). Until that lands this reads the shape it is expected to keep: + # one ref per line, an optional note after a tab, `#` comments ignored. + local queue="$cache/queue" next_source=none line note fallback fb_ok=1 + : > "$w/next.ndjson" + if [[ -s "$queue" ]]; then + while IFS= read -r line || [[ -n "$line" ]]; do + case "$line" in ''|'#'*) continue ;; esac + ref="${line%%$'\t'*}" + note="" + [[ "$line" == *$'\t'* ]] && note="${line#*$'\t'}" + described=$(_describe "$ref" "" "$root" "$prev") + refs_seen+="$ref"$'\n' + src="${described%%$'\t'*}" + item="${described#*$'\t'}" + if [[ "$src" == live ]]; then + issues_new=$(jq -c --arg ref "$ref" --argjson item "$item" '.[$ref] = $item' <<<"$issues_new") + fi + jq -c --arg note "$note" '. + {note: (if $note == "" then null else $note end)}' <<<"$item" >> "$w/next.ndjson" + next_source=queue + done < "$queue" + fi + local inflight_count + inflight_count=$(wc -l < "$w/inflight.ndjson" | tr -d ' ') + if [[ "$next_source" == none ]]; then + if fallback=$(aw_tracker_next_fallback "$root" "$doc" "$((NEXT_LIMIT + inflight_count))" 2>/dev/null); then + : + else + fb_ok=0 + fallback=$(jq -c '(.next_fallback // [])[]' <<<"$prev") + fi + if [[ -n "$fallback" ]]; then + printf '%s\n' "$fallback" | jq -c '. + {note: null}' >> "$w/next.ndjson" + next_source=tracker + fi + fi + + # --- done: merged PRs that deliver an item, newest first. Each is fetched + # once and cached for good; under --local only cached ones are shown. + local merged_at head body + : > "$w/done.ndjson" + if [[ "$merged_index" != null ]]; then + local count=0 + while IFS=$'\t' read -r n merged_at; do + [[ -n "$n" ]] || continue + if [[ "$count" -ge "$DONE_MIN" && "$merged_at" < "$CUTOFF" ]]; then + break + fi + [[ "$LOCAL_ONLY" == 1 ]] || _cache_merged_pr "$root" "$cache/prs" "$n" + [[ -s "$cache/prs/$n.json" ]] || continue + pr_json=$(jq -c . "$cache/prs/$n.json") + head=$(jq -r '.headRefName // ""' <<<"$pr_json") + body=$(jq -r '.body // ""' <<<"$pr_json") + item=$(aw_tracker_pr_item "$head" "$body" 2>/dev/null) || continue + [[ -n "$item" ]] || continue + jq -cn --argjson item "$item" --argjson pr "$(_pr_object "$pr_json")" --argjson raw "$pr_json" ' + $item + {title: $pr.title, description: $pr.summary, summary: $pr.summary, body: ($raw.body // ""), + url: $pr.url, pr: $pr | del(.summary), merged_at: $pr.merged_at, merge_sha: $pr.merge_sha}' \ + >> "$w/done.ndjson" + count=$((count + 1)) + done < <(jq -r '(. // [])[] | [(.number | tostring), (.mergedAt // "")] | @tsv' <<<"$merged_index") + fi + + # --- the cache for the next run. Only parts that answered are replaced. + if [[ "$LOCAL_ONLY" != 1 ]]; then + local fb_json=null + if [[ "$fb_ok" == 1 && "$next_source" != queue ]]; then + fb_json=$(printf '%s\n' "$fallback" | jq -sc 'map(select(. != null))') + fi + jq -c --arg at "$fetched_at" --argjson open "$open_prs" --argjson merged "$merged_index" \ + --argjson issues "$issues_new" --argjson fb "$fb_json" --arg seen "$refs_seen" ' + ($seen | split("\n") | map(select(. != ""))) as $seen + | .fetched_at = (if $at == "" then null else $at end) + | .open_prs = $open | .merged = $merged + | .issues = (((.issues // {}) | with_entries(select(.key as $k | $seen | index([$k]) != null))) + $issues) + | if $fb != null then .next_fallback = $fb else . end' <<<"$prev" \ + | _atomic_write "$cache/forge.json" + fi + + # --- assemble. + jq -n \ + --arg name "$name" --arg root "$root" --arg impl "$impl" --arg tracker "$tracker" \ + --arg agent "$agent" --arg fetched_at "$fetched_at" \ + --arg next_source "$next_source" --argjson next_limit "$NEXT_LIMIT" \ + --argjson forge_ok "$forge_ok" --argjson names "$names_json" \ + --argjson open_prs "$open_prs" --argjson merged "$merged_index" \ + --slurpfile sessions "$WORK/sessions.json" \ + --slurpfile messages "$WORK/messages.ndjson" \ + --slurpfile inflight "$w/inflight.ndjson" \ + --slurpfile reviewer_infos "$w/reviewers.ndjson" \ + --slurpfile next "$w/next.ndjson" \ + --slurpfile done_items "$w/done.ndjson" \ + -f "$AW_ROOT/lib/monitor-assemble.jq" +} + +# ----- main ----------------------------------------------------------------- + +ROOTS=$(_repo_roots) || exit 1 + +_sessions_json > "$WORK/sessions.json" +[[ -s "$WORK/sessions.json" ]] || echo '{}' > "$WORK/sessions.json" +_messages_ndjson > "$WORK/messages.ndjson" + +# Every repo name radio knows, so a role is attributed to the longest repo name +# it starts with: `worker-task-force-x` belongs to task-force, not to `task`. +NAMES=$({ + # aw_main_repo_name prints no newline; without one each name would run into + # the next. + printf '%s\n' "$ROOTS" | while IFS= read -r r; do [[ -n "$r" ]] && { aw_main_repo_name "$r"; echo; }; done + jq -r '.[] | .REPO_NAME // empty' "$WORK/sessions.json" +} | jq -Rsc 'split("\n") | map(select(. != "")) | unique') + +# One subshell per repo, run side by side: each spends most of its time +# waiting on `gh`, and they share nothing but read-only inputs. A repo that +# fails is reported and left out rather than failing the whole document. +i=0 +while IFS= read -r root; do + [[ -n "$root" ]] || continue + i=$((i + 1)) + ( _snapshot_repo "$root" "$NAMES" "repo$i" > "$WORK/repo$i.json" ) & +done <<<"$ROOTS" +wait + +: > "$WORK/repos.ndjson" +j=0 +while [[ "$j" -lt "$i" ]]; do + j=$((j + 1)) + if jq -e . "$WORK/repo$j.json" >/dev/null 2>&1; then + cat "$WORK/repo$j.json" >> "$WORK/repos.ndjson" + else + echo "task-monitor-snapshot: skipped $(sed -n "${j}p" <<<"$ROOTS"): its snapshot failed" >&2 + fi +done + +# Every repo failing is a failure, not an empty board: the front end keeps its +# last good snapshot on a non-zero exit instead of rendering nothing. +if [[ "$i" -gt 0 && ! -s "$WORK/repos.ndjson" ]]; then + exit 1 +fi + +jq -s --arg now "$NOW" --arg mode "$([[ "$LOCAL_ONLY" == 1 ]] && echo local || echo full)" \ + '{schema: 1, generated_at: $now, mode: $mode, repos: .}' "$WORK/repos.ndjson" diff --git a/claude-gh/install.sh b/claude-gh/install.sh index 8a53a77..dcd4b29 100755 --- a/claude-gh/install.sh +++ b/claude-gh/install.sh @@ -11,8 +11,9 @@ echo "Installing claude-gh scripts..." # task-done and task-work resolve the same way but then compose modules out of # lib/trackers/ (and, for task-work, lib/agents/) instead of exec'ing a # per-loadout copy (#236, #237); -# task-board, task-pm, radio, task-config, task-remove and -# task-recreate-worker are canonical single copies (#170, #219, #220, #230, #238 — +# task-board, task-pm, radio, task-config, task-remove, +# task-recreate-worker and task-monitor-snapshot are canonical single copies +# (#170, #219, #220, #230, #238, #273 — # task-config and task-remove act *across* loadouts, so a per-loadout copy would # make no sense; task-pm and task-recreate-worker differ by agent only in their # final launch line). @@ -42,6 +43,8 @@ ln -sf "$SCRIPT_DIR/../bin/task-remove" ~/.local/bin/task-remove echo " ✓ Script: task-remove (canonical)"; sleep 0.05 ln -sf "$SCRIPT_DIR/../bin/task-recreate-worker" ~/.local/bin/task-recreate-worker echo " ✓ Script: task-recreate-worker (canonical)"; sleep 0.05 +ln -sf "$SCRIPT_DIR/../bin/task-monitor-snapshot" ~/.local/bin/task-monitor-snapshot +echo " ✓ Script: task-monitor-snapshot (canonical)"; sleep 0.05 # endregion:install-shared-symlinks ln -sf "$SCRIPT_DIR/../bin/task-reviewer" ~/.local/bin/task-reviewer echo " ✓ Script: task-reviewer (canonical + kiro routing)"; sleep 0.05 diff --git a/claude-gh/steering/gh-workflow.example.md b/claude-gh/steering/gh-workflow.example.md index 10c0c1e..17082a0 100644 --- a/claude-gh/steering/gh-workflow.example.md +++ b/claude-gh/steering/gh-workflow.example.md @@ -261,6 +261,30 @@ gh run list -c "$(git rev-parse HEAD)" --limit 1 --json databaseId --jq 'length' # 0 => no run for this commit; "green" is not a claim you can make ``` +`task-monitor-snapshot` — the board as JSON: in flight, next, done, and whether this repo needs you (#273) + +It prints one JSON document (`"schema": 1`, documented in task-force's +`docs/task-monitor-schema.md`) and is the data layer of task-monitor. Every +value comes from disk, the tracker or the forge, and no model is asked to write +or summarise any of it. + +- **In flight** is every worktree whose `..info` sidecar exists, joined to + its radio session, its PR and the latest review intent. +- **Next** is the PM's queue, else the tracker's backlog (gh project items in + none of the three statuses, or the local `tasks/` files). There is none on + jira or notion. +- **Done** is the merged PRs that delivered an item in the last 24 hours, or + the last 10, whichever is more. +- **Attention** fires on exactly three signals: an unread PM inbox, a finished + review on a still-open PR that the PM has not answered, and a worker in + `awaiting`. The `awaiting` signal is claude-only for now. + +```bash +task-monitor-snapshot --json # this repo +task-monitor-snapshot --all # every repo radio knows about +task-monitor-snapshot --local # no forge calls: disk + the last full run's cache +``` + ### PM ↔ worker messaging (radio) Radio is the **canonical** coordination channel between the PM and workers — every diff --git a/claude-jira/install.sh b/claude-jira/install.sh index da91946..5da9c1c 100755 --- a/claude-jira/install.sh +++ b/claude-jira/install.sh @@ -11,8 +11,9 @@ echo "Installing claude-jira scripts..." # task-done and task-work resolve the same way but then compose modules out of # lib/trackers/ (and, for task-work, lib/agents/) instead of exec'ing a # per-loadout copy (#236, #237); -# task-board, task-pm, radio, task-config, task-remove and -# task-recreate-worker are canonical single copies (#170, #219, #220, #230, #238 — +# task-board, task-pm, radio, task-config, task-remove, +# task-recreate-worker and task-monitor-snapshot are canonical single copies +# (#170, #219, #220, #230, #238, #273 — # task-config and task-remove act *across* loadouts, so a per-loadout copy would # make no sense; task-pm and task-recreate-worker differ by agent only in their # final launch line). @@ -42,6 +43,8 @@ ln -sf "$SCRIPT_DIR/../bin/task-remove" ~/.local/bin/task-remove echo " ✓ Script: task-remove (canonical)"; sleep 0.05 ln -sf "$SCRIPT_DIR/../bin/task-recreate-worker" ~/.local/bin/task-recreate-worker echo " ✓ Script: task-recreate-worker (canonical)"; sleep 0.05 +ln -sf "$SCRIPT_DIR/../bin/task-monitor-snapshot" ~/.local/bin/task-monitor-snapshot +echo " ✓ Script: task-monitor-snapshot (canonical)"; sleep 0.05 # endregion:install-shared-symlinks ln -sf "$SCRIPT_DIR/../bin/task-reviewer" ~/.local/bin/task-reviewer echo " ✓ Script: task-reviewer (canonical + kiro routing)"; sleep 0.05 diff --git a/claude-jira/steering/jira-workflow.example.md b/claude-jira/steering/jira-workflow.example.md index 67b0f9c..1112b73 100644 --- a/claude-jira/steering/jira-workflow.example.md +++ b/claude-jira/steering/jira-workflow.example.md @@ -261,6 +261,30 @@ gh run list -c "$(git rev-parse HEAD)" --limit 1 --json databaseId --jq 'length' # 0 => no run for this commit; "green" is not a claim you can make ``` +`task-monitor-snapshot` — the board as JSON: in flight, next, done, and whether this repo needs you (#273) + +It prints one JSON document (`"schema": 1`, documented in task-force's +`docs/task-monitor-schema.md`) and is the data layer of task-monitor. Every +value comes from disk, the tracker or the forge, and no model is asked to write +or summarise any of it. + +- **In flight** is every worktree whose `..info` sidecar exists, joined to + its radio session, its PR and the latest review intent. +- **Next** is the PM's queue, else the tracker's backlog (gh project items in + none of the three statuses, or the local `tasks/` files). There is none on + jira or notion. +- **Done** is the merged PRs that delivered an item in the last 24 hours, or + the last 10, whichever is more. +- **Attention** fires on exactly three signals: an unread PM inbox, a finished + review on a still-open PR that the PM has not answered, and a worker in + `awaiting`. The `awaiting` signal is claude-only for now. + +```bash +task-monitor-snapshot --json # this repo +task-monitor-snapshot --all # every repo radio knows about +task-monitor-snapshot --local # no forge calls: disk + the last full run's cache +``` + ### PM ↔ worker messaging (radio) Radio is the **canonical** coordination channel between the PM and workers — every diff --git a/claude-local/install.sh b/claude-local/install.sh index 05e6e42..b8f661c 100755 --- a/claude-local/install.sh +++ b/claude-local/install.sh @@ -11,8 +11,9 @@ echo "Installing claude-local scripts..." # task-done and task-work resolve the same way but then compose modules out of # lib/trackers/ (and, for task-work, lib/agents/) instead of exec'ing a # per-loadout copy (#236, #237); -# task-board, task-pm, radio, task-config, task-remove and -# task-recreate-worker are canonical single copies (#170, #219, #220, #230, #238 — +# task-board, task-pm, radio, task-config, task-remove, +# task-recreate-worker and task-monitor-snapshot are canonical single copies +# (#170, #219, #220, #230, #238, #273 — # task-config and task-remove act *across* loadouts, so a per-loadout copy would # make no sense; task-pm and task-recreate-worker differ by agent only in their # final launch line). @@ -42,6 +43,8 @@ ln -sf "$SCRIPT_DIR/../bin/task-remove" ~/.local/bin/task-remove echo " ✓ Script: task-remove (canonical)"; sleep 0.05 ln -sf "$SCRIPT_DIR/../bin/task-recreate-worker" ~/.local/bin/task-recreate-worker echo " ✓ Script: task-recreate-worker (canonical)"; sleep 0.05 +ln -sf "$SCRIPT_DIR/../bin/task-monitor-snapshot" ~/.local/bin/task-monitor-snapshot +echo " ✓ Script: task-monitor-snapshot (canonical)"; sleep 0.05 # endregion:install-shared-symlinks ln -sf "$SCRIPT_DIR/../bin/task-reviewer" ~/.local/bin/task-reviewer echo " ✓ Script: task-reviewer (canonical + kiro routing)"; sleep 0.05 diff --git a/claude-local/steering/local-workflow.example.md b/claude-local/steering/local-workflow.example.md index d4e3af3..240f2b3 100644 --- a/claude-local/steering/local-workflow.example.md +++ b/claude-local/steering/local-workflow.example.md @@ -283,6 +283,30 @@ gh run list -c "$(git rev-parse HEAD)" --limit 1 --json databaseId --jq 'length' # 0 => no run for this commit; "green" is not a claim you can make ``` +`task-monitor-snapshot` — the board as JSON: in flight, next, done, and whether this repo needs you (#273) + +It prints one JSON document (`"schema": 1`, documented in task-force's +`docs/task-monitor-schema.md`) and is the data layer of task-monitor. Every +value comes from disk, the tracker or the forge, and no model is asked to write +or summarise any of it. + +- **In flight** is every worktree whose `..info` sidecar exists, joined to + its radio session, its PR and the latest review intent. +- **Next** is the PM's queue, else the tracker's backlog (gh project items in + none of the three statuses, or the local `tasks/` files). There is none on + jira or notion. +- **Done** is the merged PRs that delivered an item in the last 24 hours, or + the last 10, whichever is more. +- **Attention** fires on exactly three signals: an unread PM inbox, a finished + review on a still-open PR that the PM has not answered, and a worker in + `awaiting`. The `awaiting` signal is claude-only for now. + +```bash +task-monitor-snapshot --json # this repo +task-monitor-snapshot --all # every repo radio knows about +task-monitor-snapshot --local # no forge calls: disk + the last full run's cache +``` + ### PM ↔ worker messaging (radio) Radio is the **canonical** coordination channel between the PM and workers — every diff --git a/claude-notion/install.sh b/claude-notion/install.sh index fe76f1b..64087e6 100755 --- a/claude-notion/install.sh +++ b/claude-notion/install.sh @@ -11,8 +11,9 @@ echo "Installing claude-notion scripts..." # task-done and task-work resolve the same way but then compose modules out of # lib/trackers/ (and, for task-work, lib/agents/) instead of exec'ing a # per-loadout copy (#236, #237); -# task-board, task-pm, radio, task-config, task-remove and -# task-recreate-worker are canonical single copies (#170, #219, #220, #230, #238 — +# task-board, task-pm, radio, task-config, task-remove, +# task-recreate-worker and task-monitor-snapshot are canonical single copies +# (#170, #219, #220, #230, #238, #273 — # task-config and task-remove act *across* loadouts, so a per-loadout copy would # make no sense; task-pm and task-recreate-worker differ by agent only in their # final launch line). @@ -42,6 +43,8 @@ ln -sf "$SCRIPT_DIR/../bin/task-remove" ~/.local/bin/task-remove echo " ✓ Script: task-remove (canonical)"; sleep 0.05 ln -sf "$SCRIPT_DIR/../bin/task-recreate-worker" ~/.local/bin/task-recreate-worker echo " ✓ Script: task-recreate-worker (canonical)"; sleep 0.05 +ln -sf "$SCRIPT_DIR/../bin/task-monitor-snapshot" ~/.local/bin/task-monitor-snapshot +echo " ✓ Script: task-monitor-snapshot (canonical)"; sleep 0.05 # endregion:install-shared-symlinks ln -sf "$SCRIPT_DIR/../bin/task-reviewer" ~/.local/bin/task-reviewer echo " ✓ Script: task-reviewer (canonical + kiro routing)"; sleep 0.05 diff --git a/claude-notion/steering/notion-workflow.example.md b/claude-notion/steering/notion-workflow.example.md index c4d060d..355906a 100644 --- a/claude-notion/steering/notion-workflow.example.md +++ b/claude-notion/steering/notion-workflow.example.md @@ -278,6 +278,30 @@ gh run list -c "$(git rev-parse HEAD)" --limit 1 --json databaseId --jq 'length' # 0 => no run for this commit; "green" is not a claim you can make ``` +`task-monitor-snapshot` — the board as JSON: in flight, next, done, and whether this repo needs you (#273) + +It prints one JSON document (`"schema": 1`, documented in task-force's +`docs/task-monitor-schema.md`) and is the data layer of task-monitor. Every +value comes from disk, the tracker or the forge, and no model is asked to write +or summarise any of it. + +- **In flight** is every worktree whose `..info` sidecar exists, joined to + its radio session, its PR and the latest review intent. +- **Next** is the PM's queue, else the tracker's backlog (gh project items in + none of the three statuses, or the local `tasks/` files). There is none on + jira or notion. +- **Done** is the merged PRs that delivered an item in the last 24 hours, or + the last 10, whichever is more. +- **Attention** fires on exactly three signals: an unread PM inbox, a finished + review on a still-open PR that the PM has not answered, and a worker in + `awaiting`. The `awaiting` signal is claude-only for now. + +```bash +task-monitor-snapshot --json # this repo +task-monitor-snapshot --all # every repo radio knows about +task-monitor-snapshot --local # no forge calls: disk + the last full run's cache +``` + ### PM ↔ worker messaging (radio) Radio is the **canonical** coordination channel between the PM and workers — every diff --git a/docs/task-monitor-schema.example.json b/docs/task-monitor-schema.example.json new file mode 100644 index 0000000..54e4bd6 --- /dev/null +++ b/docs/task-monitor-schema.example.json @@ -0,0 +1,144 @@ +{ + "schema": 1, + "generated_at": "2026-10-20T14:33:14Z", + "mode": "full", + "repos": [ + { + "name": "task-force", + "root": "/home/you/src/task-force", + "loadout": "claude-gh", + "tracker": "gh", + "agent": "claude", + "pm": { + "role": "pm-task-force", + "state": "idle", + "since": "2026-10-18T20:49:28Z", + "tab": "⏸️ pm-task-force", + "tab_id": "0", + "zellij_session": "task-force", + "agent": "claude", + "alias_of": null, + "unread": 1 + }, + "forge": { + "fetched_at": "2026-10-20T14:33:14Z", + "stale": false + }, + "in_flight": [ + { + "slug": "monitor-snapshot", + "ref": "https://github.com/martin-conur/task-force/issues/273", + "branch": "task/monitor-snapshot", + "base": "main", + "worktree": "/home/you/src/task-force-worktrees/monitor-snapshot", + "label": "#273", + "number": 273, + "url": "https://github.com/martin-conur/task-force/issues/273", + "title": "task-monitor 1/6: snapshot --json collector (bash) and schema", + "description": "Part of #272 (see the epic body for the full design). This is the data layer; everything else reads it.", + "item": { + "ref": "https://github.com/martin-conur/task-force/issues/273", + "label": "#273", + "number": 273, + "title": "task-monitor 1/6: snapshot --json collector (bash) and schema", + "description": "Part of #272 (see the epic body for the full design). This is the data layer; everything else reads it.", + "url": "https://github.com/martin-conur/task-force/issues/273" + }, + "pr": null, + "status": "implementing", + "last_intent": null, + "session": { + "role": "worker-task-force-monitor-snapshot", + "state": "busy", + "since": "2026-10-20T14:11:57Z", + "tab": "▶️ monitor-snapshot", + "tab_id": "1", + "zellij_session": "task-force", + "agent": "claude" + }, + "tab_id": "1", + "reviewers": [] + } + ], + "next": { + "source": "tracker", + "items": [ + { + "ref": "19", + "label": "#19", + "number": 19, + "title": "installing and init should select code assistant separately from the task tracking", + "url": "https://github.com/martin-conur/task-force/issues/19", + "description": "The install + init flow forces users to pick a *combination* (e.g. `claude-gh`) from a flattened 7–8-line TUI list. The codebase mirrors this: there are seven cross-product loadout directories (`claude-jira`, `claude-notion`, `claude-gh`, `claude-local`, `kiro-notion`, `kiro-gh`, `kiro-local`).", + "note": null + }, + { + "ref": "90", + "label": "#90", + "number": 90, + "title": "README: add demo gif of the PM ↔ worker radio handshake", + "url": "https://github.com/martin-conur/task-force/issues/90", + "description": "The README is dense and well-written but text-heavy. A new visitor scrolling to the radio messaging or workflow sections has no immediate visceral sense of *what the experience feels like* — multiple zellij tabs, the PM tab focusing when a worker pings, the worker waking up on `approved-and-merged`. A 30–60s demo recording is the single highest-ROI step for adoption.", + "note": null + } + ] + }, + "done": { + "window_hours": 24, + "min_items": 10, + "items": [ + { + "ref": "266", + "label": "#266", + "number": 266, + "title": "task-remove hangs at 100% CPU in _la_strip_line under bash 3.2 with a UTF-8 locale (#266)", + "description": "Two \"is anything left?\" checks in `lib/loadout-artifacts.sh` used `${s//[[:space:]]/}`. Bash 3.2's matcher is superlinear on a bracket class under a multibyte locale. On macOS `/bin/bash`, an 8KB UTF-8 file took 25s and a ~30KB `CLAUDE.md` never finished. The two checks:", + "url": "https://github.com/martin-conur/task-force/pull/269", + "summary": "Two \"is anything left?\" checks in `lib/loadout-artifacts.sh` used `${s//[[:space:]]/}`. Bash 3.2's matcher is superlinear on a bracket class under a multibyte locale. On macOS `/bin/bash`, an 8KB UTF-8 file took 25s and a ~30KB `CLAUDE.md` never finished. The two checks:", + "body": "Closes #266\n\n## What\nTwo \"is anything left?\" checks in `lib/loadout-artifacts.sh` used `${s//[[:space:]]/}`. Bash 3.2's matcher is superlinear on a bracket clas…", + "pr": { + "number": 269, + "url": "https://github.com/martin-conur/task-force/pull/269", + "title": "task-remove hangs at 100% CPU in _la_strip_line under bash 3.2 with a UTF-8 locale (#266)", + "state": "MERGED", + "is_draft": false, + "head": "task/task-remove-hang", + "merged_at": "2026-10-19T17:24:59Z", + "merge_sha": "1415b86f7b499bdbb7057fcdc771084ff8717fd3" + }, + "merged_at": "2026-10-19T17:24:59Z", + "merge_sha": "1415b86f7b499bdbb7057fcdc771084ff8717fd3" + } + ] + }, + "attention": { + "active": true, + "signals": [ + { + "kind": "unread-inbox", + "count": 1, + "text": "✉ 1 unread" + }, + { + "kind": "review-waiting-merge", + "prs": [ + 270 + ], + "text": "1 review waiting merge" + }, + { + "kind": "worker-awaiting", + "workers": [ + { + "role": "worker-task-force-pr-summary-section", + "slug": "pr-summary-section", + "since": "2026-10-20T14:02:11Z" + } + ], + "text": "❓ pr-summary-section is asking" + } + ] + } + } + ] +} diff --git a/docs/task-monitor-schema.md b/docs/task-monitor-schema.md new file mode 100644 index 0000000..49a94fd --- /dev/null +++ b/docs/task-monitor-schema.md @@ -0,0 +1,216 @@ +# task-monitor snapshot schema (version 1) + +`task-monitor-snapshot --json` prints one JSON document describing what is in +flight, next and done in a repo, plus the attention signals that say whether the +repo needs you (#273, epic #272). It is the contract between the bash collector +and the ratatui front end. A full example, taken from this repo, is in +[`task-monitor-schema.example.json`](task-monitor-schema.example.json). + +Every value is read deterministically: from radio's on-disk state, task-work's +worktree sidecars, the tracker, and the forge (`gh`). Nothing is written or +summarised by a model. + +``` +task-monitor-snapshot [--json] [--all] [--local] [--repo DIR] +``` + +| flag | effect | +|---|---| +| `--json` | the output format. It is the only one, and the flag is accepted for clarity | +| `--all` | every repo a radio session file names (`REPO=`, resolved to its main checkout), plus the current one | +| `--local` | no forge calls. Reads disk only and merges with the last full run's cache | +| `--repo DIR` | describe the repo containing `DIR` instead of `$PWD` | + +It exits non-zero only on bad arguments, outside a repo without `--all`, or when +every repo failed. A failure in one repo leaves that repo out and says so on +stderr. The front end keeps its last good snapshot on a non-zero exit. + +## Compatibility + +`schema` is bumped only for a change that breaks a reader: a field removed, +renamed or retyped. New fields can appear within version 1, so a reader must +ignore fields it does not know. + +## Top level + +| field | type | meaning | +|---|---|---| +| `schema` | number | `1` | +| `generated_at` | ISO-8601 UTC | when this document was produced | +| `mode` | `"full"` \| `"local"` | `local` when run with `--local` | +| `repos` | array of repo | the current repo first, then the others by path | + +## Repo + +| field | type | meaning | +|---|---|---| +| `name` | string | the main repo's name, sanitized to radio's role charset (`task-force`) | +| `root` | path | the main checkout | +| `loadout` | string \| null | `claude-gh` …, from the repo's one workflow doc, else from its PM session's `LOADOUT=` | +| `tracker`, `agent` | string \| null | the loadout's two halves | +| `pm` | object | the PM session (see below) | +| `forge` | object | `fetched_at`: the last time the forge answered, or null. `stale`: true when this run did not hear from the forge (always under `--local`) and shows the last result instead | +| `in_flight` | array | one entry per worktree sidecar | +| `next` | object | `source` (`queue` \| `tracker` \| `none`) and `items` | +| `done` | object | `window_hours` (24), `min_items` (10) and `items` | +| `attention` | object | `active` and `signals` | + +### `pm` + +`role`, `state` (`idle` \| `busy` \| `awaiting` \| null), `since` (the session's +`LAST_HEARTBEAT`, which every state change rewrites), `tab`, `tab_id`, +`zellij_session`, `agent`: all from `radio/sessions/pm-.info`, null when +there is no session. Then: + +- `alias_of`: the primary PM's role when this repo's PM is a `task-pm --also` + alias, else null. +- `unread`: messages in the PM's `inbox/`. An alias's inbox counts toward its + primary, so an aliased repo reports `0`. + +### Item fields + +Every item has these, whatever the tracker, so the front end never has to ask +which tracker made it: + +| field | meaning | +|---|---| +| `ref` | what the tracker names the item by: an issue URL or number, a Jira key, a Notion URL, a task file | +| `label` | what a list shows: `#267`, `PROJ-42`, a local task id. Null when there is nothing short | +| `number` | the issue number or numeric task id, else null | +| `title` | one line | +| `description` | one plain paragraph, or null | +| `url` | what `o` opens, or null | + +How `title` and `description` are found: + +| | gh / local | jira / notion | +|---|---|---| +| before a PR exists | issue title and the first prose paragraph of the body (gh via `gh issue view`; local from the `tasks/` file's frontmatter `title` and body) | the ref and the worker slug, `PROJ-42 · kiro-config`. The shell cannot read these trackers | +| once a PR exists | the PR title, and its `## Summary` | the same | + +The "first prose paragraph" skips headings, HTML comments, fences and rules. A +list item starts a paragraph of its own. A PR with no `## Summary` uses the +first paragraph after its `Closes #N` line, then its first paragraph. + +### `in_flight[]` + +An item is in flight while its `/..info` sidecar exists. +A fresh heartbeat is not required, so a resumed or roleless session is still in +flight. Reviewer sidecars (`PR_NUMBER=`) are not items; they attach to the item +whose PR they review. + +The item fields above, plus: + +| field | meaning | +|---|---| +| `slug`, `branch`, `base`, `worktree`, `tab_id` | from the sidecar; `branch` is `task/` | +| `item` | the tracker's own item object, before any PR overrides `title` / `description` | +| `pr` | `{number, url, title, state, is_draft, head, summary, merged_at, merge_sha}`, found by the `task/` head branch on every tracker, else null. If no PR has that head, the PR the worker named in its own radio messages is used | +| `status` | see below | +| `last_intent` | the latest radio message to or from this worker, or about its PR: `{intent, from, to, pr, at}` | +| `session` | the worker's radio session: `{role, state, since, tab, tab_id, zellij_session, agent}`, or null | +| `reviewers` | `[{pr, role, session}]` | + +`status` comes from the PR's state plus the latest review intent exchanged about +that PR: + +| status | when | +|---|---| +| `implementing` | no PR yet | +| `pr-open` | a PR, and no review intent yet (or only `review-requested`) | +| `under-review` | as `pr-open`, with a reviewer attached | +| `changes-requested` | the PM's last word was `changes-requested` | +| `re-review` | the worker's last word was `re-review-requested` | +| `waiting-merge` | a `review-complete-*` is the latest | +| `merged` | the PR is merged, or the PM sent `approved-and-merged`. The worktree has not been cleaned up yet | +| `closed` | the PR was closed unmerged | + +### `next` + +`items[]` are item objects plus `note` (string or null). + +- `source: "queue"`: the PM's queue, in its order. It is read from + `${TASK_FORCE_HOME}/monitor//queue`, one ref per line, with an optional + tab-separated note and `#` comments. The command that writes this file is + #272's sub-issue 3, which owns the format. +- `source: "tracker"`: the queue is empty, so the tracker's fallback is used. + - **gh**: open project items whose status is none of the three in the workflow + doc (starting work, in review, done), compared case-insensitively. They are + limited to this repo's issues, in the order `gh project item-list` returns, + top 10. + - **local**: the `tasks/` backlog by numeric id, skipping `in-progress` and + `done`. + - **jira / notion**: there is no fallback, so the source is `none`. +- `source: "none"`: nothing to show. + +Items already in flight are left out either way. + +### `done` + +Merged PRs that deliver an item, newest first. That means the last 24 hours or +the last 10, whichever is more. A PR delivers an item through `Closes #N` (or +`Fixes` / `Resolves`) on gh, and through its `task/` head branch on every +tracker. Other merged PRs are left out. + +The item fields, where `title` is the PR title and `description` its summary, +plus `summary`, `body` (the full PR body, for the expanded view), `pr` +(`{number, url, title, state, is_draft, head, merged_at, merge_sha}`), +`merged_at` and `merge_sha`. + +Merged PRs never change, so each is fetched once and cached for good, under +`${TASK_FORCE_HOME}/monitor//prs/.json`. + +### `attention` + +Exactly three signals. Nothing else lights a repo. + +| `kind` | fires when | extra fields | +|---|---|---| +| `unread-inbox` | the PM's inbox is not empty. `--also` alias inboxes count toward the primary | `count` | +| `review-waiting-merge` | the latest decision about a PR is a `review-complete-clean` or `review-complete-with-findings`, with no later `changes-requested` or `approved-and-merged`, and the forge lists the PR as open | `prs` | +| `worker-awaiting` | a worker session of the repo is in `awaiting`, the state behind the tab's `❓` | `workers: [{role, slug, since}]` | + +Each signal has a `text` in words, such as `✉ 2 unread`, `1 review waiting +merge` or `❓ issue-5 is asking`. `active` is true when there is any signal. + +A PR counts as open only when the forge says so. A repo with no full run yet +therefore shows no `review-waiting-merge`, rather than lighting up for every +review its mailboxes remember. The review timeline is rebuilt from message +frontmatter (`intent:`, `pr:`) in every mailbox, `processed/` and `dead-letter/` +included. The radio log cannot be used for this, because its `send` lines carry +no `pr=`. + +`worker-awaiting` is claude-only for now. Only the claude loadouts wire +`radio awaiting`, and #272's sub-issue 6 looks into a kiro equivalent. + +A role belongs to the longest repo name it starts with, so +`worker-task-force-x` is `task-force`'s even when a repo named `task` exists. + +## The cache + +Everything the forge said is kept in `${TASK_FORCE_HOME}/monitor//`: + +- `forge.json`: the open PR list, the merged-PR index, item descriptions by + ref, and the Todo fallback. A full run replaces only the parts the forge + answered. +- `prs/.json`: one per merged PR. + +`--local` reads these files and writes nothing. It never calls `gh`: the +collector shadows `gh` with a function that fails, so every forge read takes its +cache path. + +## Tracker hooks + +Per-tracker behaviour is three hooks in `lib/trackers/`, with `_default.sh` +bodies. `tests/loadout_modules.bats` checks that every hook resolves for every +loadout. + +| hook | default | overridden by | +|---|---|---| +| `aw_tracker_item_describe ` | `ref · slug` | gh (`gh issue view`), local (the task file), jira (a browse URL shortened to its key) | +| `aw_tracker_pr_item ` | the `task/` head branch | gh (`Closes #N` first), jira (the key in upper case) | +| `aw_tracker_next_fallback ` | nothing | gh (project items), local (`tasks/`) | + +A hook that reaches the forge returns non-zero when the forge does not answer. +That is how the collector tells "nothing to show" from "could not ask". On the +second, it uses the cache. diff --git a/kiro-gh/install.sh b/kiro-gh/install.sh index 812f364..f052e46 100755 --- a/kiro-gh/install.sh +++ b/kiro-gh/install.sh @@ -11,8 +11,9 @@ echo "Installing kiro-gh scripts..." # task-done and task-work resolve the same way but then compose modules out of # lib/trackers/ (and, for task-work, lib/agents/) instead of exec'ing a # per-loadout copy (#236, #237); -# task-board, task-pm, radio, task-config, task-remove and -# task-recreate-worker are canonical single copies (#170, #219, #220, #230, #238 — +# task-board, task-pm, radio, task-config, task-remove, +# task-recreate-worker and task-monitor-snapshot are canonical single copies +# (#170, #219, #220, #230, #238, #273 — # task-config and task-remove act *across* loadouts, so a per-loadout copy would # make no sense; task-pm and task-recreate-worker differ by agent only in their # final launch line). @@ -42,6 +43,8 @@ ln -sf "$SCRIPT_DIR/../bin/task-remove" ~/.local/bin/task-remove echo " ✓ Script: task-remove (canonical)"; sleep 0.05 ln -sf "$SCRIPT_DIR/../bin/task-recreate-worker" ~/.local/bin/task-recreate-worker echo " ✓ Script: task-recreate-worker (canonical)"; sleep 0.05 +ln -sf "$SCRIPT_DIR/../bin/task-monitor-snapshot" ~/.local/bin/task-monitor-snapshot +echo " ✓ Script: task-monitor-snapshot (canonical)"; sleep 0.05 # endregion:install-shared-symlinks ln -sf "$SCRIPT_DIR/../bin/task-reviewer" ~/.local/bin/task-reviewer echo " ✓ Script: task-reviewer (canonical + kiro routing)"; sleep 0.05 diff --git a/kiro-gh/steering/gh-workflow.example.md b/kiro-gh/steering/gh-workflow.example.md index 1f231e8..1be1108 100644 --- a/kiro-gh/steering/gh-workflow.example.md +++ b/kiro-gh/steering/gh-workflow.example.md @@ -259,6 +259,30 @@ gh run list -c "$(git rev-parse HEAD)" --limit 1 --json databaseId --jq 'length' # 0 => no run for this commit; "green" is not a claim you can make ``` +`task-monitor-snapshot` — the board as JSON: in flight, next, done, and whether this repo needs you (#273) + +It prints one JSON document (`"schema": 1`, documented in task-force's +`docs/task-monitor-schema.md`) and is the data layer of task-monitor. Every +value comes from disk, the tracker or the forge, and no model is asked to write +or summarise any of it. + +- **In flight** is every worktree whose `..info` sidecar exists, joined to + its radio session, its PR and the latest review intent. +- **Next** is the PM's queue, else the tracker's backlog (gh project items in + none of the three statuses, or the local `tasks/` files). There is none on + jira or notion. +- **Done** is the merged PRs that delivered an item in the last 24 hours, or + the last 10, whichever is more. +- **Attention** fires on exactly three signals: an unread PM inbox, a finished + review on a still-open PR that the PM has not answered, and a worker in + `awaiting`. The `awaiting` signal is claude-only for now. + +```bash +task-monitor-snapshot --json # this repo +task-monitor-snapshot --all # every repo radio knows about +task-monitor-snapshot --local # no forge calls: disk + the last full run's cache +``` + ### PM ↔ worker messaging (radio) Radio is the **canonical** coordination channel between the PM and workers — every diff --git a/kiro-local/install.sh b/kiro-local/install.sh index 6fd2d1f..7cc0dff 100755 --- a/kiro-local/install.sh +++ b/kiro-local/install.sh @@ -11,8 +11,9 @@ echo "Installing kiro-local scripts..." # task-done and task-work resolve the same way but then compose modules out of # lib/trackers/ (and, for task-work, lib/agents/) instead of exec'ing a # per-loadout copy (#236, #237); -# task-board, task-pm, radio, task-config, task-remove and -# task-recreate-worker are canonical single copies (#170, #219, #220, #230, #238 — +# task-board, task-pm, radio, task-config, task-remove, +# task-recreate-worker and task-monitor-snapshot are canonical single copies +# (#170, #219, #220, #230, #238, #273 — # task-config and task-remove act *across* loadouts, so a per-loadout copy would # make no sense; task-pm and task-recreate-worker differ by agent only in their # final launch line). @@ -42,6 +43,8 @@ ln -sf "$SCRIPT_DIR/../bin/task-remove" ~/.local/bin/task-remove echo " ✓ Script: task-remove (canonical)"; sleep 0.05 ln -sf "$SCRIPT_DIR/../bin/task-recreate-worker" ~/.local/bin/task-recreate-worker echo " ✓ Script: task-recreate-worker (canonical)"; sleep 0.05 +ln -sf "$SCRIPT_DIR/../bin/task-monitor-snapshot" ~/.local/bin/task-monitor-snapshot +echo " ✓ Script: task-monitor-snapshot (canonical)"; sleep 0.05 # endregion:install-shared-symlinks # Ensure ~/.local/bin is on PATH diff --git a/kiro-local/steering/local-workflow.example.md b/kiro-local/steering/local-workflow.example.md index 71c5c1e..be98df5 100644 --- a/kiro-local/steering/local-workflow.example.md +++ b/kiro-local/steering/local-workflow.example.md @@ -281,6 +281,30 @@ gh run list -c "$(git rev-parse HEAD)" --limit 1 --json databaseId --jq 'length' # 0 => no run for this commit; "green" is not a claim you can make ``` +`task-monitor-snapshot` — the board as JSON: in flight, next, done, and whether this repo needs you (#273) + +It prints one JSON document (`"schema": 1`, documented in task-force's +`docs/task-monitor-schema.md`) and is the data layer of task-monitor. Every +value comes from disk, the tracker or the forge, and no model is asked to write +or summarise any of it. + +- **In flight** is every worktree whose `..info` sidecar exists, joined to + its radio session, its PR and the latest review intent. +- **Next** is the PM's queue, else the tracker's backlog (gh project items in + none of the three statuses, or the local `tasks/` files). There is none on + jira or notion. +- **Done** is the merged PRs that delivered an item in the last 24 hours, or + the last 10, whichever is more. +- **Attention** fires on exactly three signals: an unread PM inbox, a finished + review on a still-open PR that the PM has not answered, and a worker in + `awaiting`. The `awaiting` signal is claude-only for now. + +```bash +task-monitor-snapshot --json # this repo +task-monitor-snapshot --all # every repo radio knows about +task-monitor-snapshot --local # no forge calls: disk + the last full run's cache +``` + ### PM ↔ worker messaging (radio) Radio is the **canonical** coordination channel between the PM and workers — every diff --git a/kiro-notion/install.sh b/kiro-notion/install.sh index 769fe17..bcfa222 100755 --- a/kiro-notion/install.sh +++ b/kiro-notion/install.sh @@ -11,8 +11,9 @@ echo "Installing kiro-notion scripts..." # task-done and task-work resolve the same way but then compose modules out of # lib/trackers/ (and, for task-work, lib/agents/) instead of exec'ing a # per-loadout copy (#236, #237); -# task-board, task-pm, radio, task-config, task-remove and -# task-recreate-worker are canonical single copies (#170, #219, #220, #230, #238 — +# task-board, task-pm, radio, task-config, task-remove, +# task-recreate-worker and task-monitor-snapshot are canonical single copies +# (#170, #219, #220, #230, #238, #273 — # task-config and task-remove act *across* loadouts, so a per-loadout copy would # make no sense; task-pm and task-recreate-worker differ by agent only in their # final launch line). @@ -42,6 +43,8 @@ ln -sf "$SCRIPT_DIR/../bin/task-remove" ~/.local/bin/task-remove echo " ✓ Script: task-remove (canonical)"; sleep 0.05 ln -sf "$SCRIPT_DIR/../bin/task-recreate-worker" ~/.local/bin/task-recreate-worker echo " ✓ Script: task-recreate-worker (canonical)"; sleep 0.05 +ln -sf "$SCRIPT_DIR/../bin/task-monitor-snapshot" ~/.local/bin/task-monitor-snapshot +echo " ✓ Script: task-monitor-snapshot (canonical)"; sleep 0.05 # endregion:install-shared-symlinks # Ensure ~/.local/bin is on PATH diff --git a/kiro-notion/steering/notion-workflow.example.md b/kiro-notion/steering/notion-workflow.example.md index 255aaf6..4f70a72 100644 --- a/kiro-notion/steering/notion-workflow.example.md +++ b/kiro-notion/steering/notion-workflow.example.md @@ -256,6 +256,30 @@ gh run list -c "$(git rev-parse HEAD)" --limit 1 --json databaseId --jq 'length' # 0 => no run for this commit; "green" is not a claim you can make ``` +`task-monitor-snapshot` — the board as JSON: in flight, next, done, and whether this repo needs you (#273) + +It prints one JSON document (`"schema": 1`, documented in task-force's +`docs/task-monitor-schema.md`) and is the data layer of task-monitor. Every +value comes from disk, the tracker or the forge, and no model is asked to write +or summarise any of it. + +- **In flight** is every worktree whose `..info` sidecar exists, joined to + its radio session, its PR and the latest review intent. +- **Next** is the PM's queue, else the tracker's backlog (gh project items in + none of the three statuses, or the local `tasks/` files). There is none on + jira or notion. +- **Done** is the merged PRs that delivered an item in the last 24 hours, or + the last 10, whichever is more. +- **Attention** fires on exactly three signals: an unread PM inbox, a finished + review on a still-open PR that the PM has not answered, and a worker in + `awaiting`. The `awaiting` signal is claude-only for now. + +```bash +task-monitor-snapshot --json # this repo +task-monitor-snapshot --all # every repo radio knows about +task-monitor-snapshot --local # no forge calls: disk + the last full run's cache +``` + ### PM ↔ worker messaging (radio) Radio is the **canonical** coordination channel between the PM and workers — every diff --git a/lib/monitor-assemble.jq b/lib/monitor-assemble.jq new file mode 100644 index 0000000..e28517e --- /dev/null +++ b/lib/monitor-assemble.jq @@ -0,0 +1,149 @@ +# Assembles one repo object of the task-monitor snapshot (#273). Run by +# bin/task-monitor-snapshot with `jq -n -f`; see that file for the inputs, and +# docs/task-monitor-schema.md for the output. +# +# Everything here is a join over data bin/task-monitor-snapshot already read: +# no I/O, no forge, so --local and full runs assemble identically. + +def nonempty: if . == null or . == "" then null else . end; + +# Whether a radio role belongs to repo . Role names are task-force's own: +# pm-, worker--, reviewer--pr. +def role_of($r): + . == ("pm-" + $r) + or startswith("worker-" + $r + "-") + or startswith("reviewer-" + $r + "-"); + +# The intents that move a PR through review, and the subset that settles a +# review: a review-complete-* is waiting on the PM until one of the PM's own +# answers follows it. +def review_intents: + ["review-requested", "re-review-requested", "changes-requested", + "review-complete-clean", "review-complete-with-findings", "approved-and-merged"]; +def decision_intents: + ["review-complete-clean", "review-complete-with-findings", "changes-requested", "approved-and-merged"]; +def among($set): . as $x | $set | index([$x]) != null; + +def session_obj($S; $role): + $S[$role] as $s + | if $s == null then null else + {role: $role, state: ($s.STATE | nonempty), since: ($s.LAST_HEARTBEAT | nonempty), + tab: ($s.TAB | nonempty), tab_id: ($s.TAB_ID | nonempty), + zellij_session: ($s.ZELLIJ_SESSION | nonempty), agent: ($s.AGENT | nonempty)} + end; + +def status($pr; $last_review; $has_reviewer): + if $pr == null then "implementing" + elif $pr.state == "MERGED" then "merged" + elif $pr.state == "CLOSED" then "closed" + else ($last_review.intent // null) as $i + | if $i == null or $i == "review-requested" then + (if $has_reviewer then "under-review" else "pr-open" end) + elif $i == "re-review-requested" then "re-review" + elif $i == "changes-requested" then "changes-requested" + elif ($i | startswith("review-complete-")) then "waiting-merge" + elif $i == "approved-and-merged" then "merged" + else "pr-open" end + end; + +($sessions[0] // {}) as $S +| ("pm-" + $name) as $pm_role +| [$names[] | select(startswith($name + "-"))] as $longer +# The longest repo name wins: `worker-task-force-x` is task-force's, not task's. +| def mine: role_of($name) and ([. as $role | $longer[] as $l | $role | role_of($l)] | any | not); + +[$messages[] | select(((.from // "") | mine) or ((.to // "") | mine))] + | sort_by([.created_at, .id]) as $msgs + +# Is PR (a string) still open? Only the forge can say. With no answer yet — +# a repo whose first full run has not happened — nothing counts as open: every +# review a mailbox still remembers would otherwise light the repo up at once, +# and a light that fires for anything but a real signal is #272's anti-goal. +| def pr_open($n): + $open_prs != null and ([$open_prs[] | .number | tostring] | index([$n]) != null); + +# Reviewers, from their sidecars and their sessions alike: a reviewer whose +# session is gone but whose worktree remains is still attached, and vice versa. +( [ ($reviewer_infos[] | .pr), + ($S | keys[] | select(startswith("reviewer-" + $name + "-pr")) | select(mine) + | ltrimstr("reviewer-" + $name + "-pr")) ] + | unique + | map(. as $n | ("reviewer-" + $name + "-pr" + $n) as $role + | {pr: $n, role: $role, session: session_obj($S; $role)}) ) as $reviewers + +| [ $inflight[] + | ("worker-" + $name + "-" + .slug) as $wrole + # No PR found by head branch: fall back to the PR the worker named in its + # own review-requested, so a renamed branch still shows its review state. + | (.pr // ([$msgs[] | select(.from == $wrole and .pr != null)] | last + | if . == null then null else + {number: (.pr | tonumber? // .pr), url: null, title: null, state: null, + is_draft: false, head: null, summary: null, merged_at: null, merge_sha: null} + end)) as $pr + | ($pr.number | if . == null then null else tostring end) as $prn + | [$reviewers[] | select(.pr == $prn)] as $revs + | ([$msgs[] | select($prn != null and .pr == $prn) | select(.intent | among(review_intents))] | last) as $last_review + | ([$msgs[] | select(.from == $wrole or .to == $wrole or ($prn != null and .pr == $prn))] | last) as $last + | { slug, ref, branch, base, worktree, + label: .item.label, number: .item.number, url: .item.url, + title: (($pr.title // null) // .item.title), + description: (($pr.summary // null) // .item.description), + item: .item, + pr: $pr, + status: status($pr; $last_review; ($revs | length > 0)), + last_intent: (if $last == null then null else + {intent: $last.intent, from: $last.from, to: $last.to, pr: $last.pr, at: $last.created_at} end), + session: session_obj($S; $wrole), + tab_id: .tab_id, + reviewers: $revs } ] as $in_flight + +| ([$in_flight[] | .ref, .item.ref | select(. != null)]) as $if_refs +| ([$in_flight[] | .number | select(. != null)]) as $if_numbers +| ([$in_flight[] | .url | select(. != null)]) as $if_urls +| [ $next[] + | select((.ref | among($if_refs)) | not) + | select(.number == null or ((.number | among($if_numbers)) | not)) + | select(.url == null or ((.url | among($if_urls)) | not)) ] + | (if $next_source == "queue" then . else .[:$next_limit] end) as $next_items + +# --- attention: exactly three signals (#272). Nothing else lights a repo. +| ($S[$pm_role].ALIAS | nonempty) as $alias_of +| ([$S | to_entries[] | select(.value.ALIAS == $pm_role) | .key]) as $aliases +| (if $alias_of != null then 0 else + [$messages[] | select(.box == "inbox") | select(.mailbox == $pm_role or (.mailbox | among($aliases)))] | length + end) as $unread +| ([$msgs[] | select(.pr != null) | select(.intent | among(decision_intents))] + | group_by(.pr) + | map(sort_by([.created_at, .id]) | last) + | map(select(.intent | startswith("review-complete-")) | .pr) + | map(select(pr_open(.)))) as $waiting_prs +| ([$S | to_entries[] + | select(.key | startswith("worker-")) | select(.key | mine) + | select(.value.STATE == "awaiting") + | {role: .key, slug: (.key | ltrimstr("worker-" + $name + "-")), since: (.value.LAST_HEARTBEAT | nonempty)}]) as $awaiting +| ( (if $unread > 0 then [{kind: "unread-inbox", count: $unread, text: "✉ \($unread) unread"}] else [] end) + + (if ($waiting_prs | length) > 0 then + [{kind: "review-waiting-merge", prs: ($waiting_prs | map(tonumber? // .)), + text: "\($waiting_prs | length) review\(if ($waiting_prs | length) > 1 then "s" else "" end) waiting merge"}] + else [] end) + + (if ($awaiting | length) > 0 then + [{kind: "worker-awaiting", workers: $awaiting, + text: "❓ \($awaiting | map(.slug) | join(", ")) \(if ($awaiting | length) > 1 then "are" else "is" end) asking"}] + else [] end) ) as $signals + +| { + name: $name, + root: $root, + loadout: ($impl | nonempty), + tracker: ($tracker | nonempty), + agent: ($agent | nonempty), + pm: ((session_obj($S; $pm_role) // {role: $pm_role, state: null, since: null, tab: null, + tab_id: null, zellij_session: null, agent: null}) + + {alias_of: $alias_of, unread: $unread}), + forge: {fetched_at: ($fetched_at | nonempty), + stale: ($forge_ok != 1)}, + in_flight: $in_flight, + next: {source: (if ($next_items | length) == 0 then "none" else $next_source end), items: $next_items}, + done: {window_hours: 24, min_items: 10, items: $done_items}, + attention: {active: ($signals | length > 0), signals: $signals} + } diff --git a/lib/monitor-text.sh b/lib/monitor-text.sh new file mode 100644 index 0000000..4774555 --- /dev/null +++ b/lib/monitor-text.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +# Plain-text extraction for the task-monitor snapshot (#273). +# +# This file is meant to be sourced, not executed. +# +# Every function here reads markdown on stdin and prints one line: the +# paragraph's lines joined by single spaces, trimmed. Line-oriented awk on +# purpose. The bodies are issue and PR bodies, which run to kilobytes of UTF-8, +# and a bash `${var//pat/rep}` over a string that size is what hung task-remove +# under /bin/bash 3.2 (#266). +# +# Exports: +# tm_first_paragraph -> the first prose paragraph of a markdown body +# tm_pr_summary -> a PR body's `## Summary`, else the first paragraph +# after its `Closes #N` line, else its first paragraph +# tm_strip_frontmatter -> the body with a leading `---` … `---` block removed + +# What counts as prose: not a heading, not an HTML comment, not a fence, not a +# horizontal rule. A paragraph ends at a blank line or at the next non-prose line. +_TM_AWK_COMMON=' + function trim(s) { sub(/^[ \t\r]+/, "", s); sub(/[ \t\r]+$/, "", s); return s } + function skip(l) { + return (l ~ /^#/ || l ~ /^' '# H' '' 'one' 'two' '- item' | tm_first_paragraph" + assert_output "one two" + run bash -c "source '$REPO_ROOT_REAL/lib/monitor-text.sh'; printf '%s\n' '## H' '- first item' 'wraps' '- second' | tm_first_paragraph" + assert_output "first item wraps" +} + +@test "tm_pr_summary prefers ## Summary, then after Closes, then the first paragraph" { + local lib="$REPO_ROOT_REAL/lib/monitor-text.sh" + run bash -c "source '$lib'; printf '%s\n' 'Closes #1' '' 'after' '' '## Summary' '' 'sum' | tm_pr_summary" + assert_output "sum" + run bash -c "source '$lib'; printf '%s\n' 'intro' '' 'Fixes #1' '' 'after' | tm_pr_summary" + assert_output "after" + run bash -c "source '$lib'; printf '%s\n' 'just this' | tm_pr_summary" + assert_output "just this" +} From bc1b7f39bf9519e37c9bca04203b80e35bc06669 Mon Sep 17 00:00:00 2001 From: Martin Contreras Date: Tue, 20 Oct 2026 13:06:42 -0300 Subject: [PATCH 2/3] task-monitor 1/6: snapshot --json collector (bash) and schema: address review round 1 - worker-awaiting needs a heartbeat younger than radio orphans' 1h, so a crashed tab cannot keep a repo lit. - The unread count drops the alias-inbox branch: an alias owns no inbox, radio delivers its mail to the primary. Documented in the schema doc. - session.since is renamed last_heartbeat, which is what it is. - Same-second messages are ordered by the radio log's send lines, since a radio id ends in a random suffix and cannot break the tie. - forge.available: false for a repo whose forge has never answered (no GitHub remote), instead of a permanent stale. - The gh Next fallback is one paginated GraphQL query: open issues only, no archived items, no issues with sub-issues, ordered by the project's Priority field when it has one. The comment says API order is not board order. - A missing radio log no longer aborts the run under pipefail. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 8 +- bin/task-monitor-snapshot | 15 ++- docs/task-monitor-schema.example.json | 7 +- docs/task-monitor-schema.md | 38 ++++--- lib/monitor-assemble.jq | 42 ++++++-- lib/trackers/gh.sh | 80 +++++++++++++-- tests/task_monitor_snapshot.bats | 138 +++++++++++++++++++++----- 7 files changed, 260 insertions(+), 68 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 910b540..bfff873 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -197,15 +197,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - **`task-monitor-snapshot`: the board as one JSON document — in flight, next, done, and whether a repo needs you (#273, epic #272 1/6).** A new canonical root binary, the data layer the task-monitor front end will render. It prints `"schema": 1` for the current repo, or with `--all` for every repo a radio session file names, and every value in it is read deterministically from radio's state, task-work's `..info` sidecars, the tracker and the forge. No model is asked to write or summarise anything. The schema is documented in `docs/task-monitor-schema.md`, with an example taken from this repo. - **In flight** is every worktree sidecar, joined to its radio session (`STATE=`, heartbeat), its PR (found by the `task/` head branch on every tracker) and a status word derived from the latest review intent about that PR. Reviewers attach to their PR's item rather than being items. - - **Titles and descriptions** come from three new tracker hooks with `_default.sh` bodies: `aw_tracker_item_describe` (gh: the issue's title and first paragraph; local: the task file; jira / notion: `ref · slug`, since the shell cannot read them), `aw_tracker_pr_item` (gh: `Closes #N`, else the head branch) and `aw_tracker_next_fallback` (gh: project items in none of the three configured statuses; local: the `tasks/` backlog; none on jira / notion). Once a PR exists, its title and `## Summary` take over, falling back to the first paragraph after `Closes`. `tests/loadout_modules.bats` checks that all three resolve on every loadout. + - **Titles and descriptions** come from three new tracker hooks with `_default.sh` bodies: `aw_tracker_item_describe` (gh: the issue's title and first paragraph; local: the task file; jira / notion: `ref · slug`, since the shell cannot read them), `aw_tracker_pr_item` (gh: `Closes #N`, else the head branch) and `aw_tracker_next_fallback` (gh: open issues on the configured project in none of the three configured statuses, leaving out drafts, archived items and issues with sub-issues, ordered by the project's `Priority` field when there is one; local: the `tasks/` backlog; none on jira / notion). The gh fallback is one paginated GraphQL query, because `gh project item-list` carries neither sub-issue counts nor issue state. Once a PR exists, its title and `## Summary` take over, falling back to the first paragraph after `Closes`. `tests/loadout_modules.bats` checks that all three resolve on every loadout. - **Next** reads the PM queue file. Its writer is sub-issue 3, so the read uses the format that sub-issue is expected to keep. - **Done** is the merged PRs that deliver an item over the last 24 hours, or the last 10, whichever is more. Each merged PR is fetched once and cached under `$TASK_FORCE_HOME/monitor//prs/`. - - **Attention** has exactly three signals: an unread PM inbox, where `--also` aliases count toward the primary; a `review-complete-*` on a PR the forge lists as open, with no later `changes-requested` / `approved-and-merged`, rebuilt from message frontmatter in every mailbox; and a worker in `awaiting`, which is claude-only for now. - - **`--local`** makes no forge call at all. It shadows `gh` with a failing function and merges with the last full run's cache, so a filesystem-triggered refresh never waits on GitHub. A full run whose forge call fails keeps the previous result and marks it `stale`. + - **Attention** has exactly three signals: an unread PM inbox, where `--also` aliases count toward the primary; a `review-complete-*` on a PR the forge lists as open, with no later `changes-requested` / `approved-and-merged`, rebuilt from message frontmatter in every mailbox; and a worker in `awaiting` whose heartbeat is younger than `radio orphans`' one hour, so a crashed tab cannot keep a repo lit. The third is claude-only for now. Messages from the same second are ordered by the radio log's `send` lines: a radio id cannot break that tie, because after the timestamp it holds the sender and a random suffix. + - **`--local`** makes no forge call at all. It shadows `gh` with a failing function and merges with the last full run's cache, so a filesystem-triggered refresh never waits on GitHub. A full run whose forge call fails keeps the previous result and marks it `stale`. A repo whose forge has never answered, such as one with no GitHub remote, reports `forge.available: false` instead of a permanent `stale`. It runs on `/bin/bash` 3.2, and its bodies go through awk and jq, never a large-string pattern substitution (#266). Repos in `--all` run in parallel. Measured on this machine's four repos: a full `--all` takes about 10s, mostly `gh`, and a single-repo `--local` 0.65s. Two small extractions came with it. `aw_task_fm`, the task-file frontmatter reader, moved from `bin/task-board` to `lib/task-file.sh` so the local tracker module can share it. `aw_main_repo_root` now carries the git-common-dir logic in `lib/repo-name.sh`, and `aw_main_repo_name` calls it. - New `tests/task_monitor_snapshot.bats` (39 tests, all run under `/bin/bash`) covers each source, each tracker's titles, each status, each signal and its negatives, `--local`, the forge-failure path, `--all` discovery and the text extraction. Each assertion was mutation-checked. **Upgrading: no `task-init` re-run needed for the command itself.** Re-run the loadout's `install.sh` to get the `~/.local/bin/task-monitor-snapshot` link, which the shared install stanza now creates on all seven loadouts. Re-run `task-init ` to refresh the workflow doc, which now describes the command. + New `tests/task_monitor_snapshot.bats` (45 tests, all run under `/bin/bash`) covers each source, each tracker's titles, each status, each signal and its negatives, `--local`, the forge-failure path, `--all` discovery and the text extraction. Each assertion was mutation-checked. **Upgrading: no `task-init` re-run needed for the command itself.** Re-run the loadout's `install.sh` to get the `~/.local/bin/task-monitor-snapshot` link, which the shared install stanza now creates on all seven loadouts. Re-run `task-init ` to refresh the workflow doc, which now describes the command. - **Radio auto-submit is its own flag on claude, no longer a side effect of `--permission-mode auto` (#246).** `task-work --auto` set both, so a worker could not get its mail submitted without also auto-accepting edits. The reverse was worse: a worker kept off `--auto` because its task is destructive (a release cut that tags and publishes) lost auto-submit as a side effect, so the worker you were supervising most carefully was the one whose handoffs stalled. The coupling stays out of sight while workers are busy, because a busy recipient's mail is drained by the Stop hook with no keystroke. It shows up the moment they go idle waiting on a review, which is exactly the handoff that matters. diff --git a/bin/task-monitor-snapshot b/bin/task-monitor-snapshot index c65a31c..cba3b50 100755 --- a/bin/task-monitor-snapshot +++ b/bin/task-monitor-snapshot @@ -150,6 +150,15 @@ _messages_ndjson() { [[ -d "$d" ]] && dirs+=("$d") done [[ ${#dirs[@]} -gt 0 ]] || return 0 + # Send order, from the radio log: {id: line number}. It breaks created_at + # ties, which ids cannot (see msg_order in lib/monitor-assemble.jq). + # No log yet is no order yet, not an error: awk exits 2 on a missing file, + # which pipefail would make fatal. + { [[ -f "$TF_HOME/radio/log" ]] && awk ' + match($0, / send id=[^ ]+/) { print substr($0, RSTART + 9, RLENGTH - 9) "\t" NR } + ' "$TF_HOME/radio/log" || true; } | jq -Rsc ' + split("\n") | map(select(. != "") | split("\t")) | map({key: .[0], value: (.[1] | tonumber)}) | from_entries + ' > "$WORK/logseq.json" find "${dirs[@]}" -type f -name '*.md' -exec awk ' function flush() { if (fname != "" && !printed) { @@ -168,9 +177,9 @@ _messages_ndjson() { index($0, "pr: ") == 1 { pr = substr($0, 5) } index($0, "created_at: ") == 1 { created = substr($0, 13) } END { flush() } - ' {} + | jq -Rc --arg dl "$DEAD_LETTER_DIR/" ' + ' {} + | jq -Rc --arg dl "$DEAD_LETTER_DIR/" --slurpfile seq "$WORK/logseq.json" ' split("\t") as $f | ($f[0] | split("/")) as $p - | {id: $f[1], from: $f[2], to: $f[3], intent: $f[4], + | {id: $f[1], from: $f[2], to: $f[3], intent: $f[4], seq: $seq[0][$f[1]], pr: (if $f[5] == "" then null else $f[5] end), created_at: $f[6], box: (if ($f[0] | startswith($dl)) then "dead-letter" else $p[-2] end), mailbox: (if ($f[0] | startswith($dl)) then $p[-2] else $p[-3] end)}' @@ -443,7 +452,7 @@ _snapshot_repo() { # --- assemble. jq -n \ --arg name "$name" --arg root "$root" --arg impl "$impl" --arg tracker "$tracker" \ - --arg agent "$agent" --arg fetched_at "$fetched_at" \ + --arg agent "$agent" --arg now "$NOW" --arg fetched_at "$fetched_at" \ --arg next_source "$next_source" --argjson next_limit "$NEXT_LIMIT" \ --argjson forge_ok "$forge_ok" --argjson names "$names_json" \ --argjson open_prs "$open_prs" --argjson merged "$merged_index" \ diff --git a/docs/task-monitor-schema.example.json b/docs/task-monitor-schema.example.json index 54e4bd6..17ebe86 100644 --- a/docs/task-monitor-schema.example.json +++ b/docs/task-monitor-schema.example.json @@ -12,7 +12,7 @@ "pm": { "role": "pm-task-force", "state": "idle", - "since": "2026-10-18T20:49:28Z", + "last_heartbeat": "2026-10-18T20:49:28Z", "tab": "⏸️ pm-task-force", "tab_id": "0", "zellij_session": "task-force", @@ -21,6 +21,7 @@ "unread": 1 }, "forge": { + "available": true, "fetched_at": "2026-10-20T14:33:14Z", "stale": false }, @@ -50,7 +51,7 @@ "session": { "role": "worker-task-force-monitor-snapshot", "state": "busy", - "since": "2026-10-20T14:11:57Z", + "last_heartbeat": "2026-10-20T14:11:57Z", "tab": "▶️ monitor-snapshot", "tab_id": "1", "zellij_session": "task-force", @@ -132,7 +133,7 @@ { "role": "worker-task-force-pr-summary-section", "slug": "pr-summary-section", - "since": "2026-10-20T14:02:11Z" + "last_heartbeat": "2026-10-20T14:02:11Z" } ], "text": "❓ pr-summary-section is asking" diff --git a/docs/task-monitor-schema.md b/docs/task-monitor-schema.md index 49a94fd..2617d53 100644 --- a/docs/task-monitor-schema.md +++ b/docs/task-monitor-schema.md @@ -49,7 +49,7 @@ ignore fields it does not know. | `loadout` | string \| null | `claude-gh` …, from the repo's one workflow doc, else from its PM session's `LOADOUT=` | | `tracker`, `agent` | string \| null | the loadout's two halves | | `pm` | object | the PM session (see below) | -| `forge` | object | `fetched_at`: the last time the forge answered, or null. `stale`: true when this run did not hear from the forge (always under `--local`) and shows the last result instead | +| `forge` | object | `available`: the forge has answered for this repo at least once. A repo with no GitHub remote never has, so it reports `false` rather than a permanent `stale`; its PR-derived fields are simply empty. `fetched_at`: the last time the forge answered, or null. `stale`: the forge is available, but this run did not hear from it (always so under `--local`), so PR state shows the last result | | `in_flight` | array | one entry per worktree sidecar | | `next` | object | `source` (`queue` \| `tracker` \| `none`) and `items` | | `done` | object | `window_hours` (24), `min_items` (10) and `items` | @@ -57,15 +57,18 @@ ignore fields it does not know. ### `pm` -`role`, `state` (`idle` \| `busy` \| `awaiting` \| null), `since` (the session's -`LAST_HEARTBEAT`, which every state change rewrites), `tab`, `tab_id`, +`role`, `state` (`idle` \| `busy` \| `awaiting` \| null), `last_heartbeat` (the +session's `LAST_HEARTBEAT`: radio rewrites it on every state change and every +register, so it is when the session was last heard from, not when it entered +its current state), `tab`, `tab_id`, `zellij_session`, `agent`: all from `radio/sessions/pm-.info`, null when there is no session. Then: - `alias_of`: the primary PM's role when this repo's PM is a `task-pm --also` alias, else null. -- `unread`: messages in the PM's `inbox/`. An alias's inbox counts toward its - primary, so an aliased repo reports `0`. +- `unread`: messages in the PM's `inbox/`. An alias owns no inbox: radio + delivers mail sent to it into the primary's inbox. So an aliased repo reports + `0` with `alias_of` set, and its unread mail is counted on the primary repo. ### Item fields @@ -108,7 +111,7 @@ The item fields above, plus: | `pr` | `{number, url, title, state, is_draft, head, summary, merged_at, merge_sha}`, found by the `task/` head branch on every tracker, else null. If no PR has that head, the PR the worker named in its own radio messages is used | | `status` | see below | | `last_intent` | the latest radio message to or from this worker, or about its PR: `{intent, from, to, pr, at}` | -| `session` | the worker's radio session: `{role, state, since, tab, tab_id, zellij_session, agent}`, or null | +| `session` | the worker's radio session: `{role, state, last_heartbeat, tab, tab_id, zellij_session, agent}`, or null | | `reviewers` | `[{pr, role, session}]` | `status` comes from the PR's state plus the latest review intent exchanged about @@ -134,10 +137,14 @@ that PR: tab-separated note and `#` comments. The command that writes this file is #272's sub-issue 3, which owns the format. - `source: "tracker"`: the queue is empty, so the tracker's fallback is used. - - **gh**: open project items whose status is none of the three in the workflow - doc (starting work, in review, done), compared case-insensitively. They are - limited to this repo's issues, in the order `gh project item-list` returns, - top 10. + - **gh**: open issues on the configured project whose status is none of the + three in the workflow doc (starting work, in review, done), compared + case-insensitively. Limited to this repo's issues, top 10. Archived items, + drafts, closed issues and issues that have sub-issues (an epic is tracked + through its children) are left out. They are ordered by the project's + `Priority` field when it has one, in its option order, and otherwise in API + order. API order is roughly the order items were added, not the board's + order, which the API does not expose. - **local**: the `tasks/` backlog by numeric id, skipping `in-progress` and `done`. - **jira / notion**: there is no fallback, so the source is `none`. @@ -168,7 +175,7 @@ Exactly three signals. Nothing else lights a repo. |---|---|---| | `unread-inbox` | the PM's inbox is not empty. `--also` alias inboxes count toward the primary | `count` | | `review-waiting-merge` | the latest decision about a PR is a `review-complete-clean` or `review-complete-with-findings`, with no later `changes-requested` or `approved-and-merged`, and the forge lists the PR as open | `prs` | -| `worker-awaiting` | a worker session of the repo is in `awaiting`, the state behind the tab's `❓` | `workers: [{role, slug, since}]` | +| `worker-awaiting` | a worker session of the repo is in `awaiting`, the state behind the tab's `❓`, with a heartbeat younger than an hour (`radio orphans`' threshold: a crashed tab leaves `awaiting` behind forever) | `workers: [{role, slug, last_heartbeat}]` | Each signal has a `text` in words, such as `✉ 2 unread`, `1 review waiting merge` or `❓ issue-5 is asking`. `active` is true when there is any signal. @@ -177,8 +184,11 @@ A PR counts as open only when the forge says so. A repo with no full run yet therefore shows no `review-waiting-merge`, rather than lighting up for every review its mailboxes remember. The review timeline is rebuilt from message frontmatter (`intent:`, `pr:`) in every mailbox, `processed/` and `dead-letter/` -included. The radio log cannot be used for this, because its `send` lines carry -no `pr=`. +included. The radio log cannot be the source, because its `send` lines carry no +`pr=`. It does break ties, though. `created_at` has one-second resolution, and a +radio id cannot order two messages from the same second: after the timestamp it +holds the sender and a random suffix. So messages sort by `created_at`, then by +their position among the log's `send id=` lines, then by id. `worker-awaiting` is claude-only for now. Only the claude loadouts wire `radio awaiting`, and #272's sub-issue 6 looks into a kiro equivalent. @@ -209,7 +219,7 @@ loadout. |---|---|---| | `aw_tracker_item_describe ` | `ref · slug` | gh (`gh issue view`), local (the task file), jira (a browse URL shortened to its key) | | `aw_tracker_pr_item ` | the `task/` head branch | gh (`Closes #N` first), jira (the key in upper case) | -| `aw_tracker_next_fallback ` | nothing | gh (project items), local (`tasks/`) | +| `aw_tracker_next_fallback ` | nothing | gh (project items, one paginated GraphQL query), local (`tasks/`) | A hook that reaches the forge returns non-zero when the forge does not answer. That is how the collector tells "nothing to show" from "could not ask". On the diff --git a/lib/monitor-assemble.jq b/lib/monitor-assemble.jq index e28517e..99c1370 100644 --- a/lib/monitor-assemble.jq +++ b/lib/monitor-assemble.jq @@ -27,11 +27,24 @@ def among($set): . as $x | $set | index([$x]) != null; def session_obj($S; $role): $S[$role] as $s | if $s == null then null else - {role: $role, state: ($s.STATE | nonempty), since: ($s.LAST_HEARTBEAT | nonempty), + {role: $role, state: ($s.STATE | nonempty), last_heartbeat: ($s.LAST_HEARTBEAT | nonempty), tab: ($s.TAB | nonempty), tab_id: ($s.TAB_ID | nonempty), zellij_session: ($s.ZELLIJ_SESSION | nonempty), agent: ($s.AGENT | nonempty)} end; +# Message order. created_at has one-second resolution, and a radio id does NOT +# break the tie: after the timestamp it carries the sender and a random suffix, +# so two messages sent in the same second sort by chance. `seq` is the message's +# position among the radio log's `send id=` lines, which radio appends in send +# order; a message the log no longer holds (it keeps its last ~512KB) sorts +# after any that it does in that second, then by id. +def msg_order: [.created_at, (.seq // 1e18), .id]; + +# A heartbeat older than `radio orphans`' threshold (1h) means the tab is gone: +# a crashed tab leaves STATE=awaiting behind forever (bin/radio, cmd_send). +def fresh($now): (. // "") as $hb + | $hb != "" and (($now | fromdateiso8601) - ($hb | fromdateiso8601) < 3600); + def status($pr; $last_review; $has_reviewer): if $pr == null then "implementing" elif $pr.state == "MERGED" then "merged" @@ -53,7 +66,7 @@ def status($pr; $last_review; $has_reviewer): | def mine: role_of($name) and ([. as $role | $longer[] as $l | $role | role_of($l)] | any | not); [$messages[] | select(((.from // "") | mine) or ((.to // "") | mine))] - | sort_by([.created_at, .id]) as $msgs + | sort_by(msg_order) as $msgs # Is PR (a string) still open? Only the forge can say. With no answer yet — # a repo whose first full run has not happened — nothing counts as open: every @@ -108,19 +121,24 @@ def status($pr; $last_review; $has_reviewer): # --- attention: exactly three signals (#272). Nothing else lights a repo. | ($S[$pm_role].ALIAS | nonempty) as $alias_of -| ([$S | to_entries[] | select(.value.ALIAS == $pm_role) | .key]) as $aliases +# An alias owns no inbox: radio's alias hop delivers its mail into the +# primary's, and `register-alias` folds in anything that queued before. So the +# primary's own inbox already holds the alias's mail, and the alias repo +# reports 0 with alias_of set — that is the whole of "--also counts toward the +# primary". | (if $alias_of != null then 0 else - [$messages[] | select(.box == "inbox") | select(.mailbox == $pm_role or (.mailbox | among($aliases)))] | length + [$messages[] | select(.box == "inbox" and .mailbox == $pm_role)] | length end) as $unread | ([$msgs[] | select(.pr != null) | select(.intent | among(decision_intents))] | group_by(.pr) - | map(sort_by([.created_at, .id]) | last) + | map(sort_by(msg_order) | last) | map(select(.intent | startswith("review-complete-")) | .pr) | map(select(pr_open(.)))) as $waiting_prs | ([$S | to_entries[] | select(.key | startswith("worker-")) | select(.key | mine) - | select(.value.STATE == "awaiting") - | {role: .key, slug: (.key | ltrimstr("worker-" + $name + "-")), since: (.value.LAST_HEARTBEAT | nonempty)}]) as $awaiting + | select(.value.STATE == "awaiting" and (.value.LAST_HEARTBEAT | fresh($now))) + | {role: .key, slug: (.key | ltrimstr("worker-" + $name + "-")), + last_heartbeat: (.value.LAST_HEARTBEAT | nonempty)}]) as $awaiting | ( (if $unread > 0 then [{kind: "unread-inbox", count: $unread, text: "✉ \($unread) unread"}] else [] end) + (if ($waiting_prs | length) > 0 then [{kind: "review-waiting-merge", prs: ($waiting_prs | map(tonumber? // .)), @@ -137,11 +155,15 @@ def status($pr; $last_review; $has_reviewer): loadout: ($impl | nonempty), tracker: ($tracker | nonempty), agent: ($agent | nonempty), - pm: ((session_obj($S; $pm_role) // {role: $pm_role, state: null, since: null, tab: null, + pm: ((session_obj($S; $pm_role) // {role: $pm_role, state: null, last_heartbeat: null, tab: null, tab_id: null, zellij_session: null, agent: null}) + {alias_of: $alias_of, unread: $unread}), - forge: {fetched_at: ($fetched_at | nonempty), - stale: ($forge_ok != 1)}, + # `available`: the forge has answered for this repo at least once. A repo + # with no GitHub remote never has, and would otherwise read as permanently + # stale; `stale` is only meaningful for a repo whose forge does answer. + forge: {available: ($fetched_at | nonempty) != null, + fetched_at: ($fetched_at | nonempty), + stale: (($fetched_at | nonempty) != null and $forge_ok != 1)}, in_flight: $in_flight, next: {source: (if ($next_items | length) == 0 then "none" else $next_source end), items: $next_items}, done: {window_hours: 24, min_items: 10, items: $done_items}, diff --git a/lib/trackers/gh.sh b/lib/trackers/gh.sh index 112b25d..eedf3a4 100644 --- a/lib/trackers/gh.sh +++ b/lib/trackers/gh.sh @@ -126,10 +126,57 @@ aw_tracker_pr_item() { } # Open project items in none of the three configured statuses (in progress / in -# review / done), in the order `gh project item-list` returns them. Read from the -# workflow doc rather than added as new settings (#272): the doc already names -# the project and all three statuses. Compared case-insensitively, since the -# template says `In Progress` and GitHub's own default column is `In progress`. +# review / done), read from the workflow doc rather than added as new settings +# (#272): the doc already names the project and all three statuses. Compared +# case-insensitively, since the template says `In Progress` and GitHub's own +# default column is `In progress`. +# +# Also left out: closed and archived items, drafts, other repos' issues, and any +# issue that has sub-issues — an epic is tracked through its children, and is +# never itself the next thing to pick up. +# +# Order: by the project's Priority field when it has one, in the field's own +# option order (P0 before P1), and otherwise in the order the API returns items. +# That is NOT the board's order: Projects v2 exposes no view ordering here, and +# the API order is roughly the order items were added. +# +# One GraphQL query, paginated, rather than `gh project item-list`: item-list +# carries neither sub-issue counts nor issue state, and asking per item would +# cost a call each. +# shellcheck disable=SC2016 # GraphQL variables, not shell expansions +_AW_GH_PROJECT_QUERY=' +query($owner: String!, $number: Int!, $endCursor: String) { + repositoryOwner(login: $owner) { + ... on ProjectV2Owner { + projectV2(number: $number) { + priority: field(name: "Priority") { + ... on ProjectV2SingleSelectField { options { id } } + } + items(first: 100, after: $endCursor) { + pageInfo { hasNextPage endCursor } + nodes { + isArchived + status: fieldValueByName(name: "Status") { + ... on ProjectV2ItemFieldSingleSelectValue { name } + } + priority: fieldValueByName(name: "Priority") { + ... on ProjectV2ItemFieldSingleSelectValue { optionId } + } + content { + __typename + ... on Issue { + number title body url state + repository { nameWithOwner } + subIssuesSummary { total } + } + } + } + } + } + } + } +}' + aw_tracker_next_fallback() { local root="$1" doc="$2" limit="${3:-10}" owner repo project statuses json item desc owner=$(tf_tracker_value "$doc" gh owner) @@ -139,14 +186,25 @@ aw_tracker_next_fallback() { # shellcheck disable=SC2016 # the backticks are literal markdown, not expansions statuses=$(sed -n -E 's/^- \*\*Status when (starting work|in review|done)\*\*: `([^`]*)`.*/\2/p' "$doc" 2>/dev/null \ | jq -Rsc 'split("\n") | map(ascii_downcase | select(. != ""))') - json=$(cd "$root" 2>/dev/null && gh project item-list "$project" --owner "$owner" --format json --limit 200 2>/dev/null) || return 1 + # --paginate prints one response per page; jq -s below takes the stream whole. + json=$(cd "$root" 2>/dev/null && gh api graphql --paginate -f query="$_AW_GH_PROJECT_QUERY" \ + -f owner="$owner" -F number="$project" 2>/dev/null) || return 1 [[ -n "$json" ]] || return 1 - printf '%s' "$json" | jq -c --arg repo "${repo:+$owner/$repo}" --argjson skip "$statuses" --argjson limit "$limit" ' - [ .items[] - | select(.content.type == "Issue") - | select($repo == "" or .content.repository == $repo) - | select(((.status // "") | ascii_downcase) as $s | ($skip | index($s)) | not) - ][:$limit][] + # An unknown project makes jq error out, and bin/task-monitor-snapshot runs + # with pipefail, so that reaches the caller as a failed ask, not "nothing". + printf '%s' "$json" | jq -sc --arg repo "${repo:+$owner/$repo}" --argjson skip "$statuses" --argjson limit "$limit" ' + [.[] | .data.repositoryOwner.projectV2 | select(. != null)] as $pages + | if ($pages | length) == 0 then error("no such project") else . end + | ([$pages[0].priority.options[]?.id]) as $prio + | [ $pages[] | .items.nodes[] + | select(.isArchived | not) + | select(.content.__typename == "Issue" and .content.state == "OPEN") + | select($repo == "" or .content.repository.nameWithOwner == $repo) + | select((.content.subIssuesSummary.total // 0) == 0) + | select(((.status.name // "") | ascii_downcase) as $s | ($skip | index($s)) | not) ] + | to_entries + | sort_by([(.value.priority.optionId as $o | ($prio | index($o)) // ($prio | length)), .key]) + | [.[].value][:$limit][] | .content | {ref: (.number | tostring), label: ("#" + (.number | tostring)), number: .number, title: .title, body: (.body // ""), url: .url}' \ diff --git a/tests/task_monitor_snapshot.bats b/tests/task_monitor_snapshot.bats index 40c2855..ded88a3 100644 --- a/tests/task_monitor_snapshot.bats +++ b/tests/task_monitor_snapshot.bats @@ -34,7 +34,7 @@ case "$1 $2" in *"--state open"*) _serve pr-open.json ;; *"--state merged"*) _serve pr-merged.json ;; esac ;; - "project item-list") _serve project.json ;; + "api graphql") _serve project.json ;; esac exit 1 STUB @@ -84,10 +84,11 @@ sidecar() { } # session [extra KEY=VALUE lines...] +# The heartbeat is a minute old unless $HB says otherwise. session() { - local role="$1" state="$2"; shift 2 - { printf 'ROLE=%s\nTAB=%s\nTAB_ID=4\nZELLIJ_SESSION=zs\nREPO=%s\nREPO_NAME=%s\nSTATE=%s\nLAST_HEARTBEAT=2026-10-20T10:00:00Z\nAGENT=claude\nLOADOUT=claude-gh\n' \ - "$role" "$role" "$MAIN_REPO" "$NAME" "$state" + local role="$1" state="$2" hb="${HB:-$(ago 60)}"; shift 2 + { printf 'ROLE=%s\nTAB=%s\nTAB_ID=4\nZELLIJ_SESSION=zs\nREPO=%s\nREPO_NAME=%s\nSTATE=%s\nLAST_HEARTBEAT=%s\nAGENT=claude\nLOADOUT=claude-gh\n' \ + "$role" "$role" "$MAIN_REPO" "$NAME" "$state" "$hb" local l; for l in "$@"; do printf '%s\n' "$l"; done; } > "$SESS/$role.info" } @@ -172,10 +173,10 @@ gh_calls() { cat "$STUB_CALLS_DIR/gh.calls" 2>/dev/null || true; } @test "in flight: the worker session's STATE and heartbeat are joined" { sidecar issue-5 GH_URL https://github.com/acme/widgets/issues/5 - session "worker-$NAME-issue-5" awaiting + HB=2026-10-20T10:00:00Z session "worker-$NAME-issue-5" awaiting snap assert_equal "$(q '.in_flight[0].session.state')" awaiting - assert_equal "$(q '.in_flight[0].session.since')" 2026-10-20T10:00:00Z + assert_equal "$(q '.in_flight[0].session.last_heartbeat')" 2026-10-20T10:00:00Z } @test "in flight gh: issue title and the first prose paragraph of its body" { @@ -293,31 +294,81 @@ gh_calls() { cat "$STUB_CALLS_DIR/gh.calls" 2>/dev/null || true; } assert_equal "$(q '.next.items[0].title')" "Eight" } -@test "next gh fallback: project items in none of the three statuses, minus in flight and other repos" { +# project_page [hasNextPage] +# One page of the GraphQL answer aw_tracker_next_fallback asks for, built from +# compact items: {status, prio, type, number, title, body, repo, state, subs, +# archived}. The project's Priority options are p0, p1, p2, in that order. +project_page() { + jq -c --argjson more "${2:-false}" '{data: {repositoryOwner: {projectV2: { + priority: {options: [{id: "p0"}, {id: "p1"}, {id: "p2"}]}, + items: {pageInfo: {hasNextPage: $more, endCursor: "c"}, nodes: map({ + isArchived: (.archived // false), + status: (if .status then {name: .status} else null end), + priority: (if .prio then {optionId: .prio} else null end), + content: (if .type == "DraftIssue" then {__typename: "DraftIssue"} else + {__typename: "Issue", number, title: (.title // "t\(.number)"), body: (.body // ""), + url: "https://github.com/acme/widgets/issues/\(.number)", state: (.state // "OPEN"), + repository: {nameWithOwner: (.repo // "acme/widgets")}, + subIssuesSummary: {total: (.subs // 0)}} end)})}}}}}' <<<"$1" +} + +@test "next gh fallback: project issues in none of the three statuses, minus in flight and other repos" { sidecar issue-2 GH_URL https://github.com/acme/widgets/issues/2 - jq -n '{items: [ - {status: "Backlog", content: {type: "Issue", number: 1, title: "one", body: "B1.", url: "https://github.com/acme/widgets/issues/1", repository: "acme/widgets"}}, - {status: "In progress", content: {type: "Issue", number: 3, title: "three", body: "", url: "https://github.com/acme/widgets/issues/3", repository: "acme/widgets"}}, - {status: null, content: {type: "Issue", number: 2, title: "two", body: "", url: "https://github.com/acme/widgets/issues/2", repository: "acme/widgets"}}, - {status: "Ready", content: {type: "Issue", number: 4, title: "four", body: "", url: "https://github.com/acme/widgets/issues/4", repository: "acme/other"}}, - {status: "done", content: {type: "Issue", number: 5, title: "five", body: "", url: "https://github.com/acme/widgets/issues/5", repository: "acme/widgets"}}, - {status: "Ready", content: {type: "DraftIssue", title: "draft", body: ""}}, - {status: "Ready", content: {type: "Issue", number: 6, title: "six", body: "", url: "https://github.com/acme/widgets/issues/6", repository: "acme/widgets"}} - ]}' > "$GH_FIX/project.json" + project_page '[ + {"status": "Backlog", "number": 1, "body": "B1."}, + {"status": "In progress", "number": 3}, + {"status": null, "number": 2}, + {"status": "Ready", "number": 4, "repo": "acme/other"}, + {"status": "done", "number": 5}, + {"status": "Ready", "type": "DraftIssue"}, + {"status": "Ready", "number": 6} + ]' > "$GH_FIX/project.json" snap assert_equal "$(q .next.source)" tracker assert_equal "$(q '[.next.items[].label] | join(" ")')" "#1 #6" assert_equal "$(q '.next.items[0].description')" "B1." - run grep -F "project item-list 7 --owner acme" "$STUB_CALLS_DIR/gh.calls" + run grep -F -- "-f owner=acme -F number=7" "$STUB_CALLS_DIR/gh.calls" assert_success } +@test "next gh fallback: epics with sub-issues, closed issues and archived items are left out" { + project_page '[ + {"status": "Backlog", "number": 1, "subs": 3}, + {"status": "Backlog", "number": 2, "state": "CLOSED"}, + {"status": "Backlog", "number": 3, "archived": true}, + {"status": "Backlog", "number": 4} + ]' > "$GH_FIX/project.json" + snap + assert_equal "$(q '[.next.items[].label] | join(" ")')" "#4" +} + +@test "next gh fallback: ordered by the Priority field's option order, then API order" { + project_page '[ + {"status": "Backlog", "number": 1, "prio": "p2"}, + {"status": "Backlog", "number": 2}, + {"status": "Backlog", "number": 3, "prio": "p0"}, + {"status": "Backlog", "number": 4, "prio": "p2"} + ]' > "$GH_FIX/project.json" + # A second page, as `gh api graphql --paginate` prints it: one response after another. + project_page '[{"status": "Backlog", "number": 5, "prio": "p1"}]' >> "$GH_FIX/project.json" + snap + assert_equal "$(q '[.next.items[].label] | join(" ")')" "#3 #5 #1 #4 #2" +} + +@test "next gh fallback: an unknown project is a failed ask, so the last answer is kept" { + project_page '[{"status": "Backlog", "number": 4}]' > "$GH_FIX/project.json" + snap + echo '{"data": {"repositoryOwner": {"projectV2": null}}}' > "$GH_FIX/project.json" + snap + assert_equal "$(q '[.next.items[].label] | join(" ")')" "#4" +} + @test "next gh fallback: draft items are left out even when the doc names no repo" { sed -i.bak '/\*\*Repo\*\*/d' "$MAIN_REPO/.claude/gh-workflow.md" - jq -n '{items: [ - {status: "Ready", content: {type: "DraftIssue", title: "draft", body: ""}}, - {status: "Ready", content: {type: "Issue", number: 6, title: "six", body: "", url: "https://github.com/acme/widgets/issues/6", repository: "acme/widgets"}} - ]}' > "$GH_FIX/project.json" + project_page '[ + {"status": "Ready", "type": "DraftIssue"}, + {"status": "Ready", "number": 6} + ]' > "$GH_FIX/project.json" snap assert_equal "$(q '[.next.items[].label] | join(" ")')" "#6" } @@ -416,7 +467,9 @@ gh_calls() { cat "$STUB_CALLS_DIR/gh.calls" 2>/dev/null || true; } local other; other=$(basename "$OTHER_REPO" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9-]//g') session "pm-$NAME" idle printf 'ROLE=pm-%s\nALIAS=pm-%s\nREPO=%s\n' "$other" "$NAME" "$OTHER_REPO" > "$SESS/pm-$other.info" - msg "pm-$other" inbox 1 "worker-$other-a" review-requested 3 + # What radio's alias hop does with a worker's `--to pm`: it lands in the + # primary's inbox. The alias owns none. + msg "pm-$NAME" inbox 1 "worker-$other-a" review-requested 3 snap --all assert_equal "$(jq -r --arg n "$NAME" '.repos[] | select(.name == $n) | .pm.unread' <<<"$output")" 1 assert_equal "$(jq -r --arg n "$other" '.repos[] | select(.name == $n) | [.pm.alias_of, .pm.unread, .attention.active] | join(",")' <<<"$output")" "pm-$NAME,0,false" @@ -458,6 +511,33 @@ gh_calls() { cat "$STUB_CALLS_DIR/gh.calls" 2>/dev/null || true; } assert_equal "$(q '.attention.signals[0].text')" "❓ issue-5 is asking" } +@test "attention: an awaiting worker whose heartbeat is over an hour old does not light the repo" { + HB=$(ago 3700) session "worker-$NAME-issue-5" awaiting + snap + assert_equal "$(q .attention.active)" false + # The session itself still shows, so the front end can show it as gone quiet. + sidecar issue-5 GH_URL https://github.com/acme/widgets/issues/5 + snap + assert_equal "$(q '.in_flight[0].session.state')" awaiting +} + +@test "attention: two messages in the same second are ordered by the radio log, not by id" { + pr_fixture 9 task/issue-5 "Closes #5" > "$BATS_TEST_TMPDIR/p9" + open_prs "$BATS_TEST_TMPDIR/p9" + # Sent in this order within one second: the review, then the PM's answer. By + # id they sort the other way round (from-pm-… < from-reviewer-…), which + # would leave the review looking unanswered. + local rev="20261020-100001-from-reviewer-$NAME-pr9-zq" pm="20261020-100001-from-pm-$NAME-a1" + mkdir -p "$MBOX/pm-$NAME/processed" "$MBOX/worker-$NAME-issue-5/processed" + printf -- '---\nid: %s\nfrom: reviewer-%s-pr9\nto: pm-%s\nintent: review-complete-clean\npr: 9\ncreated_at: 2026-10-20T10:00:01Z\n---\n' \ + "$rev" "$NAME" "$NAME" > "$MBOX/pm-$NAME/processed/$rev.md" + printf -- '---\nid: %s\nfrom: pm-%s\nto: worker-%s-issue-5\nintent: changes-requested\npr: 9\ncreated_at: 2026-10-20T10:00:01Z\n---\n' \ + "$pm" "$NAME" "$NAME" > "$MBOX/worker-$NAME-issue-5/processed/$pm.md" + printf '2026-10-20T10:00:01Z send id=%s from=x to=y intent=z\n' "$rev" "$pm" > "$TASK_FORCE_HOME/radio/log" + snap + assert_equal "$(q .attention.active)" false +} + @test "attention: nothing but the three signals lights a repo" { sidecar issue-5 GH_URL https://github.com/acme/widgets/issues/5 session "worker-$NAME-issue-5" busy @@ -519,6 +599,18 @@ gh_calls() { cat "$STUB_CALLS_DIR/gh.calls" 2>/dev/null || true; } assert_equal "$(q '.forge.stale')" true } +@test "a repo whose forge has never answered is unavailable, not stale" { + rm -f "$GH_FIX"/*.json + snap + assert_success + assert_equal "$(q '[.forge.available, .forge.stale] | map(tostring) | join(",")')" "false,false" + echo '[]' > "$GH_FIX/pr-open.json" + snap + assert_equal "$(q '[.forge.available, .forge.stale] | map(tostring) | join(",")')" "true,false" + snap --local + assert_equal "$(q '[.forge.available, .forge.stale] | map(tostring) | join(",")')" "true,true" +} + @test "--all adds every repo a radio session names, resolving a worktree to its main repo" { OTHER_REPO=$(mktemp -d) git -C "$OTHER_REPO" init -q -b main @@ -526,7 +618,7 @@ gh_calls() { cat "$STUB_CALLS_DIR/gh.calls" 2>/dev/null || true; } mkdir -p "$OTHER_REPO-worktrees" git -C "$OTHER_REPO" worktree add -q "$OTHER_REPO-worktrees/w" -b task/w local other; other=$(basename "$OTHER_REPO" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9-]//g') - printf 'ROLE=worker-%s-w\nREPO=%s\nREPO_NAME=%s\nSTATE=awaiting\n' "$other" "$OTHER_REPO-worktrees/w" "$other" \ + printf 'ROLE=worker-%s-w\nREPO=%s\nREPO_NAME=%s\nSTATE=awaiting\nLAST_HEARTBEAT=%s\n' "$other" "$OTHER_REPO-worktrees/w" "$other" "$(ago 60)" \ > "$SESS/worker-$other-w.info" snap assert_equal "$(jq '.repos | length' <<<"$output")" 1 From 51fe55dd0dd4f5bae6984ec9dee3ffeadc0773ed Mon Sep 17 00:00:00 2001 From: Martin Contreras Date: Tue, 20 Oct 2026 13:28:43 -0300 Subject: [PATCH 3/3] task-monitor 1/6: snapshot --json collector (bash) and schema: fix CI on jq 1.7 and newer shellcheck - lib/monitor-assemble.jq: parenthesize the `available` comparison. jq 1.7.1 (ubuntu-latest) rejects `!=` as a bare object value; jq 1.8 accepts it, which is why it passed locally. Reproduced with a 1.7.1 binary, and the suite passes under it. - bin/task-monitor-snapshot: the radio-log guard is an `if` instead of `A && B || true` (SC2015 on CI's shellcheck). Co-Authored-By: Claude Opus 5.5 --- bin/task-monitor-snapshot | 10 +++++++--- lib/monitor-assemble.jq | 2 +- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/bin/task-monitor-snapshot b/bin/task-monitor-snapshot index cba3b50..a271808 100755 --- a/bin/task-monitor-snapshot +++ b/bin/task-monitor-snapshot @@ -154,9 +154,13 @@ _messages_ndjson() { # ties, which ids cannot (see msg_order in lib/monitor-assemble.jq). # No log yet is no order yet, not an error: awk exits 2 on a missing file, # which pipefail would make fatal. - { [[ -f "$TF_HOME/radio/log" ]] && awk ' - match($0, / send id=[^ ]+/) { print substr($0, RSTART + 9, RLENGTH - 9) "\t" NR } - ' "$TF_HOME/radio/log" || true; } | jq -Rsc ' + { + if [[ -f "$TF_HOME/radio/log" ]]; then + awk ' + match($0, / send id=[^ ]+/) { print substr($0, RSTART + 9, RLENGTH - 9) "\t" NR } + ' "$TF_HOME/radio/log" + fi + } | jq -Rsc ' split("\n") | map(select(. != "") | split("\t")) | map({key: .[0], value: (.[1] | tonumber)}) | from_entries ' > "$WORK/logseq.json" find "${dirs[@]}" -type f -name '*.md' -exec awk ' diff --git a/lib/monitor-assemble.jq b/lib/monitor-assemble.jq index 99c1370..48c5990 100644 --- a/lib/monitor-assemble.jq +++ b/lib/monitor-assemble.jq @@ -161,7 +161,7 @@ def status($pr; $last_review; $has_reviewer): # `available`: the forge has answered for this repo at least once. A repo # with no GitHub remote never has, and would otherwise read as permanently # stale; `stale` is only meaningful for a repo whose forge does answer. - forge: {available: ($fetched_at | nonempty) != null, + forge: {available: (($fetched_at | nonempty) != null), fetched_at: ($fetched_at | nonempty), stale: (($fetched_at | nonempty) != null and $forge_ok != 1)}, in_flight: $in_flight,