feat(board): PLAN column - where a destination is missing or untouched

A display-only table column and a `plan=` field in --row, read from the
filesystem: `-` = no file named exactly PLAN.md in the repo root; `Nd` =
whole days the plan's mtime lies behind the last commit (`0d` when as new
or newer); `?` = a plan with no commit to compare against. The content is
never read, and the legend says the column does not judge whether a
criterion can be falsified. Not a sort key, not in --plan, not in SUM.

The name is matched byte for byte through a glob plus a basename compare:
APFS is case-insensitive by default, so `[ -f PLAN.md ]` is true for a
`plan.md`. The glob needs no subprocess.

The checks were written red in a separate eval before the column existed
and pass unchanged; they now live in board-selftest section 34 and the
eval file is removed (two judges for one column would drift).
Mutation-verified: a case-blind test, sorting on the field, a plan= key
in --plan and a legend without IKKE each turn exactly one check red.

The new record field sits before `neste`, so every "last field onward"
index moved 17 -> 18, and F6's right-counted DRT read moved NF-3 -> NF-4.

board-selftest 428 -> 461 (badge 961). Suites: coord 257, board 461,
route 73, orders 116, guard 54; npm test 12/12, run after git add.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-09-17 16:17:49 +02:00
commit 87fb78334e
7 changed files with 327 additions and 260 deletions

View file

@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
### Added
- **`PLAN` column in the board table, and `plan=` in `--row`.** A filesystem
reading of the repo root: `-` = no file named exactly `PLAN.md`; `Nd` =
whole days the plan's mtime lies behind the last commit (`0d` when as new or
newer); `?` = a plan with no commit to compare against. The content is never
read, and the column does not judge whether a criterion can be falsified -
the legend says so. The name is matched byte for byte, because APFS's
case-insensitivity makes `[ -f PLAN.md ]` true for `plan.md`. Display only:
not a sort key, not in `--plan`, not summed. board-selftest section 34
(written red before the column existed) pins all of it.
### Fixed ### Fixed
- **`board.sh --dispatch` put three other record fields in front of `neste=`.** - **`board.sh --dispatch` put three other record fields in front of `neste=`.**

View file

