repo-mailbox/scripts/route.sh
Kjell Tore Guttormsen 5e5bc4a66e feat(route,board): strike the advisor rule, add board.sh --row <repo>
Order 20260912T202210Z-7588027378-from-.claude, operator decision
2026-09-12 (helhetlig vurdering av arbeidssystemet, cut row 3 and the
board.sh --row improvement row). One order, two parts, one version bump.

THE ADVISOR RULE IS STRUCK. route.sh and board.sh --dispatch emit no
--advisor at all. The rule fired per ROW on a need - always on the Sonnet
rows (a capability lift, which is what made the quota fallback safe to
take), and on the Opus rows at reversibility=costly|one-way - and it read
well. It was killed by a MEASUREMENT, not by taste: of 54 dispatches the
PM issued 08.-12.09, ZERO carried the flag, because sessions are started
by hand from the model and effort rather than from the whole emitted
line. A rule nothing honours is not a policy, and an emitted value nobody
acts on is decoration in a field whose only job is to be evidence. The
advisor is now what it already was in practice: an operator decision per
session, said in one sentence in route.sh --help.

The comments that rested on the rule were REWRITTEN, not left standing.
board.sh --dispatch still refuses a --model/--effort pair, but the reason
is no longer "the advisor is a property of the ROW": it is that the rubric
has exactly one copy, and a dispatch taking the model directly would be a
second, unscored way to reach the same decision - recording no traits, no
rationale and no next-cost, so nothing afterwards could say whether the
routing or the scoring was wrong. A comment defending a removed mechanism
is how the next session restores it. Both skills carry the correction.

Pinned as an ABSENCE over the whole trait space - 81 combinations, every
line of output, with a known-positive control proving the sweep's grep
can find a planted advisor - rather than on four sampled rows, because
the claim is that no path emits it. board.sh --dispatch at
reversibility=costly is pinned separately: that is the exact input a
reintroduced rule would fire on. The literal string is absent from
route.sh entirely, including the paragraph recording what was struck (it
says "an opus advisor flag" in words), because a blunt grep cannot tell a
description from a specification. Backward compatibility is pinned rather
than assumed: a route line carrying a legacy advisor= field still parses
and still yields a command - measured, 0 of 48 route lines in ~/repos
carry one, but a reader that broke on an unknown field would turn last
month's STATE.md into "that repo has no route line". The three CLI gates
section 14 carried went with the rule; the suite no longer depends on the
installed claude at all.

board.sh --row <repo> IS THE SEVENTH RENDERING of the same scan, never a
second scan, read-only like every other one. (The order calls it the
sixth; by this file's own numbering --inbox-plan is the fourth and
--dispatch the fifth. Corrected rather than carried wrong.) It exists
because the columns WERE misread: on 11.09 the PM read FLY off the table
by eye and got it wrong, 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, never
the table's blocked>target display, with blocked-on beside it; neste is
last and uncut. An unknown repo exits 2 and writes NOTHING to stdout - an
empty block would read as a repo whose every column is blank, which is a
real and different state.

upushet is the ONE field that is not a rendering of the scan, and it is
named rather than blended in: nothing in the scan measures it, so it is
read once, for the named repo only, and never enters the table, the plan
or the briefing. It reads the remote-TRACKING ref, not the remote, so
upushet=N honestly means "the local ref says N"; a repo with no upstream
reports ?, never 0.

The row fixture's three counts are three DIFFERENT integers (3/2/1), and
that is the finding worth recording. Built first with 2/1/1, it was
mutation-tested by making fly read the ORDRE field - the exact 11.09
misreading - and the check stayed GREEN, because the two fields held the
same digit. A fixture that cannot tell two columns apart is the defect
wearing a passing test, inside the section written to prevent it.

Suites under /bin/bash 3.2, before -> after: coord 257 -> 257, board
393 -> 427, route 73 -> 73 (13 advisor checks and 3 CLI gates out, 15
absence/legacy checks in, and it no longer varies with claude being on
PATH), orders 116 -> 116, state-line-guard 54 -> 54. Sum 893 -> 927,
README badge updated to the measured sum. npm test 12/12, fail 0.

