test(board): pin the "field inserted before neste" class; 16 of 16 mutants die

An independent review ran 16 mutants of the PLAN column against the 461
board checks and 5 survived:
- --row taking neste from a stale index (neste=5d|...)
- --inbox-plan taking neste from a stale index (neste=5d)
- the no-STATE.md record one field short (plan=(ingen STATE.md))
- Nd measured behind NOW instead of behind the last commit
- the '?' branch (a plan with no commit) rendered as '-'

Section 34 part 8 adds a fixture for each: a NESTE with a '|' inside,
pinned exactly in --row and --inbox-plan; a repo with no STATE.md; a repo
whose last commit is 30 days old, so the two definitions give different
numbers; a repo with a plan and no commit. Rebuilt from this tree and run
against the full suite: 16 of 16 mutants now fail it.

The same fixtures exposed two defects, fixed here:
- --inbox-plan took neste as ONE field and cut the prose at its first '|'
  (also in 0.34.0); it now takes that field onward.
- a symlinked PLAN.md was aged by the link's own mtime; it now uses
  stat -L, so the plan's age is the target's.

Docs: CHANGELOG no longer says "field 17 onward" (the index is 18) and
now records the no-STATE.md record fix and the --inbox-plan fix; README's
requirement says macOS, since board.sh uses BSD stat and renders nothing
under GNU stat.

board-selftest 461 -> 478 (badge 978). Suites: coord 257, board 478,
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 17:58:49 +02:00
commit 6f4908a5dc
5 changed files with 145 additions and 15 deletions

View file

