feat(board): cross-repo attention board as script + skill (0.9.0)
The mailbox answers "who wrote to me"; it never answered "which repo deserves the next session". board.sh scans every discovered repo and reads three sources each: the STATE.md next-step block with its optional board line, git status, and that repo's pending mailbox count. Read-only by construction, pinned by board-selftest.sh (28 checks). It ships here rather than as a personal script because the mailbox is one of its three inputs and the two carry the same axis distinction: a pending count means others are waiting on that repo, while who a repo waits ON comes only from its board line, since the message format has no reply-to field. Splitting the board from the mailbox would put that distinction in two places. It also lets the skill resolve the engine through CLAUDE_PLUGIN_ROOT like every other script here, instead of depending on a file that exists only in ~/.claude/scripts (a directory with no remote and no backup). The skill is a ranking, not a report: re-runs the board every invocation because counts drift, ranks by what unblocks the most and what is cheapest to move, then names one repo, the rule that fired, and the real next action read from that repo's STATE.md. Never the table. Not wired into session start, which would spend context on repos the session is not in. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MubwdTi88yu4hVLAFG1LbM
This commit is contained in:
parent
ec92c866c1
commit
61e224ccc3
10 changed files with 691 additions and 13 deletions
219
scripts/board-selftest.sh
Executable file
219
scripts/board-selftest.sh
Executable file
|
|
@ -0,0 +1,219 @@
|
|||
#!/bin/bash
|
||||
# board-selftest.sh - prove board.sh end-to-end against a throwaway repo tree
|
||||
# and a throwaway mailbox (never touches ~/repos or ~/.claude/coord). Re-run
|
||||
# after any edit to board.sh. ASCII only, bash 3.2 safe.
|
||||
#
|
||||
# Multibyte STATE.md content (em-dash, the NESTE pointing-hand) is generated
|
||||
# with printf octal escapes so this script's own source stays pure ASCII - a
|
||||
# literal em-dash in shell source has crashed bash 3.2 under `set -u` before.
|
||||
set -u
|
||||
export LC_ALL=C
|
||||
|
||||
DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
BOARD="$DIR/board.sh"
|
||||
|
||||
ROOT="$(mktemp -d)"
|
||||
CLAUDE_COORD_DIR="$(mktemp -d)"
|
||||
export CLAUDE_COORD_DIR
|
||||
cleanup() { /bin/rm -rf "$ROOT" "$CLAUDE_COORD_DIR" 2>/dev/null; }
|
||||
trap cleanup EXIT
|
||||
|
||||
PASS=0; FAIL=0
|
||||
check() { if [ "$2" -eq 0 ]; then PASS=$((PASS+1)); echo " ok - $1"; else FAIL=$((FAIL+1)); echo " FAIL - $1"; fi; }
|
||||
|
||||
# Multibyte building blocks (octal escapes keep this source ASCII).
|
||||
EMDASH="$(printf '\342\200\224')"
|
||||
HAND="$(printf '\360\237\221\211')"
|
||||
OSLASH="$(printf '\303\270')"
|
||||
|
||||
mkrepo() { mkdir -p "$1" && git -C "$1" init -q 2>/dev/null; }
|
||||
|
||||
echo "board-selftest (root: $ROOT, mailbox: $CLAUDE_COORD_DIR)"
|
||||
|
||||
# --- 0. Empty root: no repos, still exits cleanly. ---
|
||||
out0="$("$BOARD" --roots "$ROOT" 2>/dev/null)"; rc=$?
|
||||
[ "$rc" -eq 0 ]; check "empty root exits 0" $?
|
||||
|
||||
# --- Fixture tree ---------------------------------------------------------
|
||||
|
||||
# repo-a: full board line, in-progress, cheap next step, unhandled inbox.
|
||||
mkrepo "$ROOT/repo-a"
|
||||
{
|
||||
echo "# STATE - repo-a"
|
||||
echo ""
|
||||
printf '## %s NESTE %s START HER\n' "$HAND" "$EMDASH"
|
||||
echo "<!-- board: status=in-progress; blocked-on=-; next-cost=sonnet/xhigh -->"
|
||||
printf '**Lukk to MAJOR fra reviewen** %s deretter S3.3 concurrent fan-out.\n' "$EMDASH"
|
||||
} > "$ROOT/repo-a/STATE.md"
|
||||
|
||||
# repo-b: STATE + NESTE prose but NO board line (the un-backfilled majority).
|
||||
mkrepo "$ROOT/repo-b"
|
||||
{
|
||||
echo "# STATE - repo-b"
|
||||
echo ""
|
||||
printf '## %s NESTE %s START HER\n' "$HAND" "$EMDASH"
|
||||
echo ""
|
||||
printf '> Kj%sr forskningstema 1 headless %s hard gate for motorens arkitektur.\n' "$OSLASH" "$EMDASH"
|
||||
} > "$ROOT/repo-b/STATE.md"
|
||||
|
||||
# repo-c: no STATE.md at all (dormant class).
|
||||
mkrepo "$ROOT/repo-c"
|
||||
|
||||
# repo-d: dirty working tree.
|
||||
mkrepo "$ROOT/repo-d"
|
||||
{
|
||||
echo "# STATE - repo-d"
|
||||
printf '## %s NESTE %s START HER\n' "$HAND" "$EMDASH"
|
||||
echo "<!-- board: status=deferred; blocked-on=-; next-cost=sonnet/high -->"
|
||||
echo "Parkert med vilje."
|
||||
} > "$ROOT/repo-d/STATE.md"
|
||||
# Commit STATE.md first, so the dirty count proves board counts UNCOMMITTED
|
||||
# files only (2 junk files) rather than every file in a fresh tree.
|
||||
git -C "$ROOT/repo-d" add STATE.md >/dev/null 2>&1
|
||||
git -C "$ROOT/repo-d" -c user.email=t@t -c user.name=t commit -qm init >/dev/null 2>&1
|
||||
: > "$ROOT/repo-d/junk1.txt"
|
||||
: > "$ROOT/repo-d/junk2.txt"
|
||||
|
||||
# repo-e: malformed status token (must be flagged, never silently accepted).
|
||||
mkrepo "$ROOT/repo-e"
|
||||
{
|
||||
echo "# STATE - repo-e"
|
||||
printf '## %s NESTE %s START HER\n' "$HAND" "$EMDASH"
|
||||
echo "<!-- board: status=aktiv; blocked-on=-; next-cost=opus/high -->"
|
||||
echo "Ugyldig token."
|
||||
} > "$ROOT/repo-e/STATE.md"
|
||||
|
||||
# repo-g: prose containing a 'board:'-lookalike ABOVE the real board line.
|
||||
# An unanchored substring grep would match 'dashboard:' first and mis-parse the
|
||||
# whole repo; the board line is defined as living under the NESTE heading.
|
||||
mkrepo "$ROOT/repo-g"
|
||||
{
|
||||
echo "# STATE - repo-g"
|
||||
echo "Vi bygde et dashboard: status=done ble diskutert i forrige runde."
|
||||
printf '## %s NESTE %s START HER\n' "$HAND" "$EMDASH"
|
||||
echo "<!-- board: status=planned; blocked-on=-; next-cost=opus/xhigh -->"
|
||||
echo "Ekte neste steg her."
|
||||
} > "$ROOT/repo-g/STATE.md"
|
||||
|
||||
# repo-h: next-cost written the way the model rubric actually names the models,
|
||||
# with a space and capitals ("Sonnet 5/xhigh"). This is spec-conformant
|
||||
# (<modell>/<effort>) and must parse - a lowercase-only pattern silently drops
|
||||
# it to "?" and hides the cost column exactly where it is needed.
|
||||
mkrepo "$ROOT/repo-h"
|
||||
{
|
||||
echo "# STATE - repo-h"
|
||||
printf '## %s NESTE %s START HER\n' "$HAND" "$EMDASH"
|
||||
echo "<!-- board: status=in-progress; blocked-on=-; next-cost=Sonnet 5/xhigh -->"
|
||||
echo "Neste steg her."
|
||||
} > "$ROOT/repo-h/STATE.md"
|
||||
|
||||
# polyrepo/: NOT a git repo itself, but holds git repos one level down.
|
||||
mkdir -p "$ROOT/polyrepo"
|
||||
mkrepo "$ROOT/polyrepo/plug-x"
|
||||
{
|
||||
echo "# STATE - plug-x"
|
||||
printf '## %s NESTE %s START HER\n' "$HAND" "$EMDASH"
|
||||
echo "<!-- board: status=blocked; blocked-on=repo-a; next-cost=opus/high -->"
|
||||
printf 'Venter p%s amendment-pakken.\n' "$EMDASH"
|
||||
} > "$ROOT/polyrepo/plug-x/STATE.md"
|
||||
|
||||
# repo-wt + wt-feature: a git WORKTREE (and a submodule) has .git as a FILE,
|
||||
# not a directory. Measured 2026-07-26: STATE.md is TRACKED in 7 of 25 real
|
||||
# repos, so `git worktree add ~/repos/feature-x` yields a sibling directory
|
||||
# that CARRIES a STATE.md. Testing discovery for a .git DIRECTORY only drops
|
||||
# it silently - the same silent-loss class as the V6 marker fix.
|
||||
mkrepo "$ROOT/repo-wt"
|
||||
{
|
||||
echo "# STATE - repo-wt"
|
||||
printf '## %s NESTE %s START HER\n' "$HAND" "$EMDASH"
|
||||
echo "<!-- board: status=done; blocked-on=-; next-cost=sonnet/high -->"
|
||||
printf 'Hovedtreet %s ferdig.\n' "$EMDASH"
|
||||
} > "$ROOT/repo-wt/STATE.md"
|
||||
git -C "$ROOT/repo-wt" add STATE.md >/dev/null 2>&1
|
||||
git -C "$ROOT/repo-wt" -c user.email=t@t -c user.name=t commit -qm init >/dev/null 2>&1
|
||||
git -C "$ROOT/repo-wt" -c user.email=t@t -c user.name=t \
|
||||
worktree add -q -b feature-x "$ROOT/wt-feature" >/dev/null 2>&1
|
||||
# Distinct board line, so the check proves board read the STATE INSIDE the
|
||||
# worktree rather than matching the source repo's row by accident.
|
||||
{
|
||||
echo "# STATE - wt-feature"
|
||||
printf '## %s NESTE %s START HER\n' "$HAND" "$EMDASH"
|
||||
echo "<!-- board: status=in-progress; blocked-on=-; next-cost=fable/xhigh -->"
|
||||
printf 'Arbeid i worktree %s eget neste steg.\n' "$EMDASH"
|
||||
} > "$ROOT/wt-feature/STATE.md"
|
||||
|
||||
# plain-dir/: no git repo anywhere under it - must be ignored entirely.
|
||||
mkdir -p "$ROOT/plain-dir/sub"
|
||||
echo "hei" > "$ROOT/plain-dir/sub/file.txt"
|
||||
|
||||
# Coord fixture: 3 unhandled for repo-a, 1 archived (must not be counted).
|
||||
mkdir -p "$CLAUDE_COORD_DIR/repo-a/inbox" "$CLAUDE_COORD_DIR/repo-a/archive"
|
||||
for n in 1 2 3; do echo "msg" > "$CLAUDE_COORD_DIR/repo-a/inbox/2026-msg$n-from-x.md"; done
|
||||
echo "old" > "$CLAUDE_COORD_DIR/repo-a/archive/2026-old-from-x.md"
|
||||
|
||||
OUT="$("$BOARD" --roots "$ROOT" 2>/dev/null)"
|
||||
|
||||
# --- 1. Discovery ---------------------------------------------------------
|
||||
printf '%s' "$OUT" | grep -q 'repo-a'; check "discovers top-level git repo" $?
|
||||
printf '%s' "$OUT" | grep -q 'plug-x'; check "discovers nested polyrepo git repo (depth 2)" $?
|
||||
printf '%s' "$OUT" | grep -q 'repo-c'; check "lists git repo without STATE.md" $?
|
||||
printf '%s' "$OUT" | grep -q 'plain-dir'; [ $? -ne 0 ]; check "ignores non-git directory tree" $?
|
||||
printf '%s' "$OUT" | grep -q 'polyrepo'; [ $? -ne 0 ]; check "polyrepo container itself is not listed as a repo" $?
|
||||
# Guard the fixture itself: if git ever stops writing a .git FILE for
|
||||
# worktrees, the next two checks would pass for the wrong reason.
|
||||
[ -f "$ROOT/wt-feature/.git" ]; check "fixture: worktree .git is a FILE, not a dir" $?
|
||||
printf '%s' "$OUT" | grep -q 'wt-feature'; check "discovers git worktree (.git is a file)" $?
|
||||
printf '%s' "$OUT" | grep -qE 'wt-feature.*fable/xhigh'
|
||||
check "reads STATE.md from inside the worktree, not the source repo" $?
|
||||
|
||||
# --- 2. Board line parsing (the B field) ----------------------------------
|
||||
printf '%s' "$OUT" | grep -q 'in-progress'; check "parses status token from board line" $?
|
||||
printf '%s' "$OUT" | grep -q 'sonnet/xhigh'; check "parses next-cost from board line" $?
|
||||
printf '%s' "$OUT" | grep -qE 'repo-h.*Sonnet 5/xhigh'
|
||||
check "next-cost accepts spaces and capitals (rubric model names)" $?
|
||||
printf '%s' "$OUT" | grep -qE 'plug-x.*repo-a'
|
||||
check "blocked repo names its blocker on its own row" $?
|
||||
printf '%s' "$OUT" | grep -q 'deferred'; check "distinguishes deferred from blocked" $?
|
||||
|
||||
# --- 3. Malformed input is flagged, not swallowed -------------------------
|
||||
printf '%s' "$OUT" | grep -qi 'malformed\|ugyldig\|invalid'; check "malformed status token is flagged" $?
|
||||
|
||||
# A 'dashboard:' lookalike earlier in the file must not win over the real line.
|
||||
printf '%s' "$OUT" | grep -qE 'repo-g.*planned.*opus/xhigh'
|
||||
check "board-line parse ignores 'board:' lookalikes in prose" $?
|
||||
|
||||
# --- 4. Heuristic fallback when board line is absent -----------------------
|
||||
printf '%s' "$OUT" | grep -q 'repo-b'; check "repo without board line still listed" $?
|
||||
printf '%s' "$OUT" | grep -qi 'forskningstema\|headless'; check "shows NESTE excerpt when board line absent" $?
|
||||
# Markdown noise must be stripped: a blockquoted NESTE line renders as prose.
|
||||
printf '%s' "$OUT" | grep -qE 'repo-b.*[[:space:]]>'; [ $? -ne 0 ]
|
||||
check "blockquote marker stripped from NESTE excerpt" $?
|
||||
|
||||
# --- 5. Git + coord signals ------------------------------------------------
|
||||
printf '%s' "$OUT" | grep -qE 'repo-d.*[^0-9]2([^0-9]|$)'; check "reports dirty file count" $?
|
||||
printf '%s' "$OUT" | grep -qE 'repo-a.*[^0-9]3([^0-9]|$)'; check "reports unhandled coord inbox count (archive excluded)" $?
|
||||
|
||||
# --- 6. Bucketing ----------------------------------------------------------
|
||||
printf '%s' "$OUT" | grep -qi 'blocked'; check "blocked repo surfaced" $?
|
||||
printf '%s' "$OUT" | grep -qiE 'dormant|sovende|uten STATE'; check "STATE-less repos bucketed separately" $?
|
||||
|
||||
# --- 7. Robustness ---------------------------------------------------------
|
||||
# Multibyte prose must not crash the reader nor emit split-character garbage.
|
||||
[ -n "$OUT" ]; check "produces output over multibyte STATE prose" $?
|
||||
printf '%s' "$OUT" | iconv -f UTF-8 -t UTF-8 >/dev/null 2>&1
|
||||
check "output is valid UTF-8 (no split multibyte truncation)" $?
|
||||
|
||||
# A STATE.md with a NESTE heading and nothing after it must not hang or crash.
|
||||
mkrepo "$ROOT/repo-f"
|
||||
{ echo "# STATE - repo-f"; printf '## %s NESTE %s START HER\n' "$HAND" "$EMDASH"; } > "$ROOT/repo-f/STATE.md"
|
||||
"$BOARD" --roots "$ROOT" >/dev/null 2>&1; check "empty NESTE block does not crash" $?
|
||||
|
||||
"$BOARD" --help >/dev/null 2>&1; check "--help exits 0" $?
|
||||
|
||||
# Unreadable root is a no-op, not a crash.
|
||||
"$BOARD" --roots "$ROOT/does-not-exist" >/dev/null 2>&1; check "missing root is a clean no-op" $?
|
||||
|
||||
echo ""
|
||||
echo "board-selftest: $PASS passed, $FAIL failed"
|
||||
[ "$FAIL" -eq 0 ] || exit 1
|
||||
exit 0
|
||||
222
scripts/board.sh
Executable file
222
scripts/board.sh
Executable file
|
|
@ -0,0 +1,222 @@
|
|||
#!/bin/bash
|
||||
# board.sh - cross-repo attention board. Answers the one question no single
|
||||
# repo's STATE.md can: across ALL repos, which have a live next step, which are
|
||||
# blocked and on whom, which owe someone a reply, and what each costs to
|
||||
# advance. Read-only: never writes to a repo, a STATE.md, or the mailbox.
|
||||
#
|
||||
# Sources (all pre-existing, nothing invented):
|
||||
# STATE.md "NESTE" block - the next step, per repo (canonical)
|
||||
# STATE.md board line - optional machine-readable field (see below)
|
||||
# git status --porcelain - uncommitted risk
|
||||
# ~/.claude/coord/<repo>/inbox - UNHANDLED INBOUND: others addressed this
|
||||
# repo and it has not processed them. This is an
|
||||
# obligation the repo owes outward - NOT evidence
|
||||
# that the repo is waiting on anyone. The mailbox
|
||||
# format has no reply-to/thread field, so
|
||||
# outbound waiting is not derivable from it at
|
||||
# all; that is exactly what blocked-on carries.
|
||||
#
|
||||
# Board line (optional, one per STATE.md, directly under the NESTE heading):
|
||||
#
|
||||
# <!-- board: status=in-progress; blocked-on=-; next-cost=sonnet/xhigh -->
|
||||
#
|
||||
# status planned | in-progress | blocked | deferred | done
|
||||
# blocked-on <repo-name> or - (only meaningful with status=blocked)
|
||||
# next-cost <model>/<effort> from the global model rubric, e.g. opus/xhigh
|
||||
#
|
||||
# ATTENTION AXIS, NOT THE REGISTER AXIS. This status vocabulary is deliberately
|
||||
# NOT the 7-token vocabulary in ~/.claude/coord/register.md. That one describes
|
||||
# a TOPIC's status inside a repo (has this repo adopted OKF?); this one
|
||||
# describes a REPO's own next step (does this need me?). `not-applicable` is
|
||||
# meaningless here and `partial` carries register Regel 1 (eier + neste-steg),
|
||||
# which does not transfer. Conflating the two axes is the AS#5 defect class
|
||||
# register.md was written to close - keep them separate.
|
||||
#
|
||||
# Usage: board.sh [--roots <dir>[,<dir>...]] [--plain]
|
||||
# Env: CLAUDE_COORD_DIR overrides the mailbox root.
|
||||
# BOARD_ROOTS overrides the default scan roots.
|
||||
# ASCII only, bash 3.2 safe.
|
||||
set -u
|
||||
export LC_ALL=C
|
||||
|
||||
COORD="${CLAUDE_COORD_DIR:-$HOME/.claude/coord}"
|
||||
ROOTS="${BOARD_ROOTS:-$HOME/repos}"
|
||||
NESTE_WIDTH=38
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
# bash 3.2: `shift 2` past the end of $# is a no-op -> would loop forever.
|
||||
--roots) [ $# -ge 2 ] || { echo "board: --roots requires a value" >&2; exit 2; }
|
||||
ROOTS="$2"; shift 2 ;;
|
||||
--plain) shift ;;
|
||||
-h|--help) grep '^#' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
||||
*) echo "board: unknown argument: $1 (ignored)" >&2; shift ;;
|
||||
esac
|
||||
done
|
||||
|
||||
NOW="$(date +%s)"
|
||||
|
||||
# Truncate to N CHARACTERS (not bytes). A byte cut splits multibyte prose and
|
||||
# emits mojibake; macOS `cut -c` is character-aware under a UTF-8 locale.
|
||||
trunc() { printf '%s' "$1" | LC_ALL=en_US.UTF-8 cut -c1-"$2"; }
|
||||
|
||||
# --- Discovery: git repos at depth 1, plus depth 2 under polyrepo dirs ------
|
||||
# A directory that is itself a git repo is one repo; a directory that is not
|
||||
# but contains git repos is a polyrepo container (the plugin marketplace) and
|
||||
# contributes its children, never itself.
|
||||
#
|
||||
# "Is a repo" tests .git with -e, not -d: a worktree or submodule has .git as a
|
||||
# FILE. Measured 2026-07-26: STATE.md is tracked in 7 of 25 repos, so a plain
|
||||
# `git worktree add ~/repos/feature-x` lands a depth-1 sibling that CARRIES a
|
||||
# STATE.md - a -d test drops it silently. Kept identical in the rollup builder
|
||||
# (catalog) on purpose: two readers, one name.
|
||||
REPOS=""
|
||||
# Split on comma via IFS + `set --` rather than an unquoted $(...) expansion:
|
||||
# unquoted word-splitting would also split roots containing spaces. Arg parsing
|
||||
# is finished above, so clobbering the positional parameters is safe here.
|
||||
OLD_IFS="$IFS"; IFS=','
|
||||
set -- $ROOTS
|
||||
IFS="$OLD_IFS"
|
||||
for root in "$@"; do
|
||||
[ -d "$root" ] || continue
|
||||
for entry in "$root"/*; do
|
||||
[ -d "$entry" ] || continue
|
||||
if [ -e "$entry/.git" ]; then
|
||||
REPOS="$REPOS
|
||||
$entry"
|
||||
else
|
||||
for child in "$entry"/*; do
|
||||
[ -e "$child/.git" ] || continue
|
||||
REPOS="$REPOS
|
||||
$child"
|
||||
done
|
||||
fi
|
||||
done
|
||||
done
|
||||
|
||||
[ -n "$(printf '%s' "$REPOS" | tr -d '[:space:]')" ] || exit 0
|
||||
|
||||
# --- Collect one record per repo -------------------------------------------
|
||||
# Record: bucket|sortkey|name|status|cost|inbox|dirty|age|neste
|
||||
RECORDS=""
|
||||
MALFORMED=""
|
||||
|
||||
printf '%s\n' "$REPOS" | while IFS= read -r d; do
|
||||
[ -n "$d" ] || continue
|
||||
name="$(basename "$d")"
|
||||
state="$d/STATE.md"
|
||||
|
||||
dirty="$(git -C "$d" status --porcelain 2>/dev/null | wc -l | tr -d ' ')"
|
||||
[ -n "$dirty" ] || dirty=0
|
||||
|
||||
inbox=0
|
||||
if [ -d "$COORD/$name/inbox" ]; then
|
||||
inbox="$(ls "$COORD/$name/inbox"/*.md 2>/dev/null | wc -l | tr -d ' ')"
|
||||
[ -n "$inbox" ] || inbox=0
|
||||
fi
|
||||
|
||||
if [ ! -f "$state" ]; then
|
||||
last="$(git -C "$d" log -1 --format=%ct 2>/dev/null)"
|
||||
if [ -n "$last" ]; then age=$(( (NOW - last) / 86400 )); else age=-1; fi
|
||||
printf '5|%06d|%s|-|-|%s|%s|%sd|(ingen STATE.md)\n' \
|
||||
"$age" "$name" "$inbox" "$dirty" "$age"
|
||||
continue
|
||||
fi
|
||||
|
||||
mtime="$(stat -f %m "$state" 2>/dev/null)"
|
||||
if [ -n "$mtime" ]; then age=$(( (NOW - mtime) / 86400 )); else age=0; fi
|
||||
|
||||
# Anchored to the exact comment form, NOT a substring search: unanchored
|
||||
# 'board:' also matches prose like "dashboard: ..." and -m1 would let a
|
||||
# lookalike higher up the file win over the real line.
|
||||
line="$(grep -m1 '^<!-- board:' "$state" 2>/dev/null)"
|
||||
status=""; blockedon=""; cost=""
|
||||
if [ -n "$line" ]; then
|
||||
status="$(printf '%s' "$line" | sed -n 's/.*status=\([a-z-]*\).*/\1/p')"
|
||||
blockedon="$(printf '%s' "$line" | sed -n 's/.*blocked-on=\([A-Za-z0-9._-]*\).*/\1/p')"
|
||||
# Value runs to the next ';' or the closing '-->', NOT to the first
|
||||
# non-lowercase byte: the rubric names models "Sonnet 5 / xhigh", so a
|
||||
# lowercase-only class silently drops spec-conformant values to "?".
|
||||
cost="$(printf '%s' "$line" | sed -n 's/.*next-cost=\([^;>]*\).*/\1/p' \
|
||||
| sed -e 's/--$//' -e 's/[[:space:]]*$//' -e 's/^[[:space:]]*//')"
|
||||
fi
|
||||
|
||||
case "$status" in
|
||||
planned|in-progress|blocked|deferred|done) ;;
|
||||
"") status="?" ;;
|
||||
*) status="MALFORMED:$status" ;;
|
||||
esac
|
||||
[ -n "$cost" ] || cost="?"
|
||||
|
||||
# First content line under the NESTE heading: skip blanks, HTML comments and
|
||||
# the heading itself; strip markdown bold/bullet noise.
|
||||
neste="$(awk '
|
||||
/NESTE/ { flag=1; next }
|
||||
flag {
|
||||
if ($0 ~ /^[[:space:]]*$/) next
|
||||
if ($0 ~ /^[[:space:]]*<!--/) next
|
||||
if ($0 ~ /^#/) next
|
||||
print; exit
|
||||
}' "$state" 2>/dev/null \
|
||||
| sed -e 's/^[[:space:]]*>[[:space:]]*//' -e 's/\*\*//g' \
|
||||
-e 's/^[[:space:]]*[-*][[:space:]]*//' -e 's/^[[:space:]]*//' -e 's/`//g')"
|
||||
[ -n "$neste" ] || neste="(tom NESTE-blokk)"
|
||||
neste="$(trunc "$neste" "$NESTE_WIDTH")"
|
||||
|
||||
disp="$status"
|
||||
if [ "$status" = "blocked" ] && [ -n "$blockedon" ] && [ "$blockedon" != "-" ]; then
|
||||
disp="blocked>$blockedon"
|
||||
fi
|
||||
|
||||
case "$status" in
|
||||
blocked) bucket=1 ;;
|
||||
MALFORMED:*) bucket=2 ;;
|
||||
in-progress|planned|"?") bucket=2 ;;
|
||||
deferred) bucket=3 ;;
|
||||
done) bucket=4 ;;
|
||||
*) bucket=2 ;;
|
||||
esac
|
||||
|
||||
printf '%s|%06d|%s|%s|%s|%s|%s|%sd|%s\n' \
|
||||
"$bucket" "$age" "$name" "$disp" "$cost" "$inbox" "$dirty" "$age" "$neste"
|
||||
done > "${TMPDIR:-/tmp}/board.$$"
|
||||
|
||||
RECORDS="${TMPDIR:-/tmp}/board.$$"
|
||||
trap '/bin/rm -f "$RECORDS" 2>/dev/null' EXIT
|
||||
|
||||
hdr() {
|
||||
printf '\n%s\n' "$1"
|
||||
printf '%-32s %-34s %-14s %4s %4s %6s %s\n' \
|
||||
"REPO" "STATUS" "KOST" "INN" "DRT" "ALDER" "NESTE"
|
||||
}
|
||||
|
||||
rows() {
|
||||
awk -F'|' -v b="$1" '$1==b' "$RECORDS" | sort -t'|' -k2,2n | \
|
||||
while IFS='|' read -r bucket sortkey name status cost inbox dirty age neste; do
|
||||
printf '%-32s %-34s %-14s %4s %4s %6s %s\n' \
|
||||
"$name" "$status" "$cost" "$inbox" "$dirty" "$age" "$neste"
|
||||
done
|
||||
}
|
||||
|
||||
count() { awk -F'|' -v b="$1" '$1==b' "$RECORDS" | wc -l | tr -d ' '; }
|
||||
|
||||
echo "BOARD - tverr-repo oppmerksomhetstavle ($(awk 'END{print NR}' "$RECORDS") repo)"
|
||||
echo "INN = uhaandtert innboks (andre venter paa DEG). DRT = ukommiterte filer."
|
||||
|
||||
[ "$(count 1)" -gt 0 ] && { hdr "BLOKKERT (venter paa ekstern avhengighet)"; rows 1; }
|
||||
[ "$(count 2)" -gt 0 ] && { hdr "AKTIV (reelt neste steg)"; rows 2; }
|
||||
[ "$(count 3)" -gt 0 ] && { hdr "UTSATT (deferred - bevisst valg, ikke venting)"; rows 3; }
|
||||
[ "$(count 4)" -gt 0 ] && { hdr "FERDIG"; rows 4; }
|
||||
[ "$(count 5)" -gt 0 ] && { hdr "UTEN STATE.md (sovende / ubestemt tilstand)"; rows 5; }
|
||||
|
||||
# Obligations and risk read across buckets, so they get their own roll-up.
|
||||
tot_in="$(awk -F'|' '{s+=$6} END{print s+0}' "$RECORDS")"
|
||||
tot_dirty="$(awk -F'|' '{s+=$7} END{print s+0}' "$RECORDS")"
|
||||
n_mal="$(grep -c 'MALFORMED' "$RECORDS" 2>/dev/null | tr -d ' ')"
|
||||
n_nofield="$(awk -F'|' '$4=="?"' "$RECORDS" | wc -l | tr -d ' ')"
|
||||
|
||||
printf '\nSUM: %s uhaandterte innboks-meldinger, %s ukommiterte filer.\n' "$tot_in" "$tot_dirty"
|
||||
[ "${n_mal:-0}" -gt 0 ] && printf 'ADVARSEL: %s repo har MALFORMED status-token (utenfor det lukkede settet).\n' "$n_mal"
|
||||
[ "${n_nofield:-0}" -gt 0 ] && printf 'MERK: %s repo mangler board-linje - status/kost er ukjent (?), NESTE-utdrag brukes.\n' "$n_nofield"
|
||||
|
||||
exit 0
|
||||
Loading…
Add table
Add a link
Reference in a new issue