feat(board): split the age column, so a silent repo cannot hide behind a fresh plan

ALDER carried two readings depending on which branch a repo fell into: STATE.md
mtime for repos that had one, last-commit age for repos that did not. The
consequence was one-sided -- a repo WITH a STATE.md reported only how recently
its plan was touched, so one that had not committed in a year looked exactly as
alive as one worked on this morning, and no column contradicted it.

Now each column means one thing. ALDER is the plan's age and is blank where
there is no plan; SISTE is the last commit, read for every repo, and blank where
there are no commits -- a day count there would be a fabricated reading, which
is what the old -1d was.

The four ranking rules stay four: the new column is evidence, never sort input.
The sort key was already a separate field from the displayed age, so bucket 5
keeps ordering by commit age while showing nothing in ALDER.

The fixture tree was missing the only case that discriminates -- a repo with a
STATE.md AND an old last commit -- so the checks land with it, plus its mirror
image (old plan, fresh commit) to pin that the two ages read independently and
that neither became the sort key. Backdating sets GIT_COMMITTER_DATE, not
--date=: %ct is the committer date, and an author-only backdate would have left
the red test failing for the wrong reason.

board-selftest: 30 -> 36 checks.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QS6gYYgYgJAz7VjcFHEzbb
This commit is contained in:
Kjell Tore Guttormsen 2026-07-31 11:04:27 +02:00
commit 6a09c9caa1
5 changed files with 111 additions and 17 deletions

View file

@ -31,7 +31,7 @@ marketplace plugin. Three components, one boundary:
- **Board (`scripts/board.sh`):** cross-repo attention board. Reads STATE.md
next-step blocks + board lines, `git status`, and mailbox pending counts, and
prints one line per repo. Read-only by construction: it writes to no repo, no
STATE.md and no mailbox. Pinned by `board-selftest.sh` (30 checks).
STATE.md and no mailbox. Pinned by `board-selftest.sh` (36 checks).
**It lives here because the mailbox is one of its three inputs, and it carries
the same axis distinction the mailbox does.** A pending count means *others
@ -103,7 +103,7 @@ halves together for exactly that reason.
builtins only in hook and tests.
- TDD: no behavior change without a failing selftest check first.
`bash scripts/coord-selftest.sh` must exit 0 (136/136),
`bash scripts/board-selftest.sh` must exit 0 (30/30) and
`bash scripts/board-selftest.sh` must exit 0 (36/36) and
`bash scripts/route-selftest.sh` must exit 0 (50/50).
- English for all code, docs, and commit messages (public repo). Norwegian
trigger aliases in the skill description are deliberate.

View file

@ -132,7 +132,7 @@ Note that raising the inbox's priority (Rule 7) deliberately does **not** widen
## Development
bash scripts/coord-selftest.sh # 136 checks against a throwaway mailbox
bash scripts/board-selftest.sh # 30 checks against a throwaway repo tree
bash scripts/board-selftest.sh # 36 checks against a throwaway repo tree
bash scripts/route-selftest.sh # 50 checks, incl. the route->board round trip
npm test # all three selftests via node --test

View file

@ -142,6 +142,38 @@ git -C "$ROOT/repo-wt" -c user.email=t@t -c user.name=t \
printf 'Arbeid i worktree %s eget neste steg.\n' "$EMDASH"
} > "$ROOT/wt-feature/STATE.md"
# repo-old: fresh STATE.md, last COMMIT 400 days old. This is the case the
# fixture tree was missing entirely: with age read off STATE.md's mtime, a repo
# whose work stopped long ago looks exactly as alive as one touched this
# morning, and no column contradicts it.
mkrepo "$ROOT/repo-old"
{
echo "# STATE - repo-old"
printf '## %s NESTE %s START HER\n' "$HAND" "$EMDASH"
echo "<!-- board: status=in-progress; blocked-on=-; next-cost=Opus 5/high -->"
echo "Ser levende ut, men ingen commit paa lenge."
} > "$ROOT/repo-old/STATE.md"
OLD_EPOCH=$(( $(date +%s) - 400 * 86400 ))
git -C "$ROOT/repo-old" add STATE.md >/dev/null 2>&1
# %ct is the COMMITTER date. `--date=` sets the AUTHOR date only and would
# leave %ct at now, so the fixture would be backdated in name only.
GIT_AUTHOR_DATE="@$OLD_EPOCH +0000" GIT_COMMITTER_DATE="@$OLD_EPOCH +0000" \
git -C "$ROOT/repo-old" -c user.email=t@t -c user.name=t commit -qm init >/dev/null 2>&1
# repo-stale: the mirror image - STATE.md untouched since a fixed past date,
# last commit today. The pair pins that the two ages are independent readings
# and that neither of them silently became the sort key.
mkrepo "$ROOT/repo-stale"
{
echo "# STATE - repo-stale"
printf '## %s NESTE %s START HER\n' "$HAND" "$EMDASH"
echo "<!-- board: status=in-progress; blocked-on=-; next-cost=Opus 5/high -->"
echo "Gammel STATE, fersk commit."
} > "$ROOT/repo-stale/STATE.md"
git -C "$ROOT/repo-stale" add STATE.md >/dev/null 2>&1
git -C "$ROOT/repo-stale" -c user.email=t@t -c user.name=t commit -qm init >/dev/null 2>&1
touch -t 202601010000 "$ROOT/repo-stale/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"
@ -230,6 +262,37 @@ check "--help prescribes the canonical next-cost form" $?
if printf '%s' "$HELP" | grep -qE '(sonnet|opus|fable)/(high|xhigh)'; then rc=1; else rc=0; fi
check "--help shows no versionless model example" "$rc"
# --- 9. SISTE: last commit, read for EVERY repo ----------------------------
# One column, one meaning. ALDER answers "when was the plan last touched" and
# is blank where there is no plan; SISTE answers "when did anything last land"
# and is read for every repo. Before this, a single ALDER column carried both
# readings depending on which branch a repo fell into, so a repo with a STATE.md
# could go a year without a commit and show nothing at all.
[ "$(git -C "$ROOT/repo-old" log -1 --format=%ct 2>/dev/null)" = "$OLD_EPOCH" ]
check "fixture: repo-old's COMMITTER date is actually backdated" $?
printf '%s' "$OUT" | grep -q 'SISTE'; check "table has a SISTE column" $?
printf '%s\n' "$OUT" | grep -qE '^repo-old .*[[:space:]]0d[[:space:]]+40[01]d[[:space:]]'
check "fresh STATE.md + old commit: ALDER stays 0d, SISTE shows the 400 days" $?
printf '%s\n' "$OUT" | grep -qE '^repo-stale .*[[:space:]][1-9][0-9]*d[[:space:]]+0d[[:space:]]'
check "old STATE.md + fresh commit: the two ages read independently" $?
# A repo with no commits has no last commit to report. A day count there would
# be a fabricated reading, and the pre-existing -1d was exactly that.
crow="$(printf '%s\n' "$OUT" | grep '^repo-c ')"
printf '%s' "$crow" | grep -qE '[0-9]+d'; [ $? -ne 0 ]
check "repo with no commits reports no day count in either age column" $?
# The four ranking rules stay four: SISTE is evidence the operator reads, never
# an input to the order. If it had become the sort key, repo-old (400d) would
# fall behind repo-stale (0d) instead of ahead of it.
n_old="$(printf '%s\n' "$OUT" | grep -n '^repo-old ' | cut -d: -f1)"
n_stale="$(printf '%s\n' "$OUT" | grep -n '^repo-stale ' | cut -d: -f1)"
[ -n "$n_old" ] && [ -n "$n_stale" ] && [ "$n_old" -lt "$n_stale" ]
check "SISTE is evidence, not a ranking input (order still follows ALDER)" $?
echo ""
echo "board-selftest: $PASS passed, $FAIL failed"
[ "$FAIL" -eq 0 ] || exit 1

View file

@ -8,6 +8,7 @@
# 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
@ -32,6 +33,15 @@
# 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?)";
@ -123,11 +133,23 @@ printf '%s\n' "$REPOS" | while IFS= read -r d; do
[ -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
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"
# 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|(ingen STATE.md)\n' \
"$lastd" "$name" "$inbox" "$dirty" "$lastcol"
continue
fi
@ -185,8 +207,8 @@ printf '%s\n' "$REPOS" | while IFS= read -r d; do
*) bucket=2 ;;
esac
printf '%s|%06d|%s|%s|%s|%s|%s|%sd|%s\n' \
"$bucket" "$age" "$name" "$disp" "$cost" "$inbox" "$dirty" "$age" "$neste"
printf '%s|%06d|%s|%s|%s|%s|%s|%sd|%s|%s\n' \
"$bucket" "$age" "$name" "$disp" "$cost" "$inbox" "$dirty" "$age" "$lastcol" "$neste"
done > "${TMPDIR:-/tmp}/board.$$"
RECORDS="${TMPDIR:-/tmp}/board.$$"
@ -194,15 +216,15 @@ 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"
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 neste; do
printf '%-32s %-34s %-14s %4s %4s %6s %s\n' \
"$name" "$status" "$cost" "$inbox" "$dirty" "$age" "$neste"
while IFS='|' read -r bucket sortkey name status cost inbox dirty age last neste; do
printf '%-32s %-34s %-14s %4s %4s %6s %6s %s\n' \
"$name" "$status" "$cost" "$inbox" "$dirty" "$age" "$last" "$neste"
done
}
@ -210,6 +232,7 @@ 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; }

View file

@ -59,7 +59,8 @@ rule exists to stop. The run costs about three seconds.
| `KOST` | Model/effort for the next step, from the rubric row table in `route.sh` (the `route` skill writes it; this one only reads it). |
| `INN` | Unhandled inbox: **other repos are waiting on THIS one**. An obligation it owes outward. |
| `DRT` | Uncommitted files. |
| `ALDER` | Days since STATE.md last changed. |
| `ALDER` | Days since STATE.md last changed — the age of the *plan*. `-` where the repo has none. |
| `SISTE` | Days since the last commit — the age of the *work*. `-` where the repo has no commits yet. |
| `NESTE` | First line of the STATE.md next-step block, truncated. |
**`INN` never means "this repo is waiting on someone."** It means the opposite:
@ -85,8 +86,15 @@ fired — the rule is the justification, and it is what lets the operator disagr
naming: the config backup mirrors commits, not working trees, so uncommitted
files exist in exactly one place.
**`ALDER` is never a ranking input.** An old STATE.md often means a finished repo,
not a neglected one. Report it only if the user asks about staleness directly.
**Neither `ALDER` nor `SISTE` is a ranking input.** An old STATE.md often means a
finished repo, not a neglected one, and an old last commit can mean the same.
Report either only if the user asks about staleness directly — the four rules
above stay four.
Read them as a **pair**, though: they answer different questions, and the gap
between them is the reading neither gives alone. A fresh `ALDER` beside a long
`SISTE` is a repo that keeps being re-planned without anything landing, which no
other column on the board reports.
`deferred` is a deliberate choice, not neglect — do not surface a deferred repo as
a candidate unless nothing else qualifies, and say that it was deferred if you do.