The marker reader accepted **slug**: done and missed slug: **done**. That was never a rule - it was whichever example happened to be in front of us when the regex was written. The second form is in live use, and it makes a repo that HAS declared look silent to --focus, which is the exact failure the held-back report exists to surface. Found by acting on the report's own output instead of reading it: enumerating the held-back population turned up a repo whose declaration we were dropping ourselves. The report blamed the repo; the defect was here. The evidence field reports the status unwrapped - the emphasis is markdown the operator typed, not part of the token, and "**done**" in a key=value field reads as a value. board-selftest 114 -> 116. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0186vKCzuUEN5WcJB82kddzF
704 lines
33 KiB
Bash
Executable file
704 lines
33 KiB
Bash
Executable file
#!/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
|
|
# git log -1 --format=%ct - when anything last landed (the SISTE column)
|
|
# ~/.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 5/xhigh -->
|
|
#
|
|
# status planned | in-progress | blocked | deferred | done
|
|
# blocked-on <repo-name> or - (only meaningful with status=blocked)
|
|
# next-cost <model>/<effort>, the model spelled EXACTLY as the global rubric
|
|
# spells it: Sonnet 5/xhigh, Opus 5/high. The parser below accepts
|
|
# any spelling, but this field is compared across repos by eye, so
|
|
# one form is the whole point - and a second spelling documented
|
|
# here is how a field with no write path drifts.
|
|
# The field now HAS a write path: route.sh emits it, and the set of
|
|
# legal values is that script's row table - not this comment, which
|
|
# shows the form only. `route.sh --help` is the authority; the two
|
|
# ends are pinned together by route-selftest.sh section 6.
|
|
#
|
|
# TWO AGE COLUMNS, ONE MEANING EACH. ALDER is the STATE.md mtime - when the
|
|
# plan was last touched - and is blank for a repo that has none. SISTE is the
|
|
# last commit, read for every repo. They were one column once, and it meant
|
|
# whichever of the two the repo's branch happened to compute: a repo WITH a
|
|
# STATE.md showed only its plan's age, so one that had not committed in a year
|
|
# was indistinguishable from one worked on this morning. Both are evidence for
|
|
# the reader and neither is a ranking input - the buckets and the sort are
|
|
# unchanged by this column.
|
|
#
|
|
# ATTENTION AXIS, NOT A TOPIC AXIS. This status vocabulary is deliberately NOT
|
|
# the vocabulary a cross-repo TOPIC register uses. A topic register answers
|
|
# "what is this repo's status on subject X (has it adopted convention Y?)";
|
|
# this line answers "does this REPO's own next step need me?". Topic tokens do
|
|
# not transfer: `not-applicable` is meaningless about a repo's next step, and a
|
|
# topic-level `partial` carries an ownership-and-next-step rule that belongs to
|
|
# the register, not here. Conflating the two axes is a real defect class - the
|
|
# board reads only its own axis, so keep them separate.
|
|
#
|
|
# --brief is a SECOND RENDERING of the same scan, never a second scan. The
|
|
# table answers "what is the state of every repo"; the briefing answers the
|
|
# narrower question an unattended nightly job can answer without judgement:
|
|
# which repos have an unhandled inbox, what their next step says IN FULL, and
|
|
# the exact command to start a session there. The 38-char cut is the table
|
|
# column's property, not the record's, so the briefing prints NESTE uncut. Each
|
|
# command is derived by CALLING route.sh with that repo's own four traits -
|
|
# next-cost alone cannot yield it, since the advisor flag is a property of the
|
|
# ROW. A repo with no route line is told so rather than handed a guess.
|
|
#
|
|
# --brief is still read-only: it writes nothing. The file write lives in
|
|
# brief-nightly.sh, which renders to a temp file and renames it into place, and
|
|
# refuses to overwrite a good briefing with an empty render.
|
|
#
|
|
# --plan is a THIRD rendering of that same scan, and the only one that takes a
|
|
# position: it answers which repos to open a tab for today and in what order.
|
|
# It prints key=value blocks, not prose, because it has two consumers - the
|
|
# operator pasting commands, and a separate repo driving a terminal from it.
|
|
# Ordering is deterministic (debt, then in-progress, then planned, then repos
|
|
# with no declared status) and there is no cutoff, so nothing is hidden.
|
|
# Read-only like the rest: --plan writes nothing, in the repo or the mailbox.
|
|
#
|
|
# --focus "<prose>" narrows --plan to the repos whose STATE.md DECLARES a
|
|
# matching topic marker (`<slug>: <status>`), and is the only cutoff this
|
|
# format has. It is therefore required to report what it held back: the same
|
|
# run prints fokus= (the slugs the prose resolved to), fokus_droppet= (how
|
|
# many blocks the cutoff removed), fokus_utenfor= (the repos that MENTION a
|
|
# resolved slug with no marker line, named - that class is where the decisive
|
|
# find came from), and fokus_rekkevidde= (how many STATE.md were searched;
|
|
# board opens no other file). Each surviving block carries fokus_treff=, the
|
|
# declaration it survived on. Prose matching no declared slug prints the FULL
|
|
# plan plus fokus_ikke_brukt= - never an empty one, since the prose arrives
|
|
# verbatim from the operator and a typo must not empty the morning.
|
|
#
|
|
# Usage: board.sh [--roots <dir>[,<dir>...]] [--plain] [--brief|--plan]
|
|
# [--focus "<prose>"]
|
|
# 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
|
|
BRIEF=0
|
|
PLAN=0
|
|
FOCUS=""
|
|
|
|
# Sibling calculator, invoked rather than reimplemented: the rubric that turns
|
|
# four traits into a model has exactly one copy, and it is route.sh's row
|
|
# table. Bare form on purpose - a ${VAR:-fallback} here is the 0.12.1 defect.
|
|
SELFDIR="$(cd "$(dirname "$0")" && pwd)"
|
|
ROUTE="$SELFDIR/route.sh"
|
|
|
|
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 ;;
|
|
# Three renderings of one scan, so exactly one may be selected: last wins.
|
|
--brief) BRIEF=1; PLAN=0; shift ;;
|
|
--plan) PLAN=1; BRIEF=0; shift ;;
|
|
# Raw operator prose, forwarded verbatim by the driver: it does not
|
|
# tokenize, match or normalize, so every bit of that work is here. Same
|
|
# `shift 2` guard as --roots, for the same bash 3.2 reason.
|
|
--focus) [ $# -ge 2 ] || { echo "board: --focus requires a value" >&2; exit 2; }
|
|
FOCUS="$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. A plain `git worktree add <root>/feature-x` lands a depth-1 sibling that
|
|
# can CARRY its own 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
|
|
|
|
# Read for EVERY repo, not just the STATE-less ones: a repo whose plan file
|
|
# is fresh can still have been silent for a year, and that is precisely the
|
|
# repo no other column reports. A repo with no commits at all has no reading
|
|
# to give - printing a day count there would be a fabricated one.
|
|
lastct="$(git -C "$d" log -1 --format=%ct 2>/dev/null)"
|
|
if [ -n "$lastct" ]; then
|
|
lastd=$(( (NOW - lastct) / 86400 )); lastcol="${lastd}d"
|
|
else
|
|
lastd=-1; lastcol="-"
|
|
fi
|
|
|
|
if [ ! -f "$state" ]; then
|
|
# No plan file, so no plan age: ALDER is blank rather than quietly showing
|
|
# the commit age under a heading that means something else everywhere else
|
|
# in the table. The sort key keeps using it - order is unchanged.
|
|
printf '5|%06d|%s|-|-|%s|%s|-|%s|%s|(ingen STATE.md)\n' \
|
|
"$lastd" "$name" "$inbox" "$dirty" "$lastcol" "$d"
|
|
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')"
|
|
# Stored WHOLE. Truncation is a property of the table's 38-char column, so it
|
|
# belongs to that renderer alone - the briefing is a second rendering of this
|
|
# same record and exists precisely to carry the line uncut. Cutting here once
|
|
# meant the only copy of the text was the cut one.
|
|
[ -n "$neste" ] || neste="(tom NESTE-blokk)"
|
|
|
|
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|%s|%s\n' \
|
|
"$bucket" "$age" "$name" "$disp" "$cost" "$inbox" "$dirty" "$age" "$lastcol" "$d" "$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 %6s %s\n' \
|
|
"REPO" "STATUS" "KOST" "INN" "DRT" "ALDER" "SISTE" "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 last dir neste; do
|
|
printf '%-32s %-34s %-14s %4s %4s %6s %6s %s\n' \
|
|
"$name" "$status" "$cost" "$inbox" "$dirty" "$age" "$last" "$(trunc "$neste" "$NESTE_WIDTH")"
|
|
done
|
|
}
|
|
|
|
# --- Briefing rendering (--brief) ------------------------------------------
|
|
# The startup command for one repo, derived from that repo's OWN route line by
|
|
# calling route.sh. Deriving it from next-cost instead would not work even in
|
|
# principle: the advisor flag is a property of the ROW, and two rows can share
|
|
# a model/effort pair while differing on it. A repo with no route line gets its
|
|
# next-cost printed and is told where the command comes from - a guessed
|
|
# command would read as authoritative while being a guess, which is worse than
|
|
# no command at all.
|
|
# Shared by --brief and --plan, because there is one route line grammar and it
|
|
# gets one reader. Two no-command cases, and callers must keep them apart:
|
|
# exit 1 - no route line at all
|
|
# exit 0, empty out - a route line route.sh rejects (a typo'd trait value)
|
|
# Neither may become a guessed command, and neither may become a bare command
|
|
# marker: a driver reading `command=` would type an empty line into a live pane.
|
|
route_cmd_for() {
|
|
rc_line="$(grep -m1 '^<!-- route:' "$1/STATE.md" 2>/dev/null)"
|
|
[ -n "$rc_line" ] || return 1
|
|
rc_p="$(printf '%s' "$rc_line" | sed -n 's/.*path=\([a-z-]*\).*/\1/p')"
|
|
rc_v="$(printf '%s' "$rc_line" | sed -n 's/.*verification=\([a-z-]*\).*/\1/p')"
|
|
rc_r="$(printf '%s' "$rc_line" | sed -n 's/.*reversibility=\([a-z-]*\).*/\1/p')"
|
|
rc_s="$(printf '%s' "$rc_line" | sed -n 's/.*scope=\([a-z-]*\).*/\1/p')"
|
|
bash "$ROUTE" --path "$rc_p" --verification "$rc_v" \
|
|
--reversibility "$rc_r" --scope "$rc_s" --rationale brief 2>/dev/null \
|
|
| sed -n 's/^command=//p'
|
|
return 0
|
|
}
|
|
|
|
brief_cmd() {
|
|
if bc_cmd="$(route_cmd_for "$1")"; then
|
|
if [ -n "$bc_cmd" ]; then
|
|
printf '$ %s' "$bc_cmd"
|
|
else
|
|
printf 'KOST: %s (route-linjen kunne ikke tolkes)' "$2"
|
|
fi
|
|
else
|
|
printf 'KOST: %s (ingen route-linje - kjor route-skillen i det repoet)' "$2"
|
|
fi
|
|
}
|
|
|
|
# The repo scan and the mailbox are two different populations, and the gap
|
|
# between them is silent by default. board.sh discovers git REPOS; a mailbox
|
|
# can carry a name no scan will ever produce - a declared non-git surface
|
|
# (CLAUDE_COORD_REPO, e.g. ~/repos itself) or a checkout outside the roots.
|
|
# Such a mailbox is invisible in every column this script prints, so a briefing
|
|
# that only walks the scan answers "who is waiting on you" with a number it
|
|
# quietly knows is short. Measured on the real mailbox: 11 repos / 21 messages
|
|
# against coord-count's 12 mailboxes / 22 pending, the missing one being the
|
|
# declared surface `repos`.
|
|
#
|
|
# coord-count.sh is the right source and the only safe one: it counts without
|
|
# delivering, where coord-inbox.sh would mark broadcasts seen just by looking.
|
|
brief_orphans() {
|
|
bo_count="$SELFDIR/coord-count.sh"
|
|
[ -f "$bo_count" ] || return 0
|
|
bo_tab="$(printf '\t')"
|
|
bo_out="$(bash "$bo_count" 2>/dev/null \
|
|
| awk -F"$bo_tab" '$2+0>0 {print $1"'"$bo_tab"'"$2}' \
|
|
| while IFS="$bo_tab" read -r bo_name bo_n; do
|
|
[ -n "$bo_name" ] || continue
|
|
awk -F'|' -v n="$bo_name" '$3==n {f=1} END{exit !f}' "$RECORDS" \
|
|
|| printf ' %-32s INN %s\n' "$bo_name" "$bo_n"
|
|
done)"
|
|
[ -n "$bo_out" ] || return 0
|
|
echo ""
|
|
echo "UTENFOR REPO-SKANNEN - postkasser uten et repo i treet:"
|
|
printf '%s\n' "$bo_out"
|
|
echo "Disse har ingen STATE.md og derfor intet neste steg. En deklarert flate"
|
|
echo "(CLAUDE_COORD_REPO) eller et checkout utenfor scan-roten."
|
|
}
|
|
|
|
brief() {
|
|
n_owe="$(awk -F'|' '$6+0 > 0' "$RECORDS" | wc -l | tr -d ' ')"
|
|
tot_msg="$(awk -F'|' '$6+0 > 0 {s+=$6} END{print s+0}' "$RECORDS")"
|
|
|
|
echo "BRIEFING $(date '+%Y-%m-%d %H:%M') - repo som skylder et svar"
|
|
echo "Kilder: STATE.md (NESTE + route-linje), git, coord-innboks. 0 modellkall."
|
|
echo ""
|
|
|
|
if [ "${n_owe:-0}" -eq 0 ]; then
|
|
echo "Ingen repo har uhaandtert innboks. Ingen skylder noen et svar i dag."
|
|
# Still checked: "no repo owes" and "no mailbox is pending" are different
|
|
# claims, and only the second one is the good news it reads as.
|
|
brief_orphans
|
|
echo ""
|
|
echo "MERK: INN teller hva ANDRE venter paa fra deg. Hva et repo venter PAA"
|
|
echo "staar kun i dets egen board-linje (blocked-on) - postkassen har ikke"
|
|
echo "noe reply-to-felt, saa utgaaende venting er ikke utledbar derfra."
|
|
return 0
|
|
fi
|
|
|
|
# Most-owed first: the repo holding up the most other sessions is read first.
|
|
awk -F'|' '$6+0 > 0' "$RECORDS" | sort -t'|' -k6,6nr | \
|
|
while IFS='|' read -r bucket sortkey name status cost inbox dirty age last dir neste; do
|
|
printf ' %-32s INN %-4s %s\n' "$name" "$inbox" "$status"
|
|
# Wrapped, not cut - the whole line is the point, but a 500-character one
|
|
# is unreadable in a file nobody watched being written. Locale is set for
|
|
# the same reason trunc sets it: under LC_ALL=C fold counts BYTES and can
|
|
# split a multibyte character into mojibake.
|
|
# Trailing newline via '%s\n': fold copies its input's lack of one, and the
|
|
# command line below would then start on the tail of the NESTE text.
|
|
printf '%s\n' "$neste" | LC_ALL=en_US.UTF-8 fold -s -w 84 \
|
|
| sed -e '1s/^/ NESTE: /' -e '2,$s/^/ /'
|
|
printf ' %s\n\n' "$(brief_cmd "$dir" "$cost")"
|
|
done
|
|
|
|
printf '%s repo skylder svar, %s meldinger totalt.\n' "$n_owe" "$tot_msg"
|
|
brief_orphans
|
|
echo ""
|
|
echo "MERK: INN teller hva ANDRE venter paa fra deg. Hva et repo venter PAA"
|
|
echo "staar kun i dets egen board-linje (blocked-on) - postkassen har ikke"
|
|
echo "noe reply-to-felt, saa utgaaende venting er ikke utledbar derfra."
|
|
}
|
|
|
|
if [ "$BRIEF" -eq 1 ]; then
|
|
brief
|
|
exit 0
|
|
fi
|
|
|
|
# --- Focus resolution (--focus) --------------------------------------------
|
|
# --focus is the first CUTOFF this format has ever had, and the plan documents
|
|
# at length that it has none: it takes one position (the order), it hides
|
|
# nothing, and it LABELS what it cannot rank rather than dropping it. A filter
|
|
# that stayed silent about what fell outside it would break that property
|
|
# outright, so the report is not a refinement of the feature - it is the
|
|
# condition the feature was allowed to exist under.
|
|
#
|
|
# The topic marker grammar belongs to the register, not here. board.sh only
|
|
# ever READS a declaration, and reads it from STATE.md alone: the slug
|
|
# vocabulary is whatever the scanned STATE.md files themselves declare, so no
|
|
# new file is opened and the "STATE.md and nothing else" invariant survives.
|
|
# It also means the reader must accept the marker as operators actually write
|
|
# it - bold, backticked, bulleted - because the strict form is what the
|
|
# register's own grep looks for, and the single most consequential repo in the
|
|
# measurement behind this feature was invisible to exactly that grep.
|
|
FOCUS_STATUS='planned|in-progress|partial|blocked|deferred|done|not-applicable'
|
|
|
|
# The emphasis is optional on BOTH halves. Accepting `**slug**: done` but not
|
|
# `slug: **done**` is not a rule, only whichever example happened to be in front
|
|
# of us - and the second form is in live use, where it makes a repo that HAS
|
|
# declared look silent.
|
|
focus_marker_re() {
|
|
printf '^[[:space:]]*[-*]?[[:space:]]*\**`?%s`?\**:[[:space:]]+\**(%s)\**([[:space:]]|$)' \
|
|
"$1" "$FOCUS_STATUS"
|
|
}
|
|
|
|
# Every slug declared anywhere in the scanned tree. Field 10 of RECORDS is the
|
|
# repo directory; a repo with no STATE.md simply contributes nothing.
|
|
focus_slugs() {
|
|
awk -F'|' '{print $10}' "$RECORDS" | while read -r fs_d; do
|
|
[ -n "$fs_d" ] && [ -f "$fs_d/STATE.md" ] || continue
|
|
grep -hE "$(focus_marker_re '[a-z0-9][a-z0-9-]*')" "$fs_d/STATE.md" 2>/dev/null
|
|
done | sed -E 's/^[[:space:]]*[-*]?[[:space:]]*\**`?([a-z0-9][a-z0-9-]*)`?\**:.*/\1/' \
|
|
| sort -u
|
|
}
|
|
|
|
# Prose -> slugs. Two normalisations, because both readings occur: the operator
|
|
# types a slug ("some-subject-guard") or a bare word out of their own day
|
|
# ("guard"). Matching is never narrowed to one winner - an ambiguous phrase
|
|
# widens the answer and every slug it resolved to is named in the output,
|
|
# because silently picking one would make the cutoff lie about its own size.
|
|
focus_resolve() {
|
|
fr_words=" $(printf '%s' "$1" | tr '[:upper:]' '[:lower:]' | tr -c 'a-z0-9-' ' ') "
|
|
fr_parts=" $(printf '%s' "$fr_words" | tr '-' ' ') "
|
|
focus_slugs | while read -r fr_s; do
|
|
[ -n "$fr_s" ] || continue
|
|
case "$fr_words" in
|
|
*" $fr_s "*) printf '%s\n' "$fr_s"; continue ;;
|
|
esac
|
|
for fr_p in $(printf '%s' "$fr_s" | tr '-' ' '); do
|
|
# Two-character parts match far too much prose to be evidence of intent.
|
|
[ "${#fr_p}" -ge 3 ] || continue
|
|
case "$fr_parts" in
|
|
*" $fr_p "*) printf '%s\n' "$fr_s"; break ;;
|
|
esac
|
|
done
|
|
done | sort -u
|
|
}
|
|
|
|
# Does this repo DECLARE any of the resolved slugs? (survives the cutoff)
|
|
focus_declares() {
|
|
for fd_s in $FOCUS_SLUGS; do
|
|
grep -qE "$(focus_marker_re "$fd_s")" "$1/STATE.md" 2>/dev/null && return 0
|
|
done
|
|
return 1
|
|
}
|
|
|
|
# What a surviving block survived ON. This is the per-block evidence that
|
|
# closed `topics=`: the need was real, but a field in all 27 blocks on every
|
|
# day the operator has no focus is noise, while the same fact inside a focused
|
|
# run is the reason the block is there.
|
|
focus_evidence() {
|
|
for fe_s in $FOCUS_SLUGS; do
|
|
fe_line="$(grep -m1 -E "$(focus_marker_re "$fe_s")" "$1/STATE.md" 2>/dev/null)"
|
|
if [ -n "$fe_line" ]; then
|
|
# Reported unwrapped: the emphasis is markdown the operator typed, not
|
|
# part of the token, and "**done**" in a key=value field reads as a value.
|
|
fe_st="$(printf '%s' "$fe_line" | sed -E "s/.*:[[:space:]]+\**($FOCUS_STATUS)\**.*/\1/")"
|
|
printf '%s: %s\n' "$fe_s" "$fe_st"
|
|
return 0
|
|
fi
|
|
done
|
|
return 1
|
|
}
|
|
|
|
# The held-back population: STATE.md MENTIONS a resolved slug and declares no
|
|
# marker line for it. The wording is a constraint, not a style choice - this
|
|
# is a fact about text found in a file, and board.sh has no grounds whatever
|
|
# for a claim about relevance, so it says "nevner" and never "dekker". The
|
|
# class is NAMED rather than counted, because in the measurement that produced
|
|
# this feature the decisive find - a heavy consumer pinning the library in its
|
|
# build file - appeared only once the population was enumerated. Reasoning
|
|
# about it had missed it entirely.
|
|
focus_heldback() {
|
|
awk -F'|' '{print $3 "|" $10}' "$RECORDS" | while IFS='|' read -r fh_n fh_d; do
|
|
[ -n "$fh_d" ] && [ -f "$fh_d/STATE.md" ] || continue
|
|
focus_declares "$fh_d" && continue
|
|
for fh_s in $FOCUS_SLUGS; do
|
|
if grep -qF -- "$fh_s" "$fh_d/STATE.md" 2>/dev/null; then
|
|
printf '%s\n' "$fh_n"
|
|
break
|
|
fi
|
|
done
|
|
done
|
|
}
|
|
|
|
# --- Day-plan rendering (--plan) -------------------------------------------
|
|
# A THIRD rendering of the same scan, built on exactly the argument --brief was:
|
|
# it is a lookup over data the scan already holds, it costs zero model calls,
|
|
# and route.sh already derives the per-repo command. The table says what the
|
|
# state of every repo is; the briefing says who is waiting; the plan says which
|
|
# repos to open a tab for today, in what order, with which command.
|
|
#
|
|
# key=value blocks, not prose, because the plan has TWO consumers: the operator
|
|
# pasting commands, and a separate repo driving a terminal from it. Prose would
|
|
# make the rendered FORMAT an API, and no test in this repo could hold it stable
|
|
# for a consumer living in another one. Comment lines all start with '#', so a
|
|
# consumer drops them with one rule.
|
|
#
|
|
# ORDER IS THE POSITION THIS RENDERING TAKES, and it is the only one it takes -
|
|
# there is no cutoff, so nothing is hidden. Four rules, all deterministic over
|
|
# fields the scan already read:
|
|
# 1. INN > 0, most-owed first, WHATEVER the status. A message owed is an
|
|
# obligation to another session. Excluding `blocked` is about a repo's own
|
|
# next step, which by definition cannot be moved; answering is a different
|
|
# axis, and is frequently what unblocks it.
|
|
# 2. in-progress - live work, oldest plan first.
|
|
# 3. planned.
|
|
# 4. '?' and MALFORMED - no declared status. Planned LAST, and labelled. The
|
|
# table already prints a MERK line about repos with no board line; a plan
|
|
# that dropped them silently would repeat exactly that defect.
|
|
# Excluded: done, deferred, blocked-without-debt, and repos with no STATE.md and
|
|
# no debt - a tab that cannot be moved is not a plan entry.
|
|
# Two lines for two consumers, and they are not redundant. A driver cd's the
|
|
# pane itself and then types the command, so it needs them apart; the operator
|
|
# needs ONE thing to select and paste, because assembling `cd <dir>` from one
|
|
# field and the command from another is precisely where a tab ends up started
|
|
# in the wrong repo. paste= is emitted only alongside command= - `paste=cd X && `
|
|
# with nothing after it would run the cd and then a bare newline, leaving the
|
|
# operator in the right directory with no session and no error.
|
|
plan_cmd() {
|
|
if pc_cmd="$(route_cmd_for "$1")"; then
|
|
if [ -n "$pc_cmd" ]; then
|
|
printf 'command=%s\n' "$pc_cmd"
|
|
printf 'paste=cd %s && %s\n' "$1" "$pc_cmd"
|
|
else
|
|
printf 'command_missing=route-linjen kunne ikke tolkes (kost: %s)\n' "$2"
|
|
fi
|
|
else
|
|
printf 'command_missing=ingen route-linje - kjor route-skillen der (kost: %s)\n' "$2"
|
|
fi
|
|
}
|
|
|
|
plan() {
|
|
# Groups are appended in rank order to one file, then numbered in a single
|
|
# loop: `while ... < file` keeps the counter in THIS shell, where a pipe into
|
|
# while would run it in a subshell and reset every tab number to 1.
|
|
#
|
|
# Each group prefixes its `why` as a new FIRST field, so the sort keys shift
|
|
# by one: inbox 6->7, sortkey (ALDER) 2->3. Prefixing rather than appending is
|
|
# deliberate - `neste` is the last field and is free prose, so anything added
|
|
# after it could be swallowed by a stray separator in a STATE.md.
|
|
pf="${TMPDIR:-/tmp}/board-plan.$$"
|
|
: > "$pf"
|
|
awk -F'|' -v OFS='|' '$6+0 > 0 {print "inbox:" $6, $0}' "$RECORDS" \
|
|
| sort -t'|' -k7,7nr -k3,3n >> "$pf"
|
|
awk -F'|' -v OFS='|' '$6+0 == 0 && $4 == "in-progress" {print "in-progress", $0}' "$RECORDS" \
|
|
| sort -t'|' -k3,3n >> "$pf"
|
|
awk -F'|' -v OFS='|' '$6+0 == 0 && $4 == "planned" {print "planned", $0}' "$RECORDS" \
|
|
| sort -t'|' -k3,3n >> "$pf"
|
|
awk -F'|' -v OFS='|' '$6+0 == 0 && ($4 == "?" || $4 ~ /^MALFORMED:/) {print "uavklart", $0}' "$RECORDS" \
|
|
| sort -t'|' -k3,3n >> "$pf"
|
|
|
|
# The cutoff, and its disclosure, computed together - they are one feature.
|
|
FOCUS_SLUGS=""
|
|
fp_applied=0
|
|
fp_before="$(awk 'END{print NR+0}' "$pf")"
|
|
if [ -n "$FOCUS" ]; then
|
|
FOCUS_SLUGS="$(focus_resolve "$FOCUS" | tr '\n' ' ')"
|
|
[ -n "$(printf '%s' "$FOCUS_SLUGS" | tr -d ' ')" ] && fp_applied=1
|
|
fi
|
|
if [ "$fp_applied" -eq 1 ]; then
|
|
fp_names="${TMPDIR:-/tmp}/board-focusnames.$$"
|
|
fp_kept="${TMPDIR:-/tmp}/board-planfocus.$$"
|
|
awk -F'|' '{print $3 "|" $10}' "$RECORDS" | while IFS='|' read -r fp_n fp_d; do
|
|
[ -n "$fp_d" ] && [ -f "$fp_d/STATE.md" ] || continue
|
|
focus_declares "$fp_d" && printf '%s\n' "$fp_n"
|
|
done > "$fp_names"
|
|
awk -F'|' 'NR==FNR{keep[$0]=1;next} keep[$4]' "$fp_names" "$pf" > "$fp_kept"
|
|
/bin/rm -f "$fp_names" 2>/dev/null
|
|
mv "$fp_kept" "$pf"
|
|
fi
|
|
|
|
echo "# PLAN $(date '+%Y-%m-%d %H:%M') - en blokk per tab, i den rekkefolgen"
|
|
echo "# Kilder: STATE.md (NESTE + route-linje), git, coord-innboks. 0 modellkall."
|
|
echo "# Rekkefolge: innboksgjeld (INN desc), sa in-progress, sa planned, sa uavklart."
|
|
# "uten gjeld" governs the WHOLE list, not just the token nearest to it: a
|
|
# done or deferred repo that owes mail IS planned, and the real tree has two.
|
|
# Read the other way this line calls its own tab 4 a bug.
|
|
echo "# Utelatt naar repoet ikke skylder svar: done, deferred, blocked, uten STATE.md."
|
|
# Emitted as key=value, not as a '#' comment, because the format's second
|
|
# consumer drops every comment line by rule - a disclosure written as a
|
|
# comment would reach the operator on the terminal path and vanish on the
|
|
# driver path, which is the one case where the cutoff is applied unseen.
|
|
if [ -n "$FOCUS" ]; then
|
|
fp_state="$(awk -F'|' '{print $10}' "$RECORDS" | while read -r fp_sd; do
|
|
[ -n "$fp_sd" ] && [ -f "$fp_sd/STATE.md" ] && echo x
|
|
done | wc -l | tr -d ' ')"
|
|
if [ "$fp_applied" -eq 1 ]; then
|
|
printf 'fokus=%s\n' "$(printf '%s' "$FOCUS_SLUGS" | sed 's/[[:space:]]*$//' | tr ' ' ',')"
|
|
printf 'fokus_droppet=%s av %s blokker\n' \
|
|
"$((fp_before - $(awk 'END{print NR+0}' "$pf")))" "$fp_before"
|
|
fp_hb="$(focus_heldback | sort -u)"
|
|
fp_hbn="$(printf '%s' "$fp_hb" | grep -c . | tr -d ' ')"
|
|
# Nobody held back is an answer, and it has to LOOK like one. sed cannot
|
|
# supply the placeholder: with an empty string there is no input line for
|
|
# a substitution to run on, so the field would end at a bare colon - the
|
|
# exact shape command_missing= exists to keep out of this format.
|
|
fp_hbl="$(printf '%s' "$fp_hb" | tr '\n' ' ' | sed 's/[[:space:]]*$//')"
|
|
[ -n "$fp_hbl" ] || fp_hbl="(ingen)"
|
|
# "nevner", never "dekker": this states what was found in a file, and
|
|
# says which files were searched rather than implying it searched repos.
|
|
# One repo in the real measurement carries its strongest evidence in a
|
|
# README, which this scan never opens.
|
|
printf 'fokus_utenfor=%s repo nevner %s uten markorlinje: %s\n' \
|
|
"$fp_hbn" "$(printf '%s' "$FOCUS_SLUGS" | sed 's/[[:space:]]*$//' | tr ' ' ',')" \
|
|
"$fp_hbl"
|
|
printf 'fokus_rekkevidde=sokt i %s STATE.md - board leser ingen andre filer\n' "$fp_state"
|
|
else
|
|
# No declared slug matched. The full plan is printed: the driver forwards
|
|
# operator prose verbatim, so a typo must not silently produce a morning
|
|
# with no tabs at all.
|
|
printf 'fokus_ikke_brukt=%s traff ingen deklarert slug i %s STATE.md - hele planen vises\n' \
|
|
"$FOCUS" "$fp_state"
|
|
fi
|
|
fi
|
|
echo ""
|
|
|
|
pn=0
|
|
while IFS='|' read -r why bucket sortkey name status cost inbox dirty age last dir neste; do
|
|
[ -n "$name" ] || continue
|
|
pn=$((pn + 1))
|
|
printf 'tab=%s\n' "$pn"
|
|
printf 'repo=%s\n' "$name"
|
|
# Absolute, and the driver must cd into it explicitly: a new terminal pane
|
|
# inherits its anchor's working directory, so a plan that omitted this would
|
|
# look right and point at the wrong repo.
|
|
printf 'dir=%s\n' "$dir"
|
|
printf 'why=%s\n' "$why"
|
|
if [ "$fp_applied" -eq 1 ]; then
|
|
fp_ev="$(focus_evidence "$dir")" && printf 'fokus_treff=%s\n' "$fp_ev"
|
|
fi
|
|
printf 'status=%s\n' "$status"
|
|
printf 'neste=%s\n' "$neste"
|
|
plan_cmd "$dir" "$cost"
|
|
echo ""
|
|
done < "$pf"
|
|
/bin/rm -f "$pf" 2>/dev/null
|
|
|
|
printf '# %s tabber.\n' "$pn"
|
|
|
|
# An orphan mailbox has no directory to cd into, so it cannot BE a tab - but
|
|
# omitting it lets the plan claim a completeness it knows it lacks. Reported
|
|
# as commentary, reusing the briefing's single copy of that cross-check.
|
|
po="$(brief_orphans)"
|
|
if [ -n "$po" ]; then
|
|
printf '%s\n' "$po" | sed -e 's/^/# /' -e 's/^# *$/#/'
|
|
fi
|
|
echo "# MERK: INN teller hva ANDRE venter paa fra deg. Hva et repo venter PAA"
|
|
echo "# staar kun i dets egen board-linje (blocked-on)."
|
|
}
|
|
|
|
if [ "$PLAN" -eq 1 ]; then
|
|
plan
|
|
exit 0
|
|
fi
|
|
|
|
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."
|
|
echo "ALDER = dager siden STATE.md endret. SISTE = dager siden siste commit."
|
|
|
|
[ "$(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
|