@ -12,7 +12,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **`PLAN` column in the board table, and `plan=` in `--row`.** A filesystem - **`PLAN` column in the board table, and `plan=` in `--row`.** A filesystem
reading of the repo root: `-` = no file named exactly `PLAN.md`; `Nd` = 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 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 newer); `?` = a plan with no commit to compare against. A symlinked
`PLAN.md` is aged by its target. The content is never
read, and the column does not judge whether a criterion can be falsified - 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 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: case-insensitivity makes `[ -f PLAN.md ]` true for `plan.md`. Display only:
@ -25,8 +26,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
The index that takes "the last field onward" was written when the record had The index that takes "the last field onward" was written when the record had
14 fields and was never moved as ORDRE, FLY and VOY were inserted before 14 fields and was never moved as ORDRE, FLY and VOY were inserted before
`neste`, so a dispatch block read `neste=0|-|0|<the NESTE prose>`. It now `neste`, so a dispatch block read `neste=0|-|0|<the NESTE prose>`. It now
takes field 17 onward, and a selftest check pins the exact start of the takes the field after the last column onward (field 18, since the `PLAN`
prose so the next inserted field turns it red. column below moved it again), and a selftest check pins the exact start of
the prose so the next inserted field turns it red.
- **`--row` and `--dispatch` for a repo with no STATE.md carried shifted
values.** That record is written by its own printf, which lacked the `VOY`
field, so `--row` read `voy=(ingen STATE.md)` and an empty `neste=`. The
record is now complete; a fixture with no STATE.md pins it.
- **`--inbox-plan` cut NESTE at its first `|`.** It read `neste` as one field
instead of "that field onward"; the whole prose now comes through, as it
already did in `--row` and `--dispatch`.
- **README said "macOS or Linux".** `board.sh` uses BSD `stat` throughout and
renders no rows under GNU `stat`; the requirement now says macOS.
## [0.34.0] - 2026-09-12 ## [0.34.0] - 2026-09-12

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` (461 checks). STATE.md and no mailbox. Pinned by `board-selftest.sh` (478 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
@ -1489,6 +1489,23 @@ marketplace plugin. Three components, one boundary:
"last field onward" index now moves with the record (17 -> 18 here). F6 "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. counts DRT from the right, so its `NF-3` became `NF-4` for the same reason.
**An independent review then ran 16 mutants against the 461 checks and 5
survived** - three of them the same class as the dispatch defect (`--row`
and `--inbox-plan` reading `neste` from a stale index, and the no-STATE.md
record one field short), one the column's DEFINITION (days behind now
instead of behind the last commit - every fixture's commit was close enough
to now that the two coincided), and one the `?` branch. Section 34 part 8
now has a fixture for each, and all 16 die. Every "last field onward"
reader is pinned on the EXACT start of the prose with a `|` inside it,
because a reader taking one field instead of "onward" was a second,
pre-existing defect in `--inbox-plan` that the same fixture exposed.
A symlinked PLAN.md is aged by its target (`stat -L`). Stated, not closed:
the 34-character nested key still shifts its row (the section's own header
splitter would misread such a row; fixture names are short on purpose), and
the "trap is armed" fixture check is red on a case-sensitive volume - this
repo is macOS-only (BSD `stat`), and a case-sensitive APFS volume is the
remaining gap.
- **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 —
@ -1537,7 +1554,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 (461/461), `bash scripts/board-selftest.sh` must exit 0 (478/478),
`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-961-blue) ![Selftest checks](https://img.shields.io/badge/selftest_checks-978-blue)
--- ---
@ -152,7 +152,7 @@ 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. **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; a symlinked `PLAN.md` is aged by its target. 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`.
@ -194,14 +194,14 @@ Note that raising the inbox's priority (Rule 7) deliberately does **not** widen
## Requirements ## Requirements
- macOS or Linux with bash 3.2+ (the scripts are deliberately bash-3.2-safe and ASCII-only). - macOS with bash 3.2+ (the scripts are deliberately bash-3.2-safe and ASCII-only). `board.sh` uses BSD `stat` (`stat -f`), so it does not run on Linux as written.
- Node.js >= 18 for the SessionStart and PreToolUse hooks (zero npm dependencies). - Node.js >= 18 for the SessionStart and PreToolUse hooks (zero npm dependencies).
- `git` is required to derive repo identity automatically. Without it, pass `--from`/`--repo` explicitly; the engine refuses to guess an identity from the working directory. - `git` is required to derive repo identity automatically. Without it, pass `--from`/`--repo` explicitly; the engine refuses to guess an identity from the working directory.
## 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 # 461 checks against a throwaway repo tree bash scripts/board-selftest.sh # 478 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

@ -3483,7 +3483,7 @@ pc_state() {
echo "# STATE - $2" echo "# STATE - $2"
echo "## NESTE - START HER" echo "## NESTE - START HER"
echo "<!-- board: status=$3; blocked-on=-; next-cost=Sonnet 5/high -->" echo "<!-- board: status=$3; blocked-on=-; next-cost=Sonnet 5/high -->"
echo "Next step for $2." echo "${4:-Next step for $2.}"
} > "$1/STATE.md" } > "$1/STATE.md"
} }
# pc_commit_at <dir> <epoch> <msg>: commit everything with a fixed committer date. # pc_commit_at <dir> <epoch> <msg>: commit everything with a fixed committer date.
@ -3524,7 +3524,14 @@ 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 # 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. # 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 # Its NESTE carries a literal '|' and it holds one unhandled message, so the
# same repo pins where every "last field onward" reader starts AND that it
# really reads onward (section 8 below).
mkrepo "$PC_ROOT/p-stale"
pc_state "$PC_ROOT/p-stale" p-stale planned "Next step for p-stale | then the rest."
mkdir -p "$PC_COORD/p-stale/inbox"
printf -- '---\nfrom: x\nto: p-stale\nsubject: s\nreply-expected: yes\n---\nm\n' \
> "$PC_COORD/p-stale/inbox/20260901T101010Z-1-from-x.md"
printf '# PLAN\nPLAN=99d\n' > "$PC_ROOT/p-stale/PLAN.md" printf '# PLAN\nPLAN=99d\n' > "$PC_ROOT/p-stale/PLAN.md"
PC_P_STALE_CT=$((PC_NOW - 3600)) PC_P_STALE_CT=$((PC_NOW - 3600))
pc_commit_at "$PC_ROOT/p-stale" "$PC_P_STALE_CT" init pc_commit_at "$PC_ROOT/p-stale" "$PC_P_STALE_CT" init
@ -3544,6 +3551,46 @@ mkrepo "$PC_ROOT/p-lower"; pc_state "$PC_ROOT/p-lower" p-lower planned
echo "# plan" > "$PC_ROOT/p-lower/plan.md" echo "# plan" > "$PC_ROOT/p-lower/plan.md"
pc_commit_at "$PC_ROOT/p-lower" $((PC_NOW - PC_DAY)) init pc_commit_at "$PC_ROOT/p-lower" $((PC_NOW - PC_DAY)) init
# The next four fixtures exist because an independent review ran 16 mutants of
# the column against this suite and 5 survived. Each fixture below is the one
# a surviving mutant could not get past.
# p-nostate: a repo with NO STATE.md. Its record is written by a separate
# printf, which is exactly where a field was once missing (0.34.0 printed
# voy=(ingen STATE.md) and an empty neste=), so a short record must show here.
# The last commit is 40 days old.
mkrepo "$PC_ROOT/p-nostate"
: > "$PC_ROOT/p-nostate/PLAN.md"
PC_NS_CT=$((PC_NOW - 40 * PC_DAY))
pc_commit_at "$PC_ROOT/p-nostate" "$PC_NS_CT" init
pc_touch_at "$PC_ROOT/p-nostate/PLAN.md" $((PC_NS_CT - 3 * PC_DAY - 3600))
# p-old: the column's DEFINITION. The last commit is 30 days old, so "days
# behind the last commit" (10) and "days behind now" (40) are different
# numbers; every fixture above has a commit close enough to now that the two
# readings coincide.
mkrepo "$PC_ROOT/p-old"; pc_state "$PC_ROOT/p-old" p-old planned
: > "$PC_ROOT/p-old/PLAN.md"
PC_OLD_CT=$((PC_NOW - 30 * PC_DAY))
pc_commit_at "$PC_ROOT/p-old" "$PC_OLD_CT" init
pc_touch_at "$PC_ROOT/p-old/PLAN.md" $((PC_OLD_CT - 10 * PC_DAY - 5 * 3600))
# p-nocommit: a plan and no commit at all. There is nothing to lag behind, so
# the honest cell is '?' - never '-', which would claim there is no plan.
mkrepo "$PC_ROOT/p-nocommit"; pc_state "$PC_ROOT/p-nocommit" p-nocommit planned
: > "$PC_ROOT/p-nocommit/PLAN.md"
# p-link: PLAN.md is a symlink. The cell must read the PLAN's age, i.e. the
# target's mtime; the link's own mtime is when the link was made, and reading
# it turns an 8-day-old plan into 0d.
mkrepo "$PC_ROOT/p-link"; pc_state "$PC_ROOT/p-link" p-link planned
mkdir -p "$PC_ROOT/p-link/docs"; echo "# PLAN" > "$PC_ROOT/p-link/docs/real-plan.md"
ln -s docs/real-plan.md "$PC_ROOT/p-link/PLAN.md"
PC_LINK_CT=$((PC_NOW - 2 * 3600))
pc_commit_at "$PC_ROOT/p-link" "$PC_LINK_CT" init
pc_touch_at "$PC_ROOT/p-link/docs/real-plan.md" $((PC_LINK_CT - 8 * PC_DAY - 3600))
touch -h "$PC_ROOT/p-link/PLAN.md"
# --- Ground truth for the fixtures (these must be green today) ------------- # --- Ground truth for the fixtures (these must be green today) -------------
[ -z "$(git -C "$PC_ROOT/p-stale" status --porcelain)" ] [ -z "$(git -C "$PC_ROOT/p-stale" status --porcelain)" ]
check "plan: fixture - p-stale is committed clean" $? check "plan: fixture - p-stale is committed clean" $?
@ -3559,7 +3606,7 @@ ls -1 "$PC_ROOT/p-lower" | grep -qx 'PLAN.md'; PC_rc=$?
PC_TABLE="$(pc_board)" 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="$(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})" $? [ "${PC_N_REPOS:-0}" -eq 9 ]; check "plan: fixture - board scanned the 9 fixture repos (header says ${PC_N_REPOS:-nothing})" $?
# --- 1. Three different values from three repos ---------------------------- # --- 1. Three different values from three repos ----------------------------
printf '%s\n' "$PC_TABLE" | grep -q '^REPO .* PLAN ' printf '%s\n' "$PC_TABLE" | grep -q '^REPO .* PLAN '
@ -3577,7 +3624,7 @@ check "plan 1: the three fixtures show three DIFFERENT values ($PC_V3)" $?
[ "$(pc_table_cell "$PC_TABLE" p-lower)" = "-" ]; check "plan 2: lowercase plan.md in the root -> '-' (byte-exact name)" $? [ "$(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 --------------------------- # --- 3. --row carries plan= with the table's value ---------------------------
for PC_r in p-none p-fresh p-stale p-docs p-lower; do for PC_r in p-none p-fresh p-stale p-docs p-lower p-nostate p-old p-nocommit p-link; do
PC_RV="$(pc_row_plan "$(pc_board --row "$PC_r")")" PC_RV="$(pc_row_plan "$(pc_board --row "$PC_r")")"
PC_TV="$(pc_table_cell "$PC_TABLE" "$PC_r")" PC_TV="$(pc_table_cell "$PC_TABLE" "$PC_r")"
[ -n "$PC_RV" ] && [ "$PC_RV" = "$PC_TV" ] [ -n "$PC_RV" ] && [ "$PC_RV" = "$PC_TV" ]
@ -3645,7 +3692,7 @@ done
# --- 6. Denominator: one value per scanned repo ------------------------------ # --- 6. Denominator: one value per scanned repo ------------------------------
PC_WITH=0 PC_WITH=0
for PC_r in p-none p-fresh p-stale p-docs p-lower; do for PC_r in p-none p-fresh p-stale p-docs p-lower p-nostate p-old p-nocommit p-link; do
[ -n "$(pc_table_cell "$PC_TABLE" "$PC_r")" ] && PC_WITH=$((PC_WITH + 1)) [ -n "$(pc_table_cell "$PC_TABLE" "$PC_r")" ] && PC_WITH=$((PC_WITH + 1))
done done
# A non-empty, non-zero denominator first: with an empty scan both sides are 0 # A non-empty, non-zero denominator first: with an empty scan both sides are 0
@ -3665,6 +3712,55 @@ 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 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" $? [ -n "$PC_LEG" ] && [ "$PC_rc" -eq 0 ]; check "plan 7: the legend is ASCII like the rest of the table" $?
# --- 8. What the surviving mutants got past --------------------------------
# neste= start, pinned EXACTLY in the two renderings that take "the last field
# onward" besides --dispatch (which has its own check in section 18). A field
# inserted before `neste` with an index left behind shows up as a prefix
# ("5d|Next step ..."), and a reader that takes ONE field instead of "onward"
# cuts the prose at its first '|'.
PC_NESTE='Next step for p-stale | then the rest.'
pc_board --row p-stale | grep -qxF "neste=$PC_NESTE"
check "plan 8: --row neste= is the NESTE prose exactly, '|' included" $?
PC_IP="$(pc_board --inbox-plan)"
printf '%s\n' "$PC_IP" | grep -qx 'repo=p-stale'
check "plan 8: fixture - --inbox-plan has a block for p-stale" $?
printf '%s\n' "$PC_IP" | awk 'BEGIN{RS=""} /(^|\n)repo=p-stale(\n|$)/' | grep -qxF "neste=$PC_NESTE"
check "plan 8: --inbox-plan neste= is the NESTE prose exactly, '|' included" $?
# A repo with no STATE.md still gets its measured value, and its neste= is the
# placeholder rather than an empty line or a value shifted from another field.
PC_NS_EXP="$(( (PC_NS_CT - $(pc_mtime "$PC_ROOT/p-nostate/PLAN.md")) / PC_DAY ))d"
PC_NS_ROW="$(pc_board --row p-nostate)"
printf '%s\n' "$PC_NS_ROW" | grep -qx "plan=$PC_NS_EXP"
check "plan 8: no STATE.md - --row plan= is the measured value ($PC_NS_EXP)" $?
printf '%s\n' "$PC_NS_ROW" | grep -qxF 'neste=(ingen STATE.md)'
check "plan 8: no STATE.md - --row neste= is the placeholder, not empty or shifted" $?
[ "$(pc_table_cell "$PC_TABLE" p-nostate)" = "$PC_NS_EXP" ]
check "plan 8: no STATE.md - the table cell is the measured value too" $?
# The definition: behind the LAST COMMIT, not behind now.
PC_OLD_EXP="$(( (PC_OLD_CT - $(pc_mtime "$PC_ROOT/p-old/PLAN.md")) / PC_DAY ))d"
PC_OLD_NOW="$(( (PC_NOW - $(pc_mtime "$PC_ROOT/p-old/PLAN.md")) / PC_DAY ))d"
[ "$PC_OLD_EXP" != "$PC_OLD_NOW" ]
check "plan 8: fixture - p-old separates 'behind commit' ($PC_OLD_EXP) from 'behind now' ($PC_OLD_NOW)" $?
[ "$(pc_table_cell "$PC_TABLE" p-old)" = "$PC_OLD_EXP" ]
check "plan 8: Nd counts days behind the LAST COMMIT, not behind now ($PC_OLD_EXP)" $?
# No commit: '?' in both renderings.
git -C "$PC_ROOT/p-nocommit" log -1 >/dev/null 2>&1
[ $? -ne 0 ]; check "plan 8: fixture - p-nocommit really has no commit" $?
[ "$(pc_table_cell "$PC_TABLE" p-nocommit)" = "?" ]
check "plan 8: a plan with no commit reads '?', never '-'" $?
pc_board --row p-nocommit | grep -qxF 'plan=?'
check "plan 8: --row carries the same '?'" $?
# Symlink: the target's age, not the link's.
PC_LINK_EXP="$(( (PC_LINK_CT - $(pc_mtime "$PC_ROOT/p-link/docs/real-plan.md")) / PC_DAY ))d"
[ "$(stat -f %m "$PC_ROOT/p-link/PLAN.md")" -ge "$PC_LINK_CT" ] && [ "$PC_LINK_EXP" != "0d" ]
check "plan 8: fixture - the link itself is newer than the commit, its target is $PC_LINK_EXP behind" $?
[ "$(pc_table_cell "$PC_TABLE" p-link)" = "$PC_LINK_EXP" ]
check "plan 8: a symlinked PLAN.md reads the TARGET's age ($PC_LINK_EXP), not the link's" $?
/bin/rm -rf "$PC_ROOT" "$PC_COORD" 2>/dev/null /bin/rm -rf "$PC_ROOT" "$PC_COORD" 2>/dev/null
echo "" echo ""

View file

@ -838,7 +838,9 @@ printf '%s\n' "$REPOS" | while IFS= read -r d; do
plancol="-" plancol="-"
for pl_f in "$d"/PLAN.m[d]; do for pl_f in "$d"/PLAN.m[d]; do
[ "${pl_f##*/}" = "PLAN.md" ] && [ -f "$pl_f" ] || continue [ "${pl_f##*/}" = "PLAN.md" ] && [ -f "$pl_f" ] || continue
pl_m="$(stat -f %m "$pl_f" 2>/dev/null)" # -L: the PLAN's age is the target's mtime; a symlink's own mtime is only
# when the link was made.
pl_m="$(stat -L -f %m "$pl_f" 2>/dev/null)"
if [ -z "$pl_m" ] || [ -z "$lastct" ]; then if [ -z "$pl_m" ] || [ -z "$lastct" ]; then
plancol="?" plancol="?"
elif [ "$pl_m" -ge "$lastct" ]; then elif [ "$pl_m" -ge "$lastct" ]; then
@ -1982,7 +1984,11 @@ 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
# neste is the LAST field and may itself contain a pipe: take field 18
# ONWARD, never field 18 alone - a single field cuts the prose at its
# first pipe. The index moves with the record.
rc_dir[name] = $10; rc_neste[name] = $18 rc_dir[name] = $10; rc_neste[name] = $18
for (i = 19; i <= NF; i++) rc_neste[name] = rc_neste[name] "|" $i
next next
} }
{ {