route.sh now emits `--advisor opus` into the startup command it prints.
The flag existed and worked, but nothing generated it, so it went unused:
the only mechanism that ever set an advisor here was `/advisor`, which
writes the global advisorModel setting -- every session, every repo -- and
was abandoned for burning quota. Nothing replaced it.
Two independent triggers, almost disjoint by construction:
rows 1-2 always. Sonnet main model, so opus is a capability LIFT rather
than a peer. Load-bearing: every fallback is one row cheaper and
the cheap rows are Sonnet, so this makes the quota fallback safe.
rows 3-4 only at reversibility=costly|one-way. Opus main model, so the
advisor buys peer review where a mistake is not cheap to undo.
rows 5-6 never. The CLI rejects every advisor for a Fable main model.
costly forces row 3 and one-way forces row 4, so a Sonnet row always has
reversibility=cheap and neither rule reaches the other's rows.
verification=none is deliberately not a third trigger: beyond the stakes
rule it adds only cheap-to-reverse mistakes, docs sessions among them.
Applied per ROW, so fallback-command carries its own correct answer.
route-selftest.sh 56 -> 73. Section 14 gates the three CLI facts the rule
rests on against the installed claude without spending a token: advisor
validation runs before the empty-prompt check, so `-p ""` reaches the
validator and stops. --help cannot gate this -- it short-circuits before
option validation, so an unknown flag would pass the gate untested.
Three pre-existing checks updated rather than worked around: two asserted
whole command strings that now carry the advisor, and section 11's effort
extraction swallowed the tail of the command line.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X8N8hQEJSWWtieWUx37txT
321 lines
16 KiB
Bash
Executable file
321 lines
16 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 six strings, so a seventh 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:
|
|
#
|
|
# 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 Opus 5/xhigh has ACTUALLY failed on this same step
|
|
# 6 Fable 5/xhigh open problem, no known solution path, longest horizon
|
|
#
|
|
# 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:
|
|
# row 6 history says Opus 5/xhigh failed here, and path=undetermined
|
|
# row 5 history says Opus 5/xhigh failed here
|
|
# 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 emitted into the command as '--advisor opus' - a second,
|
|
# stronger model consulted at key moments during the session. It is added on a
|
|
# NEED, never unconditionally: an always-on advisor is the global advisorModel
|
|
# setting, which burns quota on every session in every repo and is the thing
|
|
# this rule exists to replace. Two independent needs qualify:
|
|
#
|
|
# rows 1-2 ALWAYS. The main model is Sonnet, so opus is a capability LIFT
|
|
# rather than a peer - opus judgement at sonnet cost. This is what
|
|
# makes the FALLBACK safe to take: every fallback is one row
|
|
# cheaper, and the cheapest rows are the Sonnet ones.
|
|
# rows 3-4 only at reversibility=costly|one-way. The main model is already
|
|
# Opus, so the advisor buys peer review, worth paying for when a
|
|
# mistake is not cheap to undo.
|
|
# rows 5-6 NEVER, and not as a judgement call: the CLI rejects every
|
|
# advisor for a Fable main model.
|
|
#
|
|
# The two triggers barely overlap: costly forces row 3 and one-way forces row
|
|
# 4, so a Sonnet row always has reversibility=cheap. verification=none is
|
|
# deliberately not a third trigger - beyond the stakes rule it would only add
|
|
# mistakes that are cheap to reverse, docs sessions among them.
|
|
#
|
|
# Applied per ROW, so 'fallback-command' carries its own correct answer rather
|
|
# than the winning row's.
|
|
#
|
|
# 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> [--opus-xhigh-failed]
|
|
#
|
|
# route.sh ... --last-model <Sonnet 5|Opus 5|Fable 5>
|
|
# --last-effort <low|medium|high|xhigh|max>
|
|
# --last-completed <yes|no> --last-corrections <n>
|
|
#
|
|
# --opus-xhigh-failed is the ONLY way rows 5 and 6 can fire, and it means one
|
|
# narrow thing: an Opus 5/xhigh session already ran at THIS SAME next step and
|
|
# did not complete it. Not "a session failed" - context exhaustion, an operator
|
|
# interrupt or a block on another repo are not this flag. It escalates to a
|
|
# model that runs without an advisor, so when in doubt, leave it off.
|
|
#
|
|
# 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, because the
|
|
# NEXT session reads the record back to decide --opus-xhigh-failed. 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 deliberately NOT wired to the Fable rows. 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. The record is
|
|
# evidence; --opus-xhigh-failed is a judgement. Keeping them apart is why the
|
|
# evidence stays worth reading.
|
|
#
|
|
# 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; FAILED=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 ;;
|
|
--opus-xhigh-failed) FAILED=1; shift ;;
|
|
--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 to decide --opus-xhigh-failed, so a drifted spelling
|
|
# ("opus 5" for "Opus 5") rebuilds the reader-versus-writer drift this whole
|
|
# script exists to remove, one field over.
|
|
case "$L_MODEL" in
|
|
"Sonnet 5"|"Opus 5"|"Fable 5") ;;
|
|
*) die "--last-model: '$L_MODEL' is not a row-table model (Sonnet 5|Opus 5|Fable 5)" ;;
|
|
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 ---------------------
|
|
if [ "$FAILED" -eq 1 ] && [ "$PATH_T" = "undetermined" ]; then
|
|
ROW=6; RULE="opus-xhigh-failed + path=undetermined"
|
|
elif [ "$FAILED" -eq 1 ]; then
|
|
ROW=5; RULE="opus-xhigh-failed"
|
|
elif [ "$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.
|
|
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" ;;
|
|
5) echo "Fable 5/high" ;; 6) echo "Fable 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" ;;
|
|
5) echo "claude --model fable --effort high" ;;
|
|
6) echo "claude --model fable --effort xhigh" ;;
|
|
esac
|
|
}
|
|
|
|
# THE ADVISOR is a second, stronger model consulted mid-task. It costs real
|
|
# tokens per session, so it fires on a NEED and nowhere else - an unconditional
|
|
# advisor is just the global advisorModel setting, which is the thing this
|
|
# replaces. Two independent needs qualify, and they are almost disjoint:
|
|
#
|
|
# rows 1-2 (Sonnet) ALWAYS. opus is a capability LIFT here, not a peer:
|
|
# opus judgement at sonnet cost. This half is what makes
|
|
# the fallback-command safe, since every fallback is one
|
|
# row cheaper and the cheapest rows are Sonnet.
|
|
# rows 3-4 (Opus) only at costly|one-way stakes, where the advisor is a
|
|
# peer review and being wrong is not cheap to undo.
|
|
# rows 5-6 (Fable) never. Not a judgement call: the CLI REJECTS every
|
|
# advisor for fable ("cannot be used as an advisor"), and
|
|
# opus is refused as under-capable for a fable main model.
|
|
# Gated against the installed claude by selftest 14.
|
|
#
|
|
# costly forces row 3 and one-way forces row 4, so a Sonnet row always has
|
|
# reversibility=cheap - the stakes rule can never reach rows 1-2, and the model
|
|
# rule never reaches rows 3-6. verification=none is deliberately NOT a trigger:
|
|
# beyond the stakes rule it would only add cheap-to-reverse mistakes, and it
|
|
# would put an advisor on every docs session (known/none/cheap/local).
|
|
#
|
|
# Applied per ROW rather than once, because the fallback is a real command the
|
|
# operator pastes under quota pressure and must carry its own correct answer.
|
|
row_advisor() {
|
|
case "$1" in
|
|
1|2) echo " --advisor opus" ;;
|
|
3|4) case "$2" in costly|one-way) echo " --advisor opus" ;; *) echo "" ;; esac ;;
|
|
*) echo "" ;;
|
|
esac
|
|
}
|
|
row_cmd() { printf '%s%s\n' "$(row_base_cmd "$1")" "$(row_advisor "$1" "$REVERS")"; }
|
|
|
|
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
|