Verified live against the real tree, not only fixtures: --row
repo-mailbox reports fly=1 beside ordre=0 (the distinction that was
misread), --row on the nested key from-ai-to-chitta/content-sadhguru
resolves, and an unknown repo exits 2.

No tag, no push, no catalog change - that is the operator's
release-plugin.mjs run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 23:45:47 +02:00

303 lines
15 KiB
Bash
Executable file

#!/bin/bash
# route.sh - score four traits of the NEXT task, get the model and effort to
# run it with. A pure calculator: reads nothing, writes nothing, prints one
# block of key=value lines on stdout. The session pastes the result into
# STATE.md; this script never touches a file.
#
# WHY THIS EXISTS. The board line's next-cost field had a reader (board.sh)
# and no writer, so its value was typed by hand every session and drifted into
# several competing spellings. Cleaning the data could not fix that.
# A writer with a CLOSED output range can: this script can only ever emit one
# of four strings, so a fifth cannot enter circulation.
#
# THE ROW TABLE IS THE POLICY, and it is the operator's rubric verbatim -
# moved here so there is one copy rather than one per repo. It has six rows;
# this calculator only ever computes four of them:
#
# 1 Sonnet 5/high reading, summarizing, docs, mechanical refactor
# 2 Sonnet 5/xhigh TDD cycle, known-root-cause bugfix, one-file change
# 3 Opus 5/high multi-file feature, architecture choice, hard debugging
# 4 Opus 5/xhigh long autonomous run, big refactor, cross-repo migration
# 5 Fable 5/high deliberate choice for big-picture/review/planning work
# 6 Fable 5/xhigh same, open-ended or longest horizon
#
# ROWS 5-6 ARE NEVER COMPUTED HERE. Until 2026-08-06 they fired only from an
# explicit --opus-xhigh-failed flag; that policy is REMOVED (operator
# decision), and nothing replaces it as a rubric outcome. A Fable choice is
# now always a deliberate deviation from this calculator - CLAUDE.md is
# explicit that the rubric stays the only deterministic lookup and a
# departure from it is recorded in STATE as an OVERRIDE, never produced here.
# Write "Fable 5/high" or "Fable 5/xhigh" into the board line by hand when
# that is the right call; board.sh still parses both (route-selftest.sh
# section 6 pins that half).
#
# Cheapest first, so the rubric's "always name one row cheaper as the quota
# fallback" is row minus one, floored at row 1, correct by construction.
#
# THE TRAITS describe the task, never how it feels. "Hard", "complex" and
# "important" are deliberately absent: they are unfalsifiable and collapse to
# a hunch, which is the thing being replaced.
#
# --path known | partial | undetermined
# Is the solution route described, or must it be found?
# --verification strong | weak | none
# Will tests, types or a compiler catch the error?
# --reversibility cheap | costly | one-way
# --scope local | multi-file | cross-cutting
# --rationale required free text - WHY these four scores
#
# All five are required. None has a default, and that is load-bearing:
# verification carries the most signal and is the trait most often left out,
# and a default would be indistinguishable from a real score when the log is
# read back to find out whether the ROUTING was wrong or the SCORING was.
#
# SELECTION - first match wins, most expensive first. Rows 5-6 do not appear:
# they are never a trait-derived outcome (see above).
# row 4 reversibility=one-way OR scope=cross-cutting
# row 3 path=partial|undetermined OR reversibility=costly OR scope=multi-file
# row 2 verification=weak|none
# row 1 otherwise
#
# Escalation is ASYMMETRIC on purpose: any single trait escalates, while
# reaching row 1 needs all four at the cheap end. Underkill costs one session,
# overkill costs quota every session - but a wrong architecture decision in a
# published plugin costs more than either.
#
# THE ADVISOR IS NOT EMITTED, and that is a decision rather than an omission:
# the advisor is an operator decision per session, never the rubric's. Until
# 2026-09-12 this calculator appended an opus advisor flag on a NEED - always on
# the Sonnet rows, and on the Opus rows at costly|one-way stakes. It was struck
# on a measurement: of 54 dispatches issued 08.-12.09 not one carried the flag,
# because sessions are started by hand from the model and effort, not from the
# whole line. A rule nothing honours is not policy, and an emitted value that
# nobody acts on is decoration in a field whose only job is to be evidence.
#
# WHERE IT DISAGREES WITH THE RUBRIC'S EXAMPLES. The rows are task-type labels;
# the traits are a different classification over the same six outcomes. They
# part company by one row on the two cheapest rows - documentation scores
# known/none/cheap/local and lands on row 2 where the rubric's prose says row
# 1; a TDD cycle scores known/strong/cheap/local and lands on row 1 where the
# prose says row 2. This is left alone deliberately. Chasing the example
# phrases would mean re-implementing description-matching, which is the guess
# the traits exist to replace. It is a policy, not a theorem: --rationale is
# how a misscore is found afterwards.
#
# THE SPECIFICATION CHECK THAT COMES FREE. path=undetermined with no planned
# design phase means the TASK DESCRIPTION is underspecified - not that the
# model should be upgraded. Rewrite the next step; upgrading the model to
# compensate for a vague spec is the most expensive form of procrastination
# available.
#
# Usage:
# route.sh --path <v> --verification <v> --reversibility <v> --scope <v>
# --rationale <text>
#
# route.sh ... --last-model <Sonnet 5|Opus 5|Fable 5|Fable 5.1>
# --last-effort <low|medium|high|xhigh|max>
# --last-completed <yes|no> --last-corrections <n>
#
# THE LAST-SESSION RECORD (the four --last-* fields, all or none) is what makes
# any of this falsifiable. It records how the session that just ran actually
# went, so the policy can later be judged against outcomes instead of against
# how sensible it reads. --last-corrections is the cheap proxy: systematically
# high counts on row 1 mean the cheap row is too easy to reach, systematically
# zero on row 4 means escalation fires too readily.
#
# All four fields are closed sets or numbers, and required together, so the
# record reads back later as evidence rather than a guess - a partial record
# would emit an empty value indistinguishable from a real measurement. Every
# one of them must be MEASURED by the caller: --last-effort comes from CLAUDE_EFFORT,
# which Claude Code exports into every tool-use context as the session's current
# effort level. It is deliberately NOT defaulted from that variable here - a
# calculator that reads its own environment stops being deterministic from its
# arguments, and section 6 of the selftest depends on that determinism. Reading
# the value off the previous board line would measure what was PRESCRIBED rather
# than what was RUN; so, less obviously, does asking the operator, who reads it
# off the startup command they typed. Omit the record rather than guess - a
# guessed value reads back as a measurement.
#
# It is pure telemetry and never changes what the calculator outputs.
# Inferring "the model failed" from "the session did not finish" would fire on
# context exhaustion and on operator interrupts, which say nothing about the
# model - so the record stays descriptive, never a trigger.
#
# Exit 0 on a decision, 2 on any bad or missing argument. ASCII only,
# bash 3.2 safe.
set -u
export LC_ALL=C
PATH_T=""; VERIF=""; REVERS=""; SCOPE=""; RATIONALE=""; RAT_SET=0
L_MODEL=""; L_EFFORT=""; L_DONE=""; L_CORR=""; L_SET=0
die() { echo "route: $1" >&2; exit 2; }
need() { [ $# -ge 2 ] || die "$1 requires a value"; }
while [ $# -gt 0 ]; do
case "$1" in
# bash 3.2: `shift 2` past the end of $# is a no-op -> would loop forever.
--path) need "$@"; PATH_T="$2"; shift 2 ;;
--verification) need "$@"; VERIF="$2"; shift 2 ;;
--reversibility) need "$@"; REVERS="$2"; shift 2 ;;
--scope) need "$@"; SCOPE="$2"; shift 2 ;;
--rationale) need "$@"; RATIONALE="$2"; RAT_SET=1; shift 2 ;;
--last-model) need "$@"; L_MODEL="$2"; L_SET=1; shift 2 ;;
--last-effort) need "$@"; L_EFFORT="$2"; L_SET=1; shift 2 ;;
--last-completed) need "$@"; L_DONE="$2"; L_SET=1; shift 2 ;;
--last-corrections) need "$@"; L_CORR="$2"; L_SET=1; shift 2 ;;
-h|--help) grep '^#' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
*) die "unknown argument: $1" ;;
esac
done
# Unknown values are rejected rather than tolerated. A silently accepted
# typo would route on three traits and look exactly like a scored decision.
case "$PATH_T" in
known|partial|undetermined) ;;
"") die "--path is required (known|partial|undetermined)" ;;
*) die "--path: unknown value '$PATH_T' (known|partial|undetermined)" ;;
esac
case "$VERIF" in
strong|weak|none) ;;
"") die "--verification is required (strong|weak|none)" ;;
*) die "--verification: unknown value '$VERIF' (strong|weak|none)" ;;
esac
case "$REVERS" in
cheap|costly|one-way) ;;
"") die "--reversibility is required (cheap|costly|one-way)" ;;
*) die "--reversibility: unknown value '$REVERS' (cheap|costly|one-way)" ;;
esac
case "$SCOPE" in
local|multi-file|cross-cutting) ;;
"") die "--scope is required (local|multi-file|cross-cutting)" ;;
*) die "--scope: unknown value '$SCOPE' (local|multi-file|cross-cutting)" ;;
esac
[ "$RAT_SET" -eq 1 ] || die "--rationale is required (why these four scores)"
[ -n "$RATIONALE" ] || die "--rationale must not be empty"
# The last-session record is all four fields or none at all. A partial record
# emits an empty value that reads back later exactly like a measured one, and
# the whole purpose of the record is to be readable evidence months from now.
if [ "$L_SET" -eq 1 ]; then
[ -n "$L_MODEL" ] || die "--last-model is required with a last-session record"
[ -n "$L_EFFORT" ] || die "--last-effort is required with a last-session record"
[ -n "$L_DONE" ] || die "--last-completed is required with a last-session record"
[ -n "$L_CORR" ] || die "--last-corrections is required with a last-session record"
# Model and effort are closed sets here, not free text. The next session READS
# this record back as evidence months from now, so a drifted spelling
# ("opus 5" for "Opus 5") rebuilds the reader-versus-writer drift this whole
# script exists to remove, one field over.
# THE SET IS CLOSED, AND STAYS CLOSED - decided 2026-09-01 when Fable 5.1
# shipped and was refused here. Both boundary questions were live:
#
# (a) "Fable 5" is KEPT alongside the point release. The reason is not
# backward compatibility with the one STATE.md on this machine that still
# carries it (measured: 1 of 45 route-last lines) - it is that the row
# table above spells rows 5-6 "Fable 5/high" and "Fable 5/xhigh".
# Dropping the value would make this script refuse to record a name its
# own spec writes.
#
# (b) The set was WIDENED rather than replaced by form validation. A pattern
# like "<family> <digits>[.<digits>]" would still catch a misspelled
# family and a drifted case, and would stop catching A VERSION THAT DOES
# NOT EXIST: "Fable 5.2" and "Opus 7" would both pass and read back
# months later as evidence that a model ran when it never shipped. This
# field is telemetry read as evidence, so a silently-accepted lie is
# worse than a loud refusal.
#
# The cost of that choice is real and was paid before it was made: a session
# that genuinely ran Fable 5.1 could not record it, so its record was omitted
# or lied. The list must therefore be extended the day a model ships, and the
# die message says so rather than leaving the caller to guess.
case "$L_MODEL" in
"Sonnet 5"|"Opus 5"|"Fable 5"|"Fable 5.1") ;;
*) die "--last-model: '$L_MODEL' is not a row-table model (Sonnet 5|Opus 5|Fable 5|Fable 5.1) - a newly shipped model must be added to this list in route.sh, never approximated to a name that is already in it" ;;
esac
case "$L_EFFORT" in
low|medium|high|xhigh|max) ;;
*) die "--last-effort: '$L_EFFORT' is not a verified effort level (low|medium|high|xhigh|max)" ;;
esac
case "$L_DONE" in
yes|no) ;;
*) die "--last-completed: unknown value '$L_DONE' (yes|no)" ;;
esac
case "$L_CORR" in
""|*[!0-9]*) die "--last-corrections must be a whole number, got '$L_CORR'" ;;
esac
fi
# --- Selection: first match wins, most expensive first ---------------------
# Rows 5-6 (Fable) never appear: they are a hand-written operator override,
# never a trait-derived outcome (see the header note).
if [ "$REVERS" = "one-way" ]; then
ROW=4; RULE="reversibility=one-way"
elif [ "$SCOPE" = "cross-cutting" ]; then
ROW=4; RULE="scope=cross-cutting"
elif [ "$PATH_T" != "known" ]; then
ROW=3; RULE="path=$PATH_T"
elif [ "$REVERS" = "costly" ]; then
ROW=3; RULE="reversibility=costly"
elif [ "$SCOPE" = "multi-file" ]; then
ROW=3; RULE="scope=multi-file"
elif [ "$VERIF" != "strong" ]; then
ROW=2; RULE="verification=$VERIF"
else
ROW=1; RULE="no escalating trait (all four at the cheap end)"
fi
# --- The row table: one decision, two spellings ---------------------------
# The rubric name goes in the board line so repos compare by eye; the CLI alias
# goes in the command the operator pastes. Emitting both from one table is the
# point - two hand-maintained spellings of one decision is how they disagree.
# Aliases are gated against the installed claude by route-selftest.sh section
# 11, never assumed here. Rows 5-6 have no entry: $ROW can never be 5 or 6
# (see SELECTION above), so a case arm for them would be dead code.
row_name() {
case "$1" in
1) echo "Sonnet 5/high" ;; 2) echo "Sonnet 5/xhigh" ;;
3) echo "Opus 5/high" ;; 4) echo "Opus 5/xhigh" ;;
esac
}
row_base_cmd() {
case "$1" in
1) echo "claude --model sonnet --effort high" ;;
2) echo "claude --model sonnet --effort xhigh" ;;
3) echo "claude --model opus --effort high" ;;
4) echo "claude --model opus --effort xhigh" ;;
esac
}
# No advisor is appended here or anywhere else - the advisor is an operator
# decision per session, not a property this rubric computes (struck
# 2026-09-12, see the header). row_cmd() is therefore the row's base command
# and nothing more; it stays a function rather than collapsing into
# row_base_cmd() because the emitted command and the row table are two
# separate things that happened to converge, and a later flag would attach
# here, to one place, for both the winning row and its fallback.
row_cmd() { row_base_cmd "$1"; }
FB=$((ROW - 1)); [ "$FB" -ge 1 ] || FB=1
# The rationale is free text from a session and lands inside an HTML comment on
# ONE line. A newline would split the line and hand the remainder to board.sh's
# NESTE extractor as the repo's next step; a literal '-->' would close the
# comment early and spill the rest into the rendered STATE.md. Same
# line-oriented sanitizing the send side applies to its own fields.
RAT_CLEAN="$(printf '%s' "$RATIONALE" | tr '\r\n' ' ' | tr -d '\000-\037' \
| sed 's/-->/-- >/g')"
# rationale goes LAST in the line: it is the only field that may contain a
# ';', so anything after it would be unparseable.
echo "row=$ROW"
echo "rule=$RULE"
echo "next-cost=$(row_name "$ROW")"
echo "command=$(row_cmd "$ROW")"
echo "fallback=$(row_name "$FB")"
echo "fallback-command=$(row_cmd "$FB")"
echo "route-line=<!-- route: path=$PATH_T; verification=$VERIF; reversibility=$REVERS; scope=$SCOPE; rationale=$RAT_CLEAN -->"
# No sanitizing needed on the record: all four fields are validated against
# closed sets above, so none of them can carry a newline or a '-->'.
if [ "$L_SET" -eq 1 ]; then
echo "route-last=<!-- route-last: model=$L_MODEL; effort=$L_EFFORT; completed=$L_DONE; corrections=$L_CORR -->"
fi
exit 0