@ -467,7 +467,7 @@ marketplace plugin. Three components, one boundary:
- **Board (`scripts/board.sh`):** cross-repo attention board. Reads STATE.md - **Board (`scripts/board.sh`):** cross-repo attention board. Reads STATE.md
next-step blocks + board lines, `git status`, and mailbox pending counts, and 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 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` (428 checks). STATE.md and no mailbox. Pinned by `board-selftest.sh` (461 checks).
**It lives here because the mailbox is one of its three inputs, and it carries **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 the same axis distinction the mailbox does.** A pending count means *others
@ -1454,6 +1454,41 @@ marketplace plugin. Three components, one boundary:
digit. A fixture that cannot tell two columns apart is the defect wearing a digit. A fixture that cannot tell two columns apart is the defect wearing a
passing test, inside the section written to prevent it. passing test, inside the section written to prevent it.
- **`PLAN` is a table column read from the FILESYSTEM, never from the file**
(operator decision 2026-09-17). `-` = no file named exactly `PLAN.md` in the
repo root; `Nd` = whole days its mtime lies behind the last commit; `?` = a
plan with no commit to compare against. It answers where a destination is
missing or untouched and nothing else - it does not judge whether a criterion
can be falsified, and the legend says so. Three alternatives were weighed
first and rejected on measurement, not taste: a free-text `goal=` field
(the case that motivated this HAD a goal in words; a field cannot tell words
from a falsifiable criterion), a mandatory acceptance flag on orders (every
order in that case was precisely specified - the gap was at version level),
and an "activity without a release" heuristic (a release silenced it on the
one known case, three days before the loop was diagnosed).
**The name is matched BYTE FOR BYTE through a glob (`"$d"/PLAN.m[d]` plus a
basename compare), never by `[ -f PLAN.md ]`.** APFS is case-insensitive by
default, so that test is true for `plan.md` and would report a plan that is
not there; a glob matches the names readdir returns, as stored, and costs no
subprocess. Selftest section 34 asserts the trap is ARMED on the machine
running it, so the byte-exact check is measuring something. Display only:
not a sort key, not in `--plan`, not in SUM - pinned by swapping two repos'
PLAN values between runs and requiring the same order.
**The checks were written RED first, in a separate file, and moved into
board-selftest once the column passed them unchanged.** Red checks inside
the suite would have turned main red and broken the README count check, so
"red for the right reason" could not have been told apart from anything
else. The separate file is gone - two judges for one column would drift.
Mutation-verified against four broken variants (case-blind test, sorting on
the field, a `plan=` key in `--plan`, a legend without IKKE), each turning
exactly one check red. Adding the field before `neste` exposed that
`--dispatch` had taken `neste` from field 14 onward since the record had 14
fields, gluing three columns onto the prose; fixed separately, and every
"last field onward" index now moves with the record (17 -> 18 here). F6
counts DRT from the right, so its `NF-3` became `NF-4` for the same reason.
- **Skills (`skills/coord-send/`, `skills/board/`, `skills/route/`, `skills/dispatch/`):** natural-language front - **Skills (`skills/coord-send/`, `skills/board/`, `skills/route/`, `skills/dispatch/`):** natural-language front
doors mapping user intent to engine invocations. No mailbox logic lives here doors mapping user intent to engine invocations. No mailbox logic lives here
either. `board` additionally owns the *ranking* — which repo wins and why — either. `board` additionally owns the *ranking* — which repo wins and why —
@ -1502,7 +1537,7 @@ obligations in another repo.
builtins only in hook and tests. builtins only in hook and tests.
- TDD: no behavior change without a failing selftest check first. - TDD: no behavior change without a failing selftest check first.
`bash scripts/coord-selftest.sh` must exit 0 (257/257), `bash scripts/coord-selftest.sh` must exit 0 (257/257),
`bash scripts/board-selftest.sh` must exit 0 (428/428), `bash scripts/board-selftest.sh` must exit 0 (461/461),
`bash scripts/route-selftest.sh` must exit 0 (73/73), `bash scripts/route-selftest.sh` must exit 0 (73/73),
`bash scripts/orders-selftest.sh` must exit 0 (116/116) and `bash scripts/orders-selftest.sh` must exit 0 (116/116) and
`bash scripts/state-line-guard-selftest.sh` must exit 0 (54/54). `bash scripts/state-line-guard-selftest.sh` must exit 0 (54/54).

View file

@ -14,7 +14,7 @@ Session A in repo X leaves a message for repo Y; the next session in repo Y gets
![Hooks](https://img.shields.io/badge/hooks-1-green) ![Hooks](https://img.shields.io/badge/hooks-1-green)
![Skills](https://img.shields.io/badge/skills-4-orange) ![Skills](https://img.shields.io/badge/skills-4-orange)
![CLI scripts](https://img.shields.io/badge/CLI_scripts-8-blue) ![CLI scripts](https://img.shields.io/badge/CLI_scripts-8-blue)
![Selftest checks](https://img.shields.io/badge/selftest_checks-928-blue) ![Selftest checks](https://img.shields.io/badge/selftest_checks-961-blue)
--- ---
@ -152,6 +152,8 @@ It makes **zero model calls**, which is the point rather than a detail. Under su
**`board.sh --row <repo>` renders one repo's row as `field=value` lines**, a seventh rendering of the same scan and the only one that answers about a single repo. It exists because the table's columns *were* misread: the fixed-width view is for a human, and reading a column off it by eye is how `FLY` was read as `ORDRE` on 2026-09-11 — while every other rendering a program consumes is already `key=value`. `inn`, `ordre` and `fly` are three separate fields because they are three separate facts; `status` is the bare token rather than the table's `blocked>target` display, with `blocked-on` beside it; `neste` is last and uncut. An unknown repo exits **2** and writes nothing, because an empty block would read as a repo whose every column is blank — a real and different state. One field is deliberately *not* a rendering of the scan and is named as such: `upushet` is measured once, for that repo only, from the remote-**tracking** ref rather than the remote, so a repo with no upstream reports `?` and never `0`. **`board.sh --row <repo>` renders one repo's row as `field=value` lines**, a seventh rendering of the same scan and the only one that answers about a single repo. It exists because the table's columns *were* misread: the fixed-width view is for a human, and reading a column off it by eye is how `FLY` was read as `ORDRE` on 2026-09-11 — while every other rendering a program consumes is already `key=value`. `inn`, `ordre` and `fly` are three separate fields because they are three separate facts; `status` is the bare token rather than the table's `blocked>target` display, with `blocked-on` beside it; `neste` is last and uncut. An unknown repo exits **2** and writes nothing, because an empty block would read as a repo whose every column is blank — a real and different state. One field is deliberately *not* a rendering of the scan and is named as such: `upushet` is measured once, for that repo only, from the remote-**tracking** ref rather than the remote, so a repo with no upstream reports `?` and never `0`.
**The table's `PLAN` column shows whether a repo keeps a destination where sessions actually look.** It is a filesystem reading and nothing more: `-` means no file named exactly `PLAN.md` in the repo root, `Nd` means the plan's mtime lies `N` whole days behind the last commit (`0d` when it is as new or newer), and `?` means a plan with no commit to compare against. The file's content is never read, so no wording inside it can move the cell — and the column does not judge whether the criterion inside can be falsified. The name is matched **byte for byte** through a glob rather than with `[ -f PLAN.md ]`, because APFS is case-insensitive by default and that test is true for a `plan.md`. It is display only: never a sort key, never in `--plan`, never summed; `--row` carries it as `plan=`. `touch` and a checkout reset the mtime, the same caveat `ALDER` carries. It replaced nothing: a heuristic on "activity without a release" was measured and rejected first, because a release made it go silent on the one known case it existed to catch.
**`board.sh --plan` renders the day plan**, a *third* rendering of that same scan and the only one that takes a position: which repos to open a tab for today, in what order, and the command to start each. The order is the position, and there is no cutoff — nothing is hidden, and **five ordered groups** decide it, each a lookup over a field the scan already read rather than a weighted score: (1) **chain-root credit**, most repos released first; (2) **debt**, most-owed-first, whatever the status; (3) `planned`; (4) `in-progress`; (5) `?`/`MALFORMED` — undeclared, last and labelled. Within a group, ties break on a cheap Sonnet `next-cost` row, then oldest plan first. A 0.19.0 weighted score (`40 ×` repos released, `15 ×` unhandled inbox messages) briefly stood in this spot and *could* express "this repo owes one message and releases two others" as a single number — but re-tuning those two coefficients would have silently reordered a parser living in another repo, with no test here able to hold a ranking stable for a consumer it can't see; the operator replaced it with the group order in 0.20.0 for that reason. Groups 3 and 4 are `planned` above `in-progress`, inverted from every earlier version by the same decision: turning a decision into motion is the slow step, live work is already moving. **Chain-root credit** follows `blocked-on` transitively to the first repo that is not itself blocked and credits only that root: opening a blocked repo releases nobody, since its own next step is by definition waiting. A cycle, or a `blocked-on` naming a repo the scan never produced, credits nobody rather than inventing a root — a plan that looks correct while sending you to the wrong repo is worse than one that says nothing. Repos owing mail still rank high *whatever their status*, and debt is deliberately **uncapped**: excluding `blocked` or `done` is a statement about a repo's own next step, which cannot be moved, while owing an answer is a different axis and answering is often what unblocks it. Repos with no board line come last and labelled — the table already prints a note about those, so a plan that dropped them silently would repeat exactly that defect. `why=` names the group that placed the repo, so a block reads `unblocks:2` rather than the `inbox:N` every block used to repeat. Still zero model calls, still read-only, and still cross-checked against `coord-count.sh`. **`board.sh --plan` renders the day plan**, a *third* rendering of that same scan and the only one that takes a position: which repos to open a tab for today, in what order, and the command to start each. The order is the position, and there is no cutoff — nothing is hidden, and **five ordered groups** decide it, each a lookup over a field the scan already read rather than a weighted score: (1) **chain-root credit**, most repos released first; (2) **debt**, most-owed-first, whatever the status; (3) `planned`; (4) `in-progress`; (5) `?`/`MALFORMED` — undeclared, last and labelled. Within a group, ties break on a cheap Sonnet `next-cost` row, then oldest plan first. A 0.19.0 weighted score (`40 ×` repos released, `15 ×` unhandled inbox messages) briefly stood in this spot and *could* express "this repo owes one message and releases two others" as a single number — but re-tuning those two coefficients would have silently reordered a parser living in another repo, with no test here able to hold a ranking stable for a consumer it can't see; the operator replaced it with the group order in 0.20.0 for that reason. Groups 3 and 4 are `planned` above `in-progress`, inverted from every earlier version by the same decision: turning a decision into motion is the slow step, live work is already moving. **Chain-root credit** follows `blocked-on` transitively to the first repo that is not itself blocked and credits only that root: opening a blocked repo releases nobody, since its own next step is by definition waiting. A cycle, or a `blocked-on` naming a repo the scan never produced, credits nobody rather than inventing a root — a plan that looks correct while sending you to the wrong repo is worse than one that says nothing. Repos owing mail still rank high *whatever their status*, and debt is deliberately **uncapped**: excluding `blocked` or `done` is a statement about a repo's own next step, which cannot be moved, while owing an answer is a different axis and answering is often what unblocks it. Repos with no board line come last and labelled — the table already prints a note about those, so a plan that dropped them silently would repeat exactly that defect. `why=` names the group that placed the repo, so a block reads `unblocks:2` rather than the `inbox:N` every block used to repeat. Still zero model calls, still read-only, and still cross-checked against `coord-count.sh`.
**`--focus "<prose>"` narrows that plan to one subject, and reports what it held back.** It is the only cutoff this format has, which is why the report is a condition of the feature rather than a refinement of it: `--plan` documents that it hides nothing and labels what it cannot rank, so a silent filter would break a property already written down. The same run prints the subjects the prose resolved to, how many blocks were removed, how many `STATE.md` were searched, and — named, not counted — the repos that *mention* a resolved subject without declaring a marker line. That last class is enumerated because it is where the misses live: a repo can be a heavy participant and never have written a marker, and no string measurement finds it until the held-back population is listed. Each surviving block carries the declaration it survived on. Prose matching nothing prints the *full* plan and says so, since the phrase arrives verbatim from a person and a typo must not empty the day. The subject vocabulary is read from the scanned `STATE.md` themselves, so the "reads `STATE.md` and no other file" invariant is untouched. **`--focus "<prose>"` narrows that plan to one subject, and reports what it held back.** It is the only cutoff this format has, which is why the report is a condition of the feature rather than a refinement of it: `--plan` documents that it hides nothing and labels what it cannot rank, so a silent filter would break a property already written down. The same run prints the subjects the prose resolved to, how many blocks were removed, how many `STATE.md` were searched, and — named, not counted — the repos that *mention* a resolved subject without declaring a marker line. That last class is enumerated because it is where the misses live: a repo can be a heavy participant and never have written a marker, and no string measurement finds it until the held-back population is listed. Each surviving block carries the declaration it survived on. Prose matching nothing prints the *full* plan and says so, since the phrase arrives verbatim from a person and a typo must not empty the day. The subject vocabulary is read from the scanned `STATE.md` themselves, so the "reads `STATE.md` and no other file" invariant is untouched.
@ -199,7 +201,7 @@ Note that raising the inbox's priority (Rule 7) deliberately does **not** widen
## Development ## Development
bash scripts/coord-selftest.sh # 257 checks against a throwaway mailbox bash scripts/coord-selftest.sh # 257 checks against a throwaway mailbox
bash scripts/board-selftest.sh # 428 checks against a throwaway repo tree bash scripts/board-selftest.sh # 461 checks against a throwaway repo tree
bash scripts/route-selftest.sh # 73 checks, incl. the route->board round trip bash scripts/route-selftest.sh # 73 checks, incl. the route->board round trip
bash scripts/orders-selftest.sh # 116 checks, incl. the 20-way barriered claim race bash scripts/orders-selftest.sh # 116 checks, incl. the 20-way barriered claim race
bash scripts/state-line-guard-selftest.sh # 54 checks, incl. the Edit replace_all projection and the ratchet bash scripts/state-line-guard-selftest.sh # 54 checks, incl. the Edit replace_all projection and the ratchet

View file

@ -2379,19 +2379,19 @@ check "F6: fixture ground truth - git really does fail on the broken worktree" $
F6TBL="$(CLAUDE_COORD_DIR="$F6COORD" "$BOARD" --roots "$F6ROOT" 2>/dev/null)" F6TBL="$(CLAUDE_COORD_DIR="$F6COORD" "$BOARD" --roots "$F6ROOT" 2>/dev/null)"
# DRT is counted from the RIGHT (NF-3: DRT ALDER SISTE NESTE), never from the # DRT is counted from the RIGHT (NF-4: DRT ALDER SISTE PLAN NESTE), never from the
# left: the KOST field is free text and its realistic spelling ("Sonnet 5/low") # left: the KOST field is free text and its realistic spelling ("Sonnet 5/low")
# contains a space, so $7 reads FLY instead. Measured, not assumed - the first # contains a space, so $7 reads FLY instead. Measured, not assumed - the first
# cut of this section passed its clean-tree control by accident that way. # cut of this section passed its clean-tree control by accident that way.
printf '%s' "$F6TBL" | grep -E '^f6-clean[[:space:]]' | awk '{print $(NF-3)}' | grep -qx '0' printf '%s' "$F6TBL" | grep -E '^f6-clean[[:space:]]' | awk '{print $(NF-4)}' | grep -qx '0'
check "F6: control - a healthy CLEAN tree still reads DRT 0" $? check "F6: control - a healthy CLEAN tree still reads DRT 0" $?
printf '%s' "$F6TBL" | grep -E '^f6-dirty[[:space:]]' | awk '{print $(NF-3)}' | grep -qx '1' printf '%s' "$F6TBL" | grep -E '^f6-dirty[[:space:]]' | awk '{print $(NF-4)}' | grep -qx '1'
check "F6: control - a healthy DIRTY tree still reads DRT 1" $? check "F6: control - a healthy DIRTY tree still reads DRT 1" $?
printf '%s' "$F6TBL" | grep -E '^f6-broken[[:space:]]' | awk '{print $(NF-3)}' | grep -qx '0' printf '%s' "$F6TBL" | grep -E '^f6-broken[[:space:]]' | awk '{print $(NF-4)}' | grep -qx '0'
[ $? -ne 0 ] [ $? -ne 0 ]
check "F6: a git failure does NOT read as DRT 0 (clean tree)" $? check "F6: a git failure does NOT read as DRT 0 (clean tree)" $?
printf '%s' "$F6TBL" | grep -E '^f6-broken[[:space:]]' | awk '{print $(NF-3)}' | grep -qx '?' printf '%s' "$F6TBL" | grep -E '^f6-broken[[:space:]]' | awk '{print $(NF-4)}' | grep -qx '?'
check "F6: a git failure reads as ? (not measured), the same token coord-count uses" $? check "F6: a git failure reads as ? (not measured), the same token coord-count uses" $?
# The load-bearing consequence. All three fixtures are status=done with no mail # The load-bearing consequence. All three fixtures are status=done with no mail
@ -3455,6 +3455,218 @@ grep -q -- '--row' "$BOARD"; check "row: board.sh actually carries the --row fla
/bin/rm -rf "$ROW_ROOT" "$ROW_COORD" 2>/dev/null /bin/rm -rf "$ROW_ROOT" "$ROW_COORD" 2>/dev/null
# --- 34. PLAN column: a filesystem reading, display only --------------------
# Operator decision 2026-09-17. The cell answers one question: does this repo
# keep a destination where sessions actually look (a root PLAN.md), and is
# anyone touching it? `-` = no file named exactly PLAN.md in the root; `Nd` =
# whole days its mtime lies behind the last commit. It never reads the file,
# never enters a sort, a --plan block or SUM, and does not judge whether a
# criterion inside it can be falsified - the legend says so.
#
# These checks were written RED in a separate file before the column existed
# (24 of 33 failing because the column was missing, fixture ground truth
# green), kept out of this suite so main stayed green, and moved here once the
# column passed them unchanged. That file is gone: two judges for one column
# would drift. Mutation-verified against four broken variants, each turning
# exactly one check red: a case-blind `[ -f PLAN.md ]`, sorting rows on the
# plan field, a plan= key in the --plan blocks, and a legend without IKKE.
#
# Table cells are located through the HEADER (split on runs of 2+ spaces),
# never a fixed offset, so a later column move does not break this section.
PC_ROOT="$(mktemp -d)"
PC_COORD="$(mktemp -d)"
PC_NOW="$(date +%s)"
PC_DAY=86400
pc_state() {
{
echo "# STATE - $2"
echo "## NESTE - START HER"
echo "<!-- board: status=$3; blocked-on=-; next-cost=Sonnet 5/high -->"
echo "Next step for $2."
} > "$1/STATE.md"
}
# pc_commit_at <dir> <epoch> <msg>: commit everything with a fixed committer date.
pc_commit_at() {
git -C "$1" add -A >/dev/null 2>&1
GIT_AUTHOR_DATE="@$2 +0000" GIT_COMMITTER_DATE="@$2 +0000" \
git -C "$1" -c user.email=t@t -c user.name=t commit -qm "$3" >/dev/null 2>&1
}
pc_touch_at() { touch -t "$(date -r "$2" +%Y%m%d%H%M.%S)" "$1"; }
pc_last_commit() { git -C "$1" log -1 --format=%ct 2>/dev/null; }
pc_mtime() { stat -f %m "$1"; }
# pc_table_cell <table-output> <repo>: the PLAN cell of <repo>'s row, read under
# the nearest preceding header line. Empty when the header has no PLAN column.
pc_table_cell() {
printf '%s\n' "$1" | awk -v repo="$2" '
function split2(s, arr, n) { gsub(/ +/, "\t", s); sub(/^\t/, "", s); return split(s, arr, "\t") }
/^REPO / { n = split2($0, h); col = 0; for (i = 1; i <= n; i++) if (h[i] == "PLAN") col = i; next }
$1 == repo && col > 0 { split2($0, r); print r[col]; exit }'
}
pc_row_plan() { printf '%s\n' "$1" | sed -n 's/^plan=//p'; }
pc_board() { CLAUDE_COORD_DIR="$PC_COORD" "$BOARD" --roots "$PC_ROOT" "$@" 2>/dev/null; }
# --- Fixtures ---------------------------------------------------------------
# p-none: no PLAN.md anywhere.
mkrepo "$PC_ROOT/p-none"; pc_state "$PC_ROOT/p-none" p-none planned
pc_commit_at "$PC_ROOT/p-none" $((PC_NOW - 3 * PC_DAY)) init
# p-fresh: PLAN.md newer than the last commit -> 0d. The file is EMPTY: a value
# computed from content could not produce 0d here, so this also shows that
# the column is a filesystem reading.
mkrepo "$PC_ROOT/p-fresh"; pc_state "$PC_ROOT/p-fresh" p-fresh planned
pc_commit_at "$PC_ROOT/p-fresh" $((PC_NOW - 2 * PC_DAY)) init
: > "$PC_ROOT/p-fresh/PLAN.md"
pc_touch_at "$PC_ROOT/p-fresh/PLAN.md" "$PC_NOW"
# p-stale: PLAN.md 5 days and 3 hours older than the last commit -> 5d. Its
# content looks like a value on purpose ("PLAN=99d"); it must not be read.
mkrepo "$PC_ROOT/p-stale"; pc_state "$PC_ROOT/p-stale" p-stale planned
printf '# PLAN\nPLAN=99d\n' > "$PC_ROOT/p-stale/PLAN.md"
PC_P_STALE_CT=$((PC_NOW - 3600))
pc_commit_at "$PC_ROOT/p-stale" "$PC_P_STALE_CT" init
pc_touch_at "$PC_ROOT/p-stale/PLAN.md" $((PC_P_STALE_CT - 5 * PC_DAY - 3 * 3600))
# p-docs: PLAN.md in docs/ only - the wrong place by convention.
mkrepo "$PC_ROOT/p-docs"; pc_state "$PC_ROOT/p-docs" p-docs planned
mkdir -p "$PC_ROOT/p-docs/docs"; echo "# PLAN" > "$PC_ROOT/p-docs/docs/PLAN.md"
pc_commit_at "$PC_ROOT/p-docs" $((PC_NOW - PC_DAY)) init
# p-lower: lowercase plan.md in the root. On a case-insensitive filesystem
# (APFS default) `[ -f PLAN.md ]` is TRUE here, so a board that asks the
# filesystem alone would report a plan that is not there. The name has to be
# compared byte for byte against what the directory actually stores (e.g. a
# directory listing matched exactly against PLAN.md).
mkrepo "$PC_ROOT/p-lower"; pc_state "$PC_ROOT/p-lower" p-lower planned
echo "# plan" > "$PC_ROOT/p-lower/plan.md"
pc_commit_at "$PC_ROOT/p-lower" $((PC_NOW - PC_DAY)) init
# --- Ground truth for the fixtures (these must be green today) -------------
[ -z "$(git -C "$PC_ROOT/p-stale" status --porcelain)" ]
check "plan: fixture - p-stale is committed clean" $?
PC_P_STALE_EXP="$(( ($(pc_last_commit "$PC_ROOT/p-stale") - $(pc_mtime "$PC_ROOT/p-stale/PLAN.md")) / PC_DAY ))d"
[ "$PC_P_STALE_EXP" != "0d" ]
check "plan: fixture - p-stale's expected value is computed from its timestamps and is not 0d ($PC_P_STALE_EXP)" $?
[ "$(pc_mtime "$PC_ROOT/p-fresh/PLAN.md")" -ge "$(pc_last_commit "$PC_ROOT/p-fresh")" ]
check "plan: fixture - p-fresh's PLAN.md is newer than its last commit" $?
if [ -f "$PC_ROOT/p-lower/PLAN.md" ]; then PC_LOWER_ARMED=0; else PC_LOWER_ARMED=1; fi
check "plan: fixture - the case trap is armed here - [ -f PLAN.md ] is true for plan.md (case-insensitive fs)" "$PC_LOWER_ARMED"
ls -1 "$PC_ROOT/p-lower" | grep -qx 'PLAN.md'; PC_rc=$?
[ "$PC_rc" -ne 0 ]; check "plan: fixture - a byte-exact directory listing does NOT see PLAN.md in p-lower" $?
PC_TABLE="$(pc_board)"
PC_N_REPOS="$(printf '%s\n' "$PC_TABLE" | sed -n 's/^BOARD .*(\([0-9][0-9]*\) repo).*/\1/p' | head -1)"
[ "${PC_N_REPOS:-0}" -eq 5 ]; check "plan: fixture - board scanned the 5 fixture repos (header says ${PC_N_REPOS:-nothing})" $?
# --- 1. Three different values from three repos ----------------------------
printf '%s\n' "$PC_TABLE" | grep -q '^REPO .* PLAN '
check "plan 1: the table header carries a PLAN column" $?
[ "$(pc_table_cell "$PC_TABLE" p-none)" = "-" ]; check "plan 1: no root PLAN.md -> '-'" $?
[ "$(pc_table_cell "$PC_TABLE" p-fresh)" = "0d" ]; check "plan 1: PLAN.md newer than the last commit -> '0d'" $?
[ "$(pc_table_cell "$PC_TABLE" p-stale)" = "$PC_P_STALE_EXP" ]
check "plan 1: PLAN.md older than the last commit -> '$PC_P_STALE_EXP' (from the fixture's own timestamps)" $?
PC_V3="$(pc_table_cell "$PC_TABLE" p-none) $(pc_table_cell "$PC_TABLE" p-fresh) $(pc_table_cell "$PC_TABLE" p-stale)"
[ "$(printf '%s\n' $PC_V3 | sort -u | wc -l | tr -d ' ')" -eq 3 ]
check "plan 1: the three fixtures show three DIFFERENT values ($PC_V3)" $?
# --- 2. Wrong place and wrong case are not a plan ---------------------------
[ "$(pc_table_cell "$PC_TABLE" p-docs)" = "-" ]; check "plan 2: docs/PLAN.md only -> '-'" $?
[ "$(pc_table_cell "$PC_TABLE" p-lower)" = "-" ]; check "plan 2: lowercase plan.md in the root -> '-' (byte-exact name)" $?
# --- 3. --row carries plan= with the table's value ---------------------------
for PC_r in p-none p-fresh p-stale p-docs p-lower; do
PC_RV="$(pc_row_plan "$(pc_board --row "$PC_r")")"
PC_TV="$(pc_table_cell "$PC_TABLE" "$PC_r")"
[ -n "$PC_RV" ] && [ "$PC_RV" = "$PC_TV" ]
check "plan 3: --row $PC_r has plan=${PC_RV:-<missing>} equal to the table cell (${PC_TV:-<missing>})" $?
done
[ "$(pc_board --row p-none | grep -c '^plan=')" -eq 1 ]
check "plan 3: --row emits exactly one plan= line" $?
[ "$(pc_board --row p-none | tail -1 | cut -d= -f1)" = "neste" ]
check "plan 3: neste= is still the last --row line" $?
# --- 4. Not in the sort, not in --plan blocks, not summed --------------------
# Two repos identical in every sorted property; run 1 gives s-a the stale plan
# and s-b the fresh one, run 2 swaps them. Any sort key built on PLAN would
# reverse them in one of the two runs.
PC_SROOT="$(mktemp -d)"
for PC_r in s-a s-b; do
mkrepo "$PC_SROOT/$PC_r"; pc_state "$PC_SROOT/$PC_r" "$PC_r" planned
echo "<!-- route: path=known; verification=strong; reversibility=cheap; scope=local; rationale=x -->" >> "$PC_SROOT/$PC_r/STATE.md"
: > "$PC_SROOT/$PC_r/PLAN.md"
pc_commit_at "$PC_SROOT/$PC_r" $((PC_NOW - PC_DAY)) init
pc_touch_at "$PC_SROOT/$PC_r/STATE.md" $((PC_NOW - 2 * PC_DAY))
done
pc_touch_at "$PC_SROOT/s-a/PLAN.md" $((PC_NOW - 9 * PC_DAY)); pc_touch_at "$PC_SROOT/s-b/PLAN.md" "$PC_NOW"
PC_T1="$(CLAUDE_COORD_DIR="$PC_COORD" "$BOARD" --roots "$PC_SROOT" 2>/dev/null)"; PC_P1="$(CLAUDE_COORD_DIR="$PC_COORD" "$BOARD" --roots "$PC_SROOT" --plan 2>/dev/null)"
pc_touch_at "$PC_SROOT/s-a/PLAN.md" "$PC_NOW"; pc_touch_at "$PC_SROOT/s-b/PLAN.md" $((PC_NOW - 9 * PC_DAY))
PC_T2="$(CLAUDE_COORD_DIR="$PC_COORD" "$BOARD" --roots "$PC_SROOT" 2>/dev/null)"; PC_P2="$(CLAUDE_COORD_DIR="$PC_COORD" "$BOARD" --roots "$PC_SROOT" --plan 2>/dev/null)"
PC_A1="$(pc_table_cell "$PC_T1" s-a)"; PC_A2="$(pc_table_cell "$PC_T2" s-a)"
[ -n "$PC_A1" ] && [ -n "$PC_A2" ] && [ "$PC_A1" != "$PC_A2" ]
check "plan 4: precondition - the swap really changed s-a's PLAN value (${PC_A1:-<missing>} -> ${PC_A2:-<missing>})" $?
PC_O1="$(printf '%s\n' "$PC_T1" | awk '$1 ~ /^s-/ {print $1}' | tr '\n' ' ')"
PC_O2="$(printf '%s\n' "$PC_T2" | awk '$1 ~ /^s-/ {print $1}' | tr '\n' ' ')"
[ -n "$PC_A1" ] && [ "$PC_O1" = "$PC_O2" ]; check "plan 4: table order is identical across the swap ($PC_O1| $PC_O2)" $?
PC_Q1="$(printf '%s\n' "$PC_P1" | sed -n 's/^repo=//p' | tr '\n' ' ')"
PC_Q2="$(printf '%s\n' "$PC_P2" | sed -n 's/^repo=//p' | tr '\n' ' ')"
[ -n "$PC_A1" ] && [ -n "$PC_Q1" ] && [ "$PC_Q1" = "$PC_Q2" ]; check "plan 4: --plan order is identical across the swap ($PC_Q1| $PC_Q2)" $?
if printf '%s\n' "$PC_P1" | grep -qi '^plan='; then PC_rc=1; else PC_rc=0; fi
[ -n "$PC_A1" ] && [ "$PC_rc" -eq 0 ]; check "plan 4: --plan blocks carry no plan= key" $?
PC_S1="$(printf '%s\n' "$PC_T1" | grep '^SUM:')"; PC_S2="$(printf '%s\n' "$PC_T2" | grep '^SUM:')"
if printf '%s' "$PC_S1" | grep -qi 'plan'; then PC_rc=1; else PC_rc=0; fi
[ -n "$PC_A1" ] && [ "$PC_rc" -eq 0 ] && [ "$PC_S1" = "$PC_S2" ]; check "plan 4: the SUM line neither mentions PLAN nor moves with it" $?
/bin/rm -rf "$PC_SROOT" 2>/dev/null
# --- 5. Replay: the shape C failed on ----------------------------------------
# Many commits, no root PLAN.md, the destination written in docs/*.local.md,
# and a release tag late in the window. A heuristic keyed on "activity without
# a release" went silent at the tag; this column must read '-' at every step.
PC_LROOT="$(mktemp -d)"
PC_L="$PC_LROOT/loop-repo"
mkrepo "$PC_L"; pc_state "$PC_L" loop-repo in-progress
mkdir -p "$PC_L/docs"; echo "destination lives here" > "$PC_L/docs/destination.local.md"
PC_BASE=$((PC_NOW - 60 * PC_DAY)); PC_HITS=0; PC_STEPS=0
PC_i=0
while [ "$PC_i" -lt 6 ]; do
echo "step $PC_i" > "$PC_L/work.txt"
pc_commit_at "$PC_L" $((PC_BASE + PC_i * 10 * PC_DAY)) "step $PC_i"
[ "$PC_i" -eq 4 ] && git -C "$PC_L" -c tag.gpgSign=false tag v9.9.0 >/dev/null 2>&1
PC_LT="$(CLAUDE_COORD_DIR="$PC_COORD" "$BOARD" --roots "$PC_LROOT" 2>/dev/null)"
PC_STEPS=$((PC_STEPS + 1))
[ "$(pc_table_cell "$PC_LT" loop-repo)" = "-" ] && PC_HITS=$((PC_HITS + 1))
PC_i=$((PC_i + 1))
done
[ -n "$(git -C "$PC_L" tag -l v9.9.0)" ]; check "plan 5: fixture - the release tag exists in the replay" $?
[ "$PC_HITS" -eq "$PC_STEPS" ]; check "plan 5: '-' at every step of the replay, before and after the tag ($PC_HITS of $PC_STEPS)" $?
/bin/rm -rf "$PC_LROOT" 2>/dev/null
# --- 6. Denominator: one value per scanned repo ------------------------------
PC_WITH=0
for PC_r in p-none p-fresh p-stale p-docs p-lower; do
[ -n "$(pc_table_cell "$PC_TABLE" "$PC_r")" ] && PC_WITH=$((PC_WITH + 1))
done
# A non-empty, non-zero denominator first: with an empty scan both sides are 0
# and the comparison alone would pass on nothing.
[ "${PC_N_REPOS:-0}" -gt 0 ] && [ "$PC_WITH" -eq "$PC_N_REPOS" ]
check "plan 6: every scanned repo has a PLAN value ($PC_WITH of ${PC_N_REPOS:-?})" $?
printf '%s\n' "$PC_TABLE" | grep -q '^undersoekt: '
check "plan 6: the scan's own denominator line is still printed" $?
# --- 7. Legend -----------------------------------------------------------------
PC_LEG="$(printf '%s\n' "$PC_TABLE" | awk '/^PLAN = /{on=1} on && /^[A-Z]+ = / && !/^PLAN = /{on=0} on')"
[ -n "$PC_LEG" ]; check "plan 7: the legend has a 'PLAN = ' entry" $?
printf '%s' "$PC_LEG" | grep -q 'IKKE' && printf '%s' "$PC_LEG" | grep -qi 'felle'
check "plan 7: the legend says the column does NOT judge whether the criterion can be falsified" $?
printf '%s' "$PC_LEG" | grep -q 'touch' && printf '%s' "$PC_LEG" | grep -q 'checkout'
check "plan 7: the legend says touch/checkout reset the mtime" $?
if printf '%s' "$PC_LEG" | LC_ALL=C grep -q '[^ -~]'; then PC_rc=1; else PC_rc=0; fi
[ -n "$PC_LEG" ] && [ "$PC_rc" -eq 0 ]; check "plan 7: the legend is ASCII like the rest of the table" $?
/bin/rm -rf "$PC_ROOT" "$PC_COORD" 2>/dev/null
echo "" echo ""
echo "board-selftest: $PASS passed, $FAIL failed" echo "board-selftest: $PASS passed, $FAIL failed"
[ "$FAIL" -eq 0 ] || exit 1 [ "$FAIL" -eq 0 ] || exit 1

View file

@ -9,6 +9,9 @@
# STATE.md board line - optional machine-readable field (see below) # STATE.md board line - optional machine-readable field (see below)
# git status --porcelain - uncommitted risk # git status --porcelain - uncommitted risk
# git log -1 --format=%ct - when anything last landed (the SISTE column) # git log -1 --format=%ct - when anything last landed (the SISTE column)
# <repo>/PLAN.md mtime - the PLAN column: existence (byte-exact name) and
# days behind the last commit. Never its content.
# DISPLAY only and not in the sort.
# ~/.claude/coord/<repo>/orders - PENDING orders (the ORDRE column). # ~/.claude/coord/<repo>/orders - PENDING orders (the ORDRE column).
# Rendered N:Md - the count AND the age of the # Rendered N:Md - the count AND the age of the
# OLDEST order, never the newest and never a mean: # OLDEST order, never the newest and never a mean:
@ -820,13 +823,38 @@ printf '%s\n' "$REPOS" | while IFS= read -r d; do
lastd=-1; lastcol="-" lastd=-1; lastcol="-"
fi fi
# PLAN: does this repo keep a destination where sessions actually look, and
# is anyone touching it? A FILESYSTEM reading only - PLAN.md's content is
# never read, so no wording inside it can move this cell. `-` = no file
# named exactly PLAN.md in the root; `Nd` = whole days its mtime lies behind
# the last commit (0d when it is as new or newer). The name is matched BYTE
# FOR BYTE through a glob, never by `[ -f PLAN.md ]` alone: APFS is
# case-insensitive by default, so that test is true for a `plan.md` and
# would report a plan that is not there. A glob matches the names readdir
# returns, as stored. A PLAN.md with no commit to compare against, or an
# unreadable mtime, is `?` - never `-`, which means "no plan", and never
# `0d`, which would be a measurement that did not happen.
# Display only: not in any sort key, not in --plan, not in SUM.
plancol="-"
for pl_f in "$d"/PLAN.m[d]; do
[ "${pl_f##*/}" = "PLAN.md" ] && [ -f "$pl_f" ] || continue
pl_m="$(stat -f %m "$pl_f" 2>/dev/null)"
if [ -z "$pl_m" ] || [ -z "$lastct" ]; then
plancol="?"
elif [ "$pl_m" -ge "$lastct" ]; then
plancol="0d"
else
plancol="$(( (lastct - pl_m) / 86400 ))d"
fi
done
if [ ! -f "$state" ]; then if [ ! -f "$state" ]; then
# No plan file, so no plan age: ALDER is blank rather than quietly showing # 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 # the commit age under a heading that means something else everywhere else
# in the table. The sort key keeps using it - order is unchanged. # in the table. The sort key keeps using it - order is unchanged.
printf '5|%06d|%s|-|-|%s|%s|-|%s|%s|-|%s|%s|%s|%s|(ingen STATE.md)\n' \ printf '5|%06d|%s|-|-|%s|%s|-|%s|%s|-|%s|%s|%s|%s|-|%s|(ingen STATE.md)\n' \
"$lastd" "$name" "$inbox" "$dirty" "$lastcol" "$d" \ "$lastd" "$name" "$inbox" "$dirty" "$lastcol" "$d" \
"$orders" "$ordersage" "$claimed" "$claimedage" "$orders" "$ordersage" "$claimed" "$claimedage" "$plancol"
continue continue
fi fi
@ -918,10 +946,11 @@ printf '%s\n' "$REPOS" | while IFS= read -r d; do
# `voy` sits between claimedage and neste, never after it: `neste` is free # `voy` sits between claimedage and neste, never after it: `neste` is free
# prose lifted out of a STATE.md and may contain a literal '|', so it has to # prose lifted out of a STATE.md and may contain a literal '|', so it has to
# stay the last field on the line. # stay the last field on the line.
# `plan` sits between voy and neste for the same reason.
voy="$(voyage_cell "$d")" voy="$(voyage_cell "$d")"
printf '%s|%06d|%s|%s|%s|%s|%s|%sd|%s|%s|%s|%s|%s|%s|%s|%s|%s\n' \ printf '%s|%06d|%s|%s|%s|%s|%s|%sd|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s\n' \
"$bucket" "$age" "$name" "$disp" "$cost" "$inbox" "$dirty" "$age" "$lastcol" "$d" \ "$bucket" "$age" "$name" "$disp" "$cost" "$inbox" "$dirty" "$age" "$lastcol" "$d" \
"${blockedon:--}" "$orders" "$ordersage" "$claimed" "$claimedage" "$voy" "$neste" "${blockedon:--}" "$orders" "$ordersage" "$claimed" "$claimedage" "$voy" "$plancol" "$neste"
done > "${TMPDIR:-/tmp}/board.$$" done > "${TMPDIR:-/tmp}/board.$$"
RECORDS="${TMPDIR:-/tmp}/board.$$" RECORDS="${TMPDIR:-/tmp}/board.$$"
@ -1078,20 +1107,20 @@ awk -F'|' '$4 ~ /^blocked/ && $11 != "-" && $11 != "" {print $11}' "$RECORDS" \
hdr() { hdr() {
printf '\n%s\n' "$1" printf '\n%s\n' "$1"
printf '%-32s %-34s %-14s %4s %7s %7s %7s %4s %6s %6s %s\n' \ printf '%-32s %-34s %-14s %4s %7s %7s %7s %4s %6s %6s %5s %s\n' \
"REPO" "STATUS" "KOST" "INN" "ORDRE" "FLY" "VOY" "DRT" "ALDER" "SISTE" "NESTE" "REPO" "STATUS" "KOST" "INN" "ORDRE" "FLY" "VOY" "DRT" "ALDER" "SISTE" "PLAN" "NESTE"
} }
rows() { rows() {
awk -F'|' -v b="$1" '$1==b' "$RECORDS" | sort -t'|' -k2,2n | \ 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 blockedon orders ordersage claimed claimedage voy neste; do while IFS='|' read -r bucket sortkey name status cost inbox dirty age last dir blockedon orders ordersage claimed claimedage voy plan neste; do
# `N:age` only when there is something to date. An empty queue prints a # `N:age` only when there is something to date. An empty queue prints a
# bare 0, never "0:0d" - absence must not borrow the shape of a # bare 0, never "0:0d" - absence must not borrow the shape of a
# measurement. # measurement.
o_cell="$orders"; [ "$ordersage" = "-" ] || o_cell="$orders:$ordersage" o_cell="$orders"; [ "$ordersage" = "-" ] || o_cell="$orders:$ordersage"
c_cell="$claimed"; [ "$claimedage" = "-" ] || c_cell="$claimed:$claimedage" c_cell="$claimed"; [ "$claimedage" = "-" ] || c_cell="$claimed:$claimedage"
printf '%-32s %-34s %-14s %4s %7s %7s %7s %4s %6s %6s %s\n' \ printf '%-32s %-34s %-14s %4s %7s %7s %7s %4s %6s %6s %5s %s\n' \
"$name" "$status" "$cost" "$inbox" "$o_cell" "$c_cell" "$voy" "$dirty" "$age" "$last" "$(trunc "$neste" "$NESTE_WIDTH")" "$name" "$status" "$cost" "$inbox" "$o_cell" "$c_cell" "$voy" "$dirty" "$age" "$last" "$plan" "$(trunc "$neste" "$NESTE_WIDTH")"
done done
} }
@ -1281,7 +1310,7 @@ brief() {
FILENAME==OWF { ow[$1] = $2 + 0; next } FILENAME==OWF { ow[$1] = $2 + 0; next }
{ name = $3; owed = (name in ow) ? ow[name] : 0; if (owed > 0) print owed, $0 } { name = $3; owed = (name in ow) ? ow[name] : 0; if (owed > 0) print owed, $0 }
' "$OWED" "$RECORDS" | sort -t'|' -k1,1nr | \ ' "$OWED" "$RECORDS" | sort -t'|' -k1,1nr | \
while IFS='|' read -r owed bucket sortkey name status cost inbox dirty age last dir blockedon orders ordersage claimed claimedage voy neste; do while IFS='|' read -r owed bucket sortkey name status cost inbox dirty age last dir blockedon orders ordersage claimed claimedage voy plan neste; do
printf ' %-32s INN %-4s %s\n' "$name" "$owed" "$status" printf ' %-32s INN %-4s %s\n' "$name" "$owed" "$status"
# Wrapped, not cut - the whole line is the point, but a 500-character one # 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 # is unreadable in a file nobody watched being written. Locale is set for
@ -1409,7 +1438,7 @@ dispatch() {
# `neste` is the LAST record field and is taken as "field N onward". This # `neste` is the LAST record field and is taken as "field N onward". This
# index once read 14 and was never moved as fields were inserted before # index once read 14 and was never moved as fields were inserted before
# `neste`, gluing three other columns onto the prose - move it with the record. # `neste`, gluing three other columns onto the prose - move it with the record.
d_neste="$(printf '%s' "$d_rec" | cut -d'|' -f17-)" d_neste="$(printf '%s' "$d_rec" | cut -d'|' -f18-)"
[ -d "$d_dir" ] || { echo "board: the directory for $D_REPO does not exist: $d_dir" >&2; exit 2; } [ -d "$d_dir" ] || { echo "board: the directory for $D_REPO does not exist: $d_dir" >&2; exit 2; }
# Rejected traits REFUSE. Degrading to a command without them is the worst # Rejected traits REFUSE. Degrading to a command without them is the worst
@ -1893,7 +1922,7 @@ plan() {
echo "" echo ""
pn=0 pn=0
while IFS='|' read -r why bucket sortkey name status cost inbox dirty age last dir blockedon orders ordersage claimed claimedage voy neste; do while IFS='|' read -r why bucket sortkey name status cost inbox dirty age last dir blockedon orders ordersage claimed claimedage voy plan neste; do
[ -n "$name" ] || continue [ -n "$name" ] || continue
pn=$((pn + 1)) pn=$((pn + 1))
printf 'tab=%s\n' "$pn" printf 'tab=%s\n' "$pn"
@ -1953,7 +1982,7 @@ inbox_plan() {
FILENAME==RCF { FILENAME==RCF {
name = $3 name = $3
rc_bucket[name] = $1; rc_status[name] = $4; rc_cost[name] = $5 rc_bucket[name] = $1; rc_status[name] = $4; rc_cost[name] = $5
rc_dir[name] = $10; rc_neste[name] = $17 rc_dir[name] = $10; rc_neste[name] = $18
next next
} }
{ {
@ -2186,10 +2215,11 @@ row_one() {
r_fly="$(printf '%s' "$r_rec" | cut -d'|' -f14)" r_fly="$(printf '%s' "$r_rec" | cut -d'|' -f14)"
r_flyage="$(printf '%s' "$r_rec" | cut -d'|' -f15)" r_flyage="$(printf '%s' "$r_rec" | cut -d'|' -f15)"
r_voy="$(printf '%s' "$r_rec" | cut -d'|' -f16)" r_voy="$(printf '%s' "$r_rec" | cut -d'|' -f16)"
r_plan="$(printf '%s' "$r_rec" | cut -d'|' -f17)"
# `neste` is free prose and may contain a literal '|', which is why it is the # `neste` is free prose and may contain a literal '|', which is why it is the
# last field of the record - so it is taken as "everything from 17 on", never # last field of the record - so it is taken as "everything from 18 on", never
# as field 17. # as field 18.
r_neste="$(printf '%s' "$r_rec" | cut -d'|' -f17-)" r_neste="$(printf '%s' "$r_rec" | cut -d'|' -f18-)"
# The TOKEN, not the display string: the table renders a blocked repo as # The TOKEN, not the display string: the table renders a blocked repo as
# `blocked>target` because one column has to carry both, and this rendering # `blocked>target` because one column has to carry both, and this rendering
@ -2217,6 +2247,7 @@ row_one() {
echo "alder=$r_alder" echo "alder=$r_alder"
echo "siste=$r_siste" echo "siste=$r_siste"
echo "upushet=$r_unpushed" echo "upushet=$r_unpushed"
echo "plan=$r_plan"
# LAST, and for the same reason it is last in the record: it is free prose # LAST, and for the same reason it is last in the record: it is free prose
# lifted out of a STATE.md, so anything placed after it would be unreachable # lifted out of a STATE.md, so anything placed after it would be unreachable
# for a consumer reading from the end. Printed WHOLE - the 38-character cut # for a consumer reading from the end. Printed WHOLE - the 38-character cut
@ -2259,6 +2290,15 @@ echo " Baerer samme N:Md - alderen paa den ELDSTE claimen. Ingenting frigir
echo "VOY = Voyage-prosjekter (brief i omloep). N:Md = antall og alderen paa det" echo "VOY = Voyage-prosjekter (brief i omloep). N:Md = antall og alderen paa det"
echo " STALESTE prosjektet - dager siden NYESTE artefakt der, dvs. hvor lenge" echo " STALESTE prosjektet - dager siden NYESTE artefakt der, dvs. hvor lenge"
echo " ingenting har skjedd. Bart 0 = ingen prosjekter. Detaljer: --voyage." echo " ingenting har skjedd. Bart 0 = ingen prosjekter. Detaljer: --voyage."
# PLAN is a filesystem reading and nothing more. It shows WHERE a destination
# is missing or untouched; whether the criterion inside it can be falsified is
# a judgement no column can make, and the legend says so rather than letting
# `0d` read as "this repo has a good plan".
echo "PLAN = rot-PLAN.md: '-' = ingen fil som heter noyaktig PLAN.md. Nd = dager"
echo " planens mtime ligger bak siste commit (0d = like ny eller nyere)."
echo " '?' = plan uten commit aa sammenligne med. Bedommer IKKE om kriteriet"
echo " kan felles, og leser aldri innholdet. touch/checkout nullstiller mtime"
echo " (samme forbehold som ALDER). Kun visning: aldri sortert, aldri summert."
echo "ALDER = dager siden STATE.md endret. SISTE = dager siden siste commit." 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 1)" -gt 0 ] && { hdr "BLOKKERT (venter paa ekstern avhengighet)"; rows 1; }

View file

@ -1,235 +0,0 @@
#!/bin/bash
# plan-column-eval.sh - the RED gate for board.sh's PLAN column, written
# before the column exists. It is NOT one of the five selftest suites and is
# deliberately not wired into `npm test`: a committed red check inside
# board-selftest.sh would turn the whole suite red on main and break the
# README number check, so "red for the right reason" could no longer be told
# apart from "red because something else broke". The session that builds the
# column moves these checks into board-selftest.sh (and updates the counts)
# once they are green.
#
# What the column is (operator decision 2026-09-17): a DISPLAY-ONLY reading of
# the filesystem, one value per scanned repo:
# - no file named exactly PLAN.md in the repo root
# Nd PLAN.md exists; N = whole days its mtime lies BEHIND the last commit
# (0d when PLAN.md is as new as, or newer than, the last commit)
# It never reads PLAN.md's content, never enters a sort, a --plan block or a
# sum, and does not judge whether a criterion can be falsified.
#
# Table cells are located by the header, never by a fixed offset: the column's
# position is the builder's choice. The locator splits header and row on runs
# of 2+ spaces, which relies on two properties the table already has - every
# cell is followed by at least two spaces, and NESTE (free prose) is last.
#
# ASCII only, bash 3.2 safe, never touches ~/repos or ~/.claude/coord.
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; }
NOW="$(date +%s)"
DAY=86400
mkrepo() { mkdir -p "$1" && git -C "$1" init -q 2>/dev/null; }
state() {
{
echo "# STATE - $2"
echo "## NESTE - START HER"
echo "<!-- board: status=$3; blocked-on=-; next-cost=Sonnet 5/high -->"
echo "Next step for $2."
} > "$1/STATE.md"
}
# commit_at <dir> <epoch> <msg>: commit everything with a fixed committer date.
commit_at() {
git -C "$1" add -A >/dev/null 2>&1
GIT_AUTHOR_DATE="@$2 +0000" GIT_COMMITTER_DATE="@$2 +0000" \
git -C "$1" -c user.email=t@t -c user.name=t commit -qm "$3" >/dev/null 2>&1
}
touch_at() { touch -t "$(date -r "$2" +%Y%m%d%H%M.%S)" "$1"; }
last_commit() { git -C "$1" log -1 --format=%ct 2>/dev/null; }
mtime() { stat -f %m "$1"; }
# table_cell <table-output> <repo>: the PLAN cell of <repo>'s row, read under
# the nearest preceding header line. Empty when the header has no PLAN column.
table_cell() {
printf '%s\n' "$1" | awk -v repo="$2" '
function split2(s, arr, n) { gsub(/ +/, "\t", s); sub(/^\t/, "", s); return split(s, arr, "\t") }
/^REPO / { n = split2($0, h); col = 0; for (i = 1; i <= n; i++) if (h[i] == "PLAN") col = i; next }
$1 == repo && col > 0 { split2($0, r); print r[col]; exit }'
}
row_plan() { printf '%s\n' "$1" | sed -n 's/^plan=//p'; }
board() { "$BOARD" --roots "$ROOT" "$@" 2>/dev/null; }
echo "plan-column-eval (root: $ROOT, mailbox: $CLAUDE_COORD_DIR)"
# --- Fixtures ---------------------------------------------------------------
# p-none: no PLAN.md anywhere.
mkrepo "$ROOT/p-none"; state "$ROOT/p-none" p-none planned
commit_at "$ROOT/p-none" $((NOW - 3 * DAY)) init
# p-fresh: PLAN.md newer than the last commit -> 0d. The file is EMPTY: a value
# computed from content could not produce 0d here, so this also shows that
# the column is a filesystem reading.
mkrepo "$ROOT/p-fresh"; state "$ROOT/p-fresh" p-fresh planned
commit_at "$ROOT/p-fresh" $((NOW - 2 * DAY)) init
: > "$ROOT/p-fresh/PLAN.md"
touch_at "$ROOT/p-fresh/PLAN.md" "$NOW"
# p-stale: PLAN.md 5 days and 3 hours older than the last commit -> 5d. Its
# content looks like a value on purpose ("PLAN=99d"); it must not be read.
mkrepo "$ROOT/p-stale"; state "$ROOT/p-stale" p-stale planned
printf '# PLAN\nPLAN=99d\n' > "$ROOT/p-stale/PLAN.md"
P_STALE_CT=$((NOW - 3600))
commit_at "$ROOT/p-stale" "$P_STALE_CT" init
touch_at "$ROOT/p-stale/PLAN.md" $((P_STALE_CT - 5 * DAY - 3 * 3600))
# p-docs: PLAN.md in docs/ only - the wrong place by convention.
mkrepo "$ROOT/p-docs"; state "$ROOT/p-docs" p-docs planned
mkdir -p "$ROOT/p-docs/docs"; echo "# PLAN" > "$ROOT/p-docs/docs/PLAN.md"
commit_at "$ROOT/p-docs" $((NOW - DAY)) init
# p-lower: lowercase plan.md in the root. On a case-insensitive filesystem
# (APFS default) `[ -f PLAN.md ]` is TRUE here, so a board that asks the
# filesystem alone would report a plan that is not there. The name has to be
# compared byte for byte against what the directory actually stores (e.g. a
# directory listing matched exactly against PLAN.md).
mkrepo "$ROOT/p-lower"; state "$ROOT/p-lower" p-lower planned
echo "# plan" > "$ROOT/p-lower/plan.md"
commit_at "$ROOT/p-lower" $((NOW - DAY)) init
# --- Ground truth for the fixtures (these must be green today) -------------
[ -z "$(git -C "$ROOT/p-stale" status --porcelain)" ]
check "fixture: p-stale is committed clean" $?
P_STALE_EXP="$(( ($(last_commit "$ROOT/p-stale") - $(mtime "$ROOT/p-stale/PLAN.md")) / DAY ))d"
[ "$P_STALE_EXP" != "0d" ]
check "fixture: p-stale's expected value is computed from its timestamps and is not 0d ($P_STALE_EXP)" $?
[ "$(mtime "$ROOT/p-fresh/PLAN.md")" -ge "$(last_commit "$ROOT/p-fresh")" ]
check "fixture: p-fresh's PLAN.md is newer than its last commit" $?
if [ -f "$ROOT/p-lower/PLAN.md" ]; then LOWER_ARMED=0; else LOWER_ARMED=1; fi
check "fixture: the case trap is armed here - [ -f PLAN.md ] is true for plan.md (case-insensitive fs)" "$LOWER_ARMED"
ls -1 "$ROOT/p-lower" | grep -qx 'PLAN.md'; rc=$?
[ "$rc" -ne 0 ]; check "fixture: a byte-exact directory listing does NOT see PLAN.md in p-lower" $?
TABLE="$(board)"
N_REPOS="$(printf '%s\n' "$TABLE" | sed -n 's/^BOARD .*(\([0-9][0-9]*\) repo).*/\1/p' | head -1)"
[ "${N_REPOS:-0}" -eq 5 ]; check "fixture: board scanned the 5 fixture repos (header says ${N_REPOS:-nothing})" $?
# --- 1. Three different values from three repos ----------------------------
printf '%s\n' "$TABLE" | grep -q '^REPO .* PLAN '
check "1: the table header carries a PLAN column" $?
[ "$(table_cell "$TABLE" p-none)" = "-" ]; check "1: no root PLAN.md -> '-'" $?
[ "$(table_cell "$TABLE" p-fresh)" = "0d" ]; check "1: PLAN.md newer than the last commit -> '0d'" $?
[ "$(table_cell "$TABLE" p-stale)" = "$P_STALE_EXP" ]
check "1: PLAN.md older than the last commit -> '$P_STALE_EXP' (from the fixture's own timestamps)" $?
V3="$(table_cell "$TABLE" p-none) $(table_cell "$TABLE" p-fresh) $(table_cell "$TABLE" p-stale)"
[ "$(printf '%s\n' $V3 | sort -u | wc -l | tr -d ' ')" -eq 3 ]
check "1: the three fixtures show three DIFFERENT values ($V3)" $?
# --- 2. Wrong place and wrong case are not a plan ---------------------------
[ "$(table_cell "$TABLE" p-docs)" = "-" ]; check "2: docs/PLAN.md only -> '-'" $?
[ "$(table_cell "$TABLE" p-lower)" = "-" ]; check "2: lowercase plan.md in the root -> '-' (byte-exact name)" $?
# --- 3. --row carries plan= with the table's value ---------------------------
for r in p-none p-fresh p-stale p-docs p-lower; do
RV="$(row_plan "$(board --row "$r")")"
TV="$(table_cell "$TABLE" "$r")"
[ -n "$RV" ] && [ "$RV" = "$TV" ]
check "3: --row $r has plan=${RV:-<missing>} equal to the table cell (${TV:-<missing>})" $?
done
[ "$(board --row p-none | grep -c '^plan=')" -eq 1 ]
check "3: --row emits exactly one plan= line" $?
[ "$(board --row p-none | tail -1 | cut -d= -f1)" = "neste" ]
check "3: neste= is still the last --row line" $?
# --- 4. Not in the sort, not in --plan blocks, not summed --------------------
# Two repos identical in every sorted property; run 1 gives s-a the stale plan
# and s-b the fresh one, run 2 swaps them. Any sort key built on PLAN would
# reverse them in one of the two runs.
SROOT="$(mktemp -d)"
for r in s-a s-b; do
mkrepo "$SROOT/$r"; state "$SROOT/$r" "$r" planned
echo "<!-- route: path=known; verification=strong; reversibility=cheap; scope=local; rationale=x -->" >> "$SROOT/$r/STATE.md"
: > "$SROOT/$r/PLAN.md"
commit_at "$SROOT/$r" $((NOW - DAY)) init
touch_at "$SROOT/$r/STATE.md" $((NOW - 2 * DAY))
done
touch_at "$SROOT/s-a/PLAN.md" $((NOW - 9 * DAY)); touch_at "$SROOT/s-b/PLAN.md" "$NOW"
T1="$("$BOARD" --roots "$SROOT" 2>/dev/null)"; P1="$("$BOARD" --roots "$SROOT" --plan 2>/dev/null)"
touch_at "$SROOT/s-a/PLAN.md" "$NOW"; touch_at "$SROOT/s-b/PLAN.md" $((NOW - 9 * DAY))
T2="$("$BOARD" --roots "$SROOT" 2>/dev/null)"; P2="$("$BOARD" --roots "$SROOT" --plan 2>/dev/null)"
A1="$(table_cell "$T1" s-a)"; A2="$(table_cell "$T2" s-a)"
[ -n "$A1" ] && [ -n "$A2" ] && [ "$A1" != "$A2" ]
check "4: precondition - the swap really changed s-a's PLAN value (${A1:-<missing>} -> ${A2:-<missing>})" $?
O1="$(printf '%s\n' "$T1" | awk '$1 ~ /^s-/ {print $1}' | tr '\n' ' ')"
O2="$(printf '%s\n' "$T2" | awk '$1 ~ /^s-/ {print $1}' | tr '\n' ' ')"
[ -n "$A1" ] && [ "$O1" = "$O2" ]; check "4: table order is identical across the swap ($O1| $O2)" $?
Q1="$(printf '%s\n' "$P1" | sed -n 's/^repo=//p' | tr '\n' ' ')"
Q2="$(printf '%s\n' "$P2" | sed -n 's/^repo=//p' | tr '\n' ' ')"
[ -n "$A1" ] && [ -n "$Q1" ] && [ "$Q1" = "$Q2" ]; check "4: --plan order is identical across the swap ($Q1| $Q2)" $?
if printf '%s\n' "$P1" | grep -qi '^plan='; then rc=1; else rc=0; fi
[ -n "$A1" ] && [ "$rc" -eq 0 ]; check "4: --plan blocks carry no plan= key" $?
S1="$(printf '%s\n' "$T1" | grep '^SUM:')"; S2="$(printf '%s\n' "$T2" | grep '^SUM:')"
if printf '%s' "$S1" | grep -qi 'plan'; then rc=1; else rc=0; fi
[ -n "$A1" ] && [ "$rc" -eq 0 ] && [ "$S1" = "$S2" ]; check "4: the SUM line neither mentions PLAN nor moves with it" $?
/bin/rm -rf "$SROOT" 2>/dev/null
# --- 5. Replay: the shape C failed on ----------------------------------------
# Many commits, no root PLAN.md, the destination written in docs/*.local.md,
# and a release tag late in the window. A heuristic keyed on "activity without
# a release" went silent at the tag; this column must read '-' at every step.
LROOT="$(mktemp -d)"
L="$LROOT/loop-repo"
mkrepo "$L"; state "$L" loop-repo in-progress
mkdir -p "$L/docs"; echo "destination lives here" > "$L/docs/destination.local.md"
BASE=$((NOW - 60 * DAY)); HITS=0; STEPS=0
i=0
while [ "$i" -lt 6 ]; do
echo "step $i" > "$L/work.txt"
commit_at "$L" $((BASE + i * 10 * DAY)) "step $i"
[ "$i" -eq 4 ] && git -C "$L" -c tag.gpgSign=false tag v9.9.0 >/dev/null 2>&1
LT="$("$BOARD" --roots "$LROOT" 2>/dev/null)"
STEPS=$((STEPS + 1))
[ "$(table_cell "$LT" loop-repo)" = "-" ] && HITS=$((HITS + 1))
i=$((i + 1))
done
[ -n "$(git -C "$L" tag -l v9.9.0)" ]; check "5: fixture - the release tag exists in the replay" $?
[ "$HITS" -eq "$STEPS" ]; check "5: '-' at every step of the replay, before and after the tag ($HITS of $STEPS)" $?
/bin/rm -rf "$LROOT" 2>/dev/null
# --- 6. Denominator: one value per scanned repo ------------------------------
WITH=0
for r in p-none p-fresh p-stale p-docs p-lower; do
[ -n "$(table_cell "$TABLE" "$r")" ] && WITH=$((WITH + 1))
done
# A non-empty, non-zero denominator first: with an empty scan both sides are 0
# and the comparison alone would pass on nothing.
[ "${N_REPOS:-0}" -gt 0 ] && [ "$WITH" -eq "$N_REPOS" ]
check "6: every scanned repo has a PLAN value ($WITH of ${N_REPOS:-?})" $?
printf '%s\n' "$TABLE" | grep -q '^undersoekt: '
check "6: the scan's own denominator line is still printed" $?
# --- 7. Legend -----------------------------------------------------------------
LEG="$(printf '%s\n' "$TABLE" | awk '/^PLAN = /{on=1} on && /^[A-Z]+ = / && !/^PLAN = /{on=0} on')"
[ -n "$LEG" ]; check "7: the legend has a 'PLAN = ' entry" $?
printf '%s' "$LEG" | grep -q 'IKKE' && printf '%s' "$LEG" | grep -qi 'felle'
check "7: the legend says the column does NOT judge whether the criterion can be falsified" $?
printf '%s' "$LEG" | grep -q 'touch' && printf '%s' "$LEG" | grep -q 'checkout'
check "7: the legend says touch/checkout reset the mtime" $?
if printf '%s' "$LEG" | LC_ALL=C grep -q '[^ -~]'; then rc=1; else rc=0; fi
[ -n "$LEG" ] && [ "$rc" -eq 0 ]; check "7: the legend is ASCII like the rest of the table" $?
echo ""
echo "plan-column-eval: $PASS passed, $FAIL failed"
[ "$FAIL" -eq 0 ] || exit 1
exit 0

View file

@ -75,6 +75,7 @@ the `VOY` column).
| `DRT` | Uncommitted files. | | `DRT` | Uncommitted files. |
| `ALDER` | Days since STATE.md last changed — the age of the *plan*. `-` where the repo has none. | | `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. | | `SISTE` | Days since the last commit — the age of the *work*. `-` where the repo has no commits yet. |
| `PLAN` | Root `PLAN.md` as a filesystem reading: `-` = no file named exactly `PLAN.md` in the root; `Nd` = days the plan's mtime lies behind the last commit (`0d` = as new or newer); `?` = a plan with no commit to compare against. Read it as *where a destination is missing or untouched* — it never judges whether the criterion inside can be falsified, and it **never ranks**: do not order repos by it. |
| `NESTE` | First line of the STATE.md next-step block, truncated. | | `NESTE` | First line of the STATE.md next-step block, truncated. |
## Briefs in flight — `--voyage` ## Briefs in flight — `--voyage`