diff --git a/.claude/gh-workflow.md b/.claude/gh-workflow.md index d115f18..7a129db 100644 --- a/.claude/gh-workflow.md +++ b/.claude/gh-workflow.md @@ -269,6 +269,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 d8dd470..37ad633 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -197,6 +197,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: 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` 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` (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. **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 fd64cf5..3847325 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..a271808 --- /dev/null +++ b/bin/task-monitor-snapshot @@ -0,0 +1,518 @@ +#!/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 + # 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. + { + 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 ' + 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/" --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], 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)}' +} + +# 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 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" \ + --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 8ebe048..82d7d72 100644 --- a/claude-gh/steering/gh-workflow.example.md +++ b/claude-gh/steering/gh-workflow.example.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/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 a590764..96085be 100644 --- a/claude-jira/steering/jira-workflow.example.md +++ b/claude-jira/steering/jira-workflow.example.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/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 9dfe264..a8bc981 100644 --- a/claude-local/steering/local-workflow.example.md +++ b/claude-local/steering/local-workflow.example.md @@ -287,6 +287,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 4b413cc..9829c95 100644 --- a/claude-notion/steering/notion-workflow.example.md +++ b/claude-notion/steering/notion-workflow.example.md @@ -282,6 +282,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..17ebe86 --- /dev/null +++ b/docs/task-monitor-schema.example.json @@ -0,0 +1,145 @@ +{ + "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", + "last_heartbeat": "2026-10-18T20:49:28Z", + "tab": "⏸️ pm-task-force", + "tab_id": "0", + "zellij_session": "task-force", + "agent": "claude", + "alias_of": null, + "unread": 1 + }, + "forge": { + "available": true, + "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", + "last_heartbeat": "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", + "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 new file mode 100644 index 0000000..2617d53 --- /dev/null +++ b/docs/task-monitor-schema.md @@ -0,0 +1,226 @@ +# 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 | `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` | +| `attention` | object | `active` and `signals` | + +### `pm` + +`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 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 + +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, 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 +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 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`. +- `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 `❓`, 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. + +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 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. + +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, 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 +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 0f63802..09b53f8 100644 --- a/kiro-gh/steering/gh-workflow.example.md +++ b/kiro-gh/steering/gh-workflow.example.md @@ -263,6 +263,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 c0f5608..11a7651 100644 --- a/kiro-local/steering/local-workflow.example.md +++ b/kiro-local/steering/local-workflow.example.md @@ -285,6 +285,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 b71f8fb..5902faa 100644 --- a/kiro-notion/steering/notion-workflow.example.md +++ b/kiro-notion/steering/notion-workflow.example.md @@ -260,6 +260,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..48c5990 --- /dev/null +++ b/lib/monitor-assemble.jq @@ -0,0 +1,171 @@ +# 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), 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" + 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(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 +# 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 +# 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" and .mailbox == $pm_role)] | length + end) as $unread +| ([$msgs[] | select(.pr != null) | select(.intent | among(decision_intents))] + | group_by(.pr) + | 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" 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? // .)), + 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, last_heartbeat: null, tab: null, + tab_id: null, zellij_session: null, agent: null}) + + {alias_of: $alias_of, unread: $unread}), + # `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}, + 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" +}