fix(board): exact-match status and route-trait tokens, not a prefix
board.sh's status classifier and route_cmd_for()'s four-trait extraction both used a [a-z-]* sed capture that stops at the first byte outside that class instead of running to the field's real boundary. status=done2 silently classified as done (excluded from --plan like a real done repo, shown green); status=Planned captured as empty and read as "?" (no board line at all), feeding the MERK footer a false count. route_cmd_for() had the same defect on all four traits: path=known2 truncated to known, which route.sh's own exact-match validation then accepted, producing a safely-worded but WRONG startup command instead of a refusal. Fixed by capturing to the next ';' or the closing '-->' (the same [^;>]* + trim shape next-cost already used), so the exact-match case statements downstream see the real, un-truncated value. Selftest: repo-status-prefix, repo-status-case, repo-trait-prefix and repo-revers-prefix pin all four cases, with known-positive controls that a genuinely absent board line still reads ? and a genuinely valid route line still derives a real command. All four suites green: coord 220, board 229, route 69, guard 40. Verified no MALFORMED regression against the real ~/repos tree (empty set before and after). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DxLmbNN6qswBuu6XkSYVrt
This commit is contained in:
parent
165385be5f
commit
dde392d79d
3 changed files with 161 additions and 6 deletions
33
CLAUDE.md
33
CLAUDE.md
|
|
@ -217,7 +217,10 @@ marketplace plugin. Three components, one boundary:
|
||||||
selected with `board.sh`'s own anchor (`^<!-- board:`) and the status token
|
selected with `board.sh`'s own anchor (`^<!-- board:`) and the status token
|
||||||
compared exactly, so prose saying `status=done` never triggers it (a STATE.md
|
compared exactly, so prose saying `status=done` never triggers it (a STATE.md
|
||||||
documenting this guard writes that string routinely) and `done2` is not `done`
|
documenting this guard writes that string routinely) and `done2` is not `done`
|
||||||
here even though board.sh's F3+F4 prefix defect still reads it as one.
|
here - and, since F3 (see `board.sh` below), not on `board.sh` either
|
||||||
|
anymore: both now do the same exact match on the same extracted value, so
|
||||||
|
`done2` reads as MALFORMED in both places, never as `done` in one and
|
||||||
|
something else in the other.
|
||||||
Selftest section 10, 17 checks, including the mandatory known-positive: a
|
Selftest section 10, 17 checks, including the mandatory known-positive: a
|
||||||
`status=done` with everything pushed must still go through, or the guard is a
|
`status=done` with everything pushed must still go through, or the guard is a
|
||||||
gate that denies everything and proves nothing.
|
gate that denies everything and proves nothing.
|
||||||
|
|
@ -471,6 +474,34 @@ marketplace plugin. Three components, one boundary:
|
||||||
environment. A tree where nobody owes anything is a different case — that is a
|
environment. A tree where nobody owes anything is a different case — that is a
|
||||||
valid, non-empty briefing saying so, and is written normally.
|
valid, non-empty briefing saying so, and is written normally.
|
||||||
|
|
||||||
|
**F3+F4 (2026-08-16): the status classifier and `route_cmd_for()`'s four-trait
|
||||||
|
extraction both matched a PREFIX of the closed vocabulary, not the exact
|
||||||
|
token.** Both used a `[a-z-]*` sed capture, which stops at the first byte
|
||||||
|
outside that class instead of running to the field's real boundary. Two
|
||||||
|
distinct failure shapes came out of the same defect: `status=done2` (a valid
|
||||||
|
token plus one byte) captured as `done` and was silently classified as a real
|
||||||
|
`done` - excluded from `--plan` the same way a genuine done repo is, and shown
|
||||||
|
green in the table, never flagged; `status=Planned` (a case variant) captured
|
||||||
|
as empty and was silently classified as `?` - "no board line at all" - which
|
||||||
|
fed the MERK footer a false count for a repo that has one. `route_cmd_for()`
|
||||||
|
carried the identical class on all four route traits, and there the failure is
|
||||||
|
worse: `path=known2` truncated to `known`, which `route.sh`'s own exact-match
|
||||||
|
validation then ACCEPTS, producing a safely-worded but WRONG startup command
|
||||||
|
(row 1) instead of the refusal a typo like `knwon` (a whole different word,
|
||||||
|
already handled correctly) already got. Fixed by capturing to the next `;` or
|
||||||
|
the closing `-->` instead - the same `[^;>]*` + trim shape `next-cost` already
|
||||||
|
used for its own reason (spec-conformant values contain spaces and capitals) -
|
||||||
|
so the classifier and `route.sh`'s case statement see the value un-truncated
|
||||||
|
and the exact-match either accepts it or correctly calls it MALFORMED /
|
||||||
|
`command_missing=`. `blocked-on`'s `[A-Za-z0-9._-]*` capture is a different,
|
||||||
|
wider class feeding a different mechanism (matched against scanned repo
|
||||||
|
names for chain-root credit, not a closed vocabulary) and was checked, not
|
||||||
|
touched - grepping `scripts/board.sh` for `[a-z-]*` after the fix returns
|
||||||
|
nothing. Selftest fixtures `repo-status-prefix`, `repo-status-case`,
|
||||||
|
`repo-trait-prefix` and `repo-revers-prefix` pin all four cases, plus the
|
||||||
|
known-positive controls that a genuinely absent board line still reads `?`
|
||||||
|
and a genuinely valid route line still derives a real command.
|
||||||
|
|
||||||
- **Route (`scripts/route.sh`):** pure calculator for the next session's model
|
- **Route (`scripts/route.sh`):** pure calculator for the next session's model
|
||||||
and effort. Takes four scored traits of the next task plus a required
|
and effort. Takes four scored traits of the next task plus a required
|
||||||
rationale, and prints one block of `key=value` lines: the rubric row, the rule
|
rationale, and prints one block of `key=value` lines: the rubric row, the rule
|
||||||
|
|
|
||||||
|
|
@ -83,6 +83,66 @@ mkrepo "$ROOT/repo-e"
|
||||||
echo "Ugyldig token."
|
echo "Ugyldig token."
|
||||||
} > "$ROOT/repo-e/STATE.md"
|
} > "$ROOT/repo-e/STATE.md"
|
||||||
|
|
||||||
|
# repo-status-prefix: F3 repro. status=done2 - a valid vocab word ("done") with
|
||||||
|
# a trailing byte the old [a-z-]* capture class does not match, so the old sed
|
||||||
|
# stopped at "done" and silently classified this as done (excluded from --plan
|
||||||
|
# when it owes nothing, table shows plain "done"). The correct read is
|
||||||
|
# MALFORMED - the token is out-of-vocabulary, and being one prefix-edit from a
|
||||||
|
# real token makes it MORE dangerous, not less: it must never disappear from
|
||||||
|
# the plan the way a genuine done repo does.
|
||||||
|
mkrepo "$ROOT/repo-status-prefix"
|
||||||
|
{
|
||||||
|
echo "# STATE - repo-status-prefix"
|
||||||
|
printf '## %s NESTE %s START HER\n' "$HAND" "$EMDASH"
|
||||||
|
echo "<!-- board: status=done2; blocked-on=-; next-cost=Sonnet 5/high -->"
|
||||||
|
echo "Ugyldig token, ett tegn forbi et gyldig ett."
|
||||||
|
} > "$ROOT/repo-status-prefix/STATE.md"
|
||||||
|
|
||||||
|
# repo-status-case: F3 repro. status=Planned (capital P) - the old [a-z-]*
|
||||||
|
# class does not match the leading uppercase byte at all, so the capture was
|
||||||
|
# EMPTY and the repo was reported as "?" (no board line), feeding the MERK
|
||||||
|
# footer with a false "mangler board-linje" count for a repo that has one. The
|
||||||
|
# correct read is MALFORMED, not "no board line at all".
|
||||||
|
mkrepo "$ROOT/repo-status-case"
|
||||||
|
{
|
||||||
|
echo "# STATE - repo-status-case"
|
||||||
|
printf '## %s NESTE %s START HER\n' "$HAND" "$EMDASH"
|
||||||
|
echo "<!-- board: status=Planned; blocked-on=-; next-cost=Sonnet 5/high -->"
|
||||||
|
echo "Feil case, ikke fravaer av board-linje."
|
||||||
|
} > "$ROOT/repo-status-case/STATE.md"
|
||||||
|
|
||||||
|
# repo-trait-prefix: F4 repro, the exact example from the order. path=known2 -
|
||||||
|
# the old [a-z-]* capture on route_cmd_for's four traits silently truncated to
|
||||||
|
# "known", which route.sh's exact-match case statement then ACCEPTS - producing
|
||||||
|
# a safely-worded but WRONG startup command instead of a refusal. This is
|
||||||
|
# worse than repo-typo's `knwon` (a whole different word, already rejected
|
||||||
|
# correctly): a one-byte-longer valid-looking token is the case an
|
||||||
|
# exact-match, not prefix-match, extraction is required to catch.
|
||||||
|
mkrepo "$ROOT/repo-trait-prefix"
|
||||||
|
{
|
||||||
|
echo "# STATE - repo-trait-prefix"
|
||||||
|
printf '## %s NESTE %s START HER\n' "$HAND" "$EMDASH"
|
||||||
|
echo "<!-- board: status=planned; blocked-on=-; next-cost=Opus 5/high -->"
|
||||||
|
echo "<!-- route: path=known2; verification=strong; reversibility=cheap; scope=local; rationale=x -->"
|
||||||
|
echo "Neste steg for repo-trait-prefix."
|
||||||
|
} > "$ROOT/repo-trait-prefix/STATE.md"
|
||||||
|
mkdir -p "$CLAUDE_COORD_DIR/repo-trait-prefix/inbox"
|
||||||
|
echo "msg" > "$CLAUDE_COORD_DIR/repo-trait-prefix/inbox/2026-msg1-from-y.md"
|
||||||
|
|
||||||
|
# repo-revers-prefix: same defect class on a DIFFERENT one of the four traits
|
||||||
|
# (reversibility), proving the fix covers all four sed lines in route_cmd_for,
|
||||||
|
# not just the path field the order's example happened to name.
|
||||||
|
mkrepo "$ROOT/repo-revers-prefix"
|
||||||
|
{
|
||||||
|
echo "# STATE - repo-revers-prefix"
|
||||||
|
printf '## %s NESTE %s START HER\n' "$HAND" "$EMDASH"
|
||||||
|
echo "<!-- board: status=planned; blocked-on=-; next-cost=Opus 5/high -->"
|
||||||
|
echo "<!-- route: path=known; verification=strong; reversibility=cheap2; scope=local; rationale=x -->"
|
||||||
|
echo "Neste steg for repo-revers-prefix."
|
||||||
|
} > "$ROOT/repo-revers-prefix/STATE.md"
|
||||||
|
mkdir -p "$CLAUDE_COORD_DIR/repo-revers-prefix/inbox"
|
||||||
|
echo "msg" > "$CLAUDE_COORD_DIR/repo-revers-prefix/inbox/2026-msg1-from-y.md"
|
||||||
|
|
||||||
# repo-g: prose containing a 'board:'-lookalike ABOVE the real board line.
|
# repo-g: prose containing a 'board:'-lookalike ABOVE the real board line.
|
||||||
# An unanchored substring grep would match 'dashboard:' first and mis-parse the
|
# An unanchored substring grep would match 'dashboard:' first and mis-parse the
|
||||||
# whole repo; the board line is defined as living under the NESTE heading.
|
# whole repo; the board line is defined as living under the NESTE heading.
|
||||||
|
|
@ -315,6 +375,27 @@ printf '%s' "$OUT" | grep -q 'deferred'; check "distinguishes deferred from bloc
|
||||||
# --- 3. Malformed input is flagged, not swallowed -------------------------
|
# --- 3. Malformed input is flagged, not swallowed -------------------------
|
||||||
printf '%s' "$OUT" | grep -qi 'malformed\|ugyldig\|invalid'; check "malformed status token is flagged" $?
|
printf '%s' "$OUT" | grep -qi 'malformed\|ugyldig\|invalid'; check "malformed status token is flagged" $?
|
||||||
|
|
||||||
|
# F3: a prefix-of-a-valid-token status (status=done2) must be MALFORMED, never
|
||||||
|
# silently classified as the valid token it happens to start with.
|
||||||
|
printf '%s' "$OUT" | grep -qE 'repo-status-prefix.*MALFORMED:done2'
|
||||||
|
check "prefix-match status token (done2) is flagged, not silently read as done" $?
|
||||||
|
printf '%s' "$OUT" | grep -E '^repo-status-prefix ' | grep -qE '[[:space:]]done[[:space:]]'
|
||||||
|
[ $? -ne 0 ]
|
||||||
|
check "prefix-match status token (done2) never displays as bare done" $?
|
||||||
|
|
||||||
|
# F3: a case-variant status (status=Planned) must be MALFORMED, never emptied
|
||||||
|
# to "?" - "?" means no board line at all, and this repo has one.
|
||||||
|
printf '%s' "$OUT" | grep -qE 'repo-status-case.*MALFORMED:Planned'
|
||||||
|
check "case-variant status token (Planned) is flagged, not emptied to ?" $?
|
||||||
|
printf '%s' "$OUT" | grep -E '^repo-status-case ' | grep -qE '[[:space:]][?][[:space:]]'
|
||||||
|
[ $? -ne 0 ]
|
||||||
|
check "case-variant status token (Planned) never reads as a missing board line" $?
|
||||||
|
|
||||||
|
# Known-positive control: a genuinely absent board line still reads as "?",
|
||||||
|
# proving the fix narrowed the match, it did not just stop matching "?".
|
||||||
|
printf '%s' "$OUT" | grep -E '^repo-b ' | grep -qE '[[:space:]][?][[:space:]]'
|
||||||
|
check "known-positive: repo truly missing a board line still reads as ?" $?
|
||||||
|
|
||||||
# A 'dashboard:' lookalike earlier in the file must not win over the real line.
|
# A 'dashboard:' lookalike earlier in the file must not win over the real line.
|
||||||
printf '%s' "$OUT" | grep -qE 'repo-g.*planned.*opus/xhigh'
|
printf '%s' "$OUT" | grep -qE 'repo-g.*planned.*opus/xhigh'
|
||||||
check "board-line parse ignores 'board:' lookalikes in prose" $?
|
check "board-line parse ignores 'board:' lookalikes in prose" $?
|
||||||
|
|
@ -511,6 +592,23 @@ check "unparseable route line degrades to a marker, not to an empty command" $?
|
||||||
printf '%s' "$BRIEF" | grep -A4 'repo-typo' | grep -qE '^ \$ claude'; [ $? -ne 0 ]
|
printf '%s' "$BRIEF" | grep -A4 'repo-typo' | grep -qE '^ \$ claude'; [ $? -ne 0 ]
|
||||||
check "unparseable route line never emits a command line at all" $?
|
check "unparseable route line never emits a command line at all" $?
|
||||||
|
|
||||||
|
# F4: path=known2 - a prefix-of-a-valid-token route trait. Silently trimmed to
|
||||||
|
# "known" by the old [a-z-]* capture, route.sh would accept it and hand back a
|
||||||
|
# safely-worded but WRONG command (row 1: known/strong/cheap/local). The fix
|
||||||
|
# must produce the SAME degrade-to-marker outcome as repo-typo's whole-word
|
||||||
|
# typo, never a plausible command built on a truncated value.
|
||||||
|
printf '%s' "$BRIEF" | grep -A4 'repo-trait-prefix' | grep -qi 'route'
|
||||||
|
check "prefix-match route trait (known2) degrades to a marker, not a guessed command" $?
|
||||||
|
printf '%s' "$BRIEF" | grep -A4 'repo-trait-prefix' | grep -qE '^ \$ claude'; [ $? -ne 0 ]
|
||||||
|
check "prefix-match route trait (known2) never emits a command line at all" $?
|
||||||
|
|
||||||
|
# F4 on a different one of the four traits (reversibility=cheap2), proving the
|
||||||
|
# fix is not path-specific.
|
||||||
|
printf '%s' "$BRIEF" | grep -A4 'repo-revers-prefix' | grep -qi 'route'
|
||||||
|
check "prefix-match route trait (cheap2) degrades to a marker, not a guessed command" $?
|
||||||
|
printf '%s' "$BRIEF" | grep -A4 'repo-revers-prefix' | grep -qE '^ \$ claude'; [ $? -ne 0 ]
|
||||||
|
check "prefix-match route trait (cheap2) never emits a command line at all" $?
|
||||||
|
|
||||||
# repo-a owes 3 messages and has no route line. A fabricated command would be
|
# repo-a owes 3 messages and has no route line. A fabricated command would be
|
||||||
# worse than none: it would read as authoritative while being a guess.
|
# worse than none: it would read as authoritative while being a guess.
|
||||||
printf '%s' "$BRIEF" | grep -A4 'repo-a' | grep -qi 'route'
|
printf '%s' "$BRIEF" | grep -A4 'repo-a' | grep -qi 'route'
|
||||||
|
|
@ -806,6 +904,15 @@ check "unparseable route line degrades to command_missing=, not to a guess" $?
|
||||||
printf '%s\n' "$PLAN" | grep -A6 '^repo=repo-a$' | grep -q '^command_missing='
|
printf '%s\n' "$PLAN" | grep -A6 '^repo=repo-a$' | grep -q '^command_missing='
|
||||||
check "repo owing mail but lacking a route line is marked, never guessed at" $?
|
check "repo owing mail but lacking a route line is marked, never guessed at" $?
|
||||||
|
|
||||||
|
# F4: path=known2 must degrade to command_missing=, exactly like repo-typo's
|
||||||
|
# whole-word typo - never to a command= built on a truncated "known".
|
||||||
|
printf '%s\n' "$PLAN" | grep -A6 '^repo=repo-trait-prefix$' | grep -q '^command_missing='
|
||||||
|
check "F4: prefix-match route trait (known2) degrades to command_missing=" $?
|
||||||
|
printf '%s\n' "$PLAN" | grep -A7 '^repo=repo-trait-prefix$' | grep -q '^paste='; [ $? -ne 0 ]
|
||||||
|
check "F4: prefix-match route trait (known2) gets no paste line either" $?
|
||||||
|
printf '%s\n' "$PLAN" | grep -A6 '^repo=repo-revers-prefix$' | grep -q '^command_missing='
|
||||||
|
check "F4: prefix-match route trait (cheap2) degrades to command_missing=" $?
|
||||||
|
|
||||||
# Same argument as the briefing: the 38-char cut is the TABLE column's property.
|
# Same argument as the briefing: the 38-char cut is the TABLE column's property.
|
||||||
printf '%s' "$PLAN" | grep -q '^neste=.*check-versions'
|
printf '%s' "$PLAN" | grep -q '^neste=.*check-versions'
|
||||||
check "plan prints the full NESTE line, not the 38-char table excerpt" $?
|
check "plan prints the full NESTE line, not the 38-char table excerpt" $?
|
||||||
|
|
|
||||||
|
|
@ -325,7 +325,14 @@ printf '%s\n' "$REPOS" | while IFS= read -r d; do
|
||||||
line="$(grep -m1 '^<!-- board:' "$state" 2>/dev/null)"
|
line="$(grep -m1 '^<!-- board:' "$state" 2>/dev/null)"
|
||||||
status=""; blockedon=""; cost=""
|
status=""; blockedon=""; cost=""
|
||||||
if [ -n "$line" ]; then
|
if [ -n "$line" ]; then
|
||||||
status="$(printf '%s' "$line" | sed -n 's/.*status=\([a-z-]*\).*/\1/p')"
|
# Captures to the next ';' or the closing '-->', NOT to the first
|
||||||
|
# non-[a-z-] byte: a prefix class stops early on a value that is one byte
|
||||||
|
# past a valid token ("done2" -> "done") and silently accepts it as that
|
||||||
|
# token, or on a case variant ("Planned") and captures empty - the exact
|
||||||
|
# bugs F3 closes. The case statement below still does an EXACT match, so
|
||||||
|
# anything out of vocabulary lands in MALFORMED with its real value intact.
|
||||||
|
status="$(printf '%s' "$line" | sed -n 's/.*status=\([^;>]*\).*/\1/p' \
|
||||||
|
| sed -e 's/--$//' -e 's/[[:space:]]*$//' -e 's/^[[:space:]]*//')"
|
||||||
blockedon="$(printf '%s' "$line" | sed -n 's/.*blocked-on=\([A-Za-z0-9._-]*\).*/\1/p')"
|
blockedon="$(printf '%s' "$line" | sed -n 's/.*blocked-on=\([A-Za-z0-9._-]*\).*/\1/p')"
|
||||||
# Value runs to the next ';' or the closing '-->', NOT to the first
|
# Value runs to the next ';' or the closing '-->', NOT to the first
|
||||||
# non-lowercase byte: the rubric names models "Sonnet 5 / xhigh", so a
|
# non-lowercase byte: the rubric names models "Sonnet 5 / xhigh", so a
|
||||||
|
|
@ -529,10 +536,20 @@ rows() {
|
||||||
route_cmd_for() {
|
route_cmd_for() {
|
||||||
rc_line="$(grep -m1 '^<!-- route:' "$1/STATE.md" 2>/dev/null)"
|
rc_line="$(grep -m1 '^<!-- route:' "$1/STATE.md" 2>/dev/null)"
|
||||||
[ -n "$rc_line" ] || return 1
|
[ -n "$rc_line" ] || return 1
|
||||||
rc_p="$(printf '%s' "$rc_line" | sed -n 's/.*path=\([a-z-]*\).*/\1/p')"
|
# Captures to the next ';' or '-->', NOT to the first non-[a-z-] byte: a
|
||||||
rc_v="$(printf '%s' "$rc_line" | sed -n 's/.*verification=\([a-z-]*\).*/\1/p')"
|
# prefix class silently truncates "known2" to "known", which route.sh's own
|
||||||
rc_r="$(printf '%s' "$rc_line" | sed -n 's/.*reversibility=\([a-z-]*\).*/\1/p')"
|
# exact-match validation then ACCEPTS - a safely-worded but WRONG command
|
||||||
rc_s="$(printf '%s' "$rc_line" | sed -n 's/.*scope=\([a-z-]*\).*/\1/p')"
|
# instead of the refusal this defect (F4) exists to force. route.sh still
|
||||||
|
# does the real rejection; this only stops the value from being mangled
|
||||||
|
# into something valid before it gets there.
|
||||||
|
rc_p="$(printf '%s' "$rc_line" | sed -n 's/.*path=\([^;>]*\).*/\1/p' \
|
||||||
|
| sed -e 's/--$//' -e 's/[[:space:]]*$//' -e 's/^[[:space:]]*//')"
|
||||||
|
rc_v="$(printf '%s' "$rc_line" | sed -n 's/.*verification=\([^;>]*\).*/\1/p' \
|
||||||
|
| sed -e 's/--$//' -e 's/[[:space:]]*$//' -e 's/^[[:space:]]*//')"
|
||||||
|
rc_r="$(printf '%s' "$rc_line" | sed -n 's/.*reversibility=\([^;>]*\).*/\1/p' \
|
||||||
|
| sed -e 's/--$//' -e 's/[[:space:]]*$//' -e 's/^[[:space:]]*//')"
|
||||||
|
rc_s="$(printf '%s' "$rc_line" | sed -n 's/.*scope=\([^;>]*\).*/\1/p' \
|
||||||
|
| sed -e 's/--$//' -e 's/[[:space:]]*$//' -e 's/^[[:space:]]*//')"
|
||||||
bash "$ROUTE" --path "$rc_p" --verification "$rc_v" \
|
bash "$ROUTE" --path "$rc_p" --verification "$rc_v" \
|
||||||
--reversibility "$rc_r" --scope "$rc_s" --rationale brief 2>/dev/null \
|
--reversibility "$rc_r" --scope "$rc_s" --rationale brief 2>/dev/null \
|
||||||
| sed -n 's/^command=//p'
|
| sed -n 's/^command=//p'
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue