feat(route): give next-cost a writer, and make the rubric the only copy
The board line's next-cost field had a reader (board.sh) and no writer, so
its value was retyped by hand every session and drifted into several
competing spellings. Cleaning the data could not fix that: the cause was the
missing write path.
route.sh is that writer. Four scored traits of the next task -- path,
verification, reversibility, scope -- plus a required rationale, run through
the operator's model rubric moved here as the single copy. The row table is a
closed set of six values, so a seventh spelling cannot enter circulation, and
route-selftest.sh section 6 runs the round trip (route emits -> board parses)
inside one repo rather than across two.
Two spellings of one decision come out of one table: the rubric name for the
board line, the CLI alias for the command the operator pastes. Effort levels
are the set pinned in this marketplace; model aliases are gated against the
installed claude rather than hardcoded.
Three things worth naming:
- Escalation is asymmetric. Any single trait escalates; the cheapest row needs
all four at the cheap end. Underkill costs one session, overkill costs quota
every session -- but a wrong architecture call costs more than either.
- The Fable rows fire only from an explicit judgement flag, never inferred
from the last-session record. "The session did not finish" also covers
context exhaustion and operator interrupts, which say nothing about the
model, and Fable runs without an advisor.
- The trait block is a single-line HTML comment because board.sh's NESTE
extractor skips only lines that START with '<!--'. Measured first: a YAML
block or a multi-line comment silently replaces the repo's next step on the
board with "next_task:". Pinned by section 7.
board.sh is untouched as a program; its header now points at route.sh for the
value set so this does not reopen the two-specs defect 305f168 closed.
Selftests: coord 136, board 30, route 47 (new), node 7.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017peNgsxVt1BR4BTuMwiPoX
This commit is contained in:
parent
305f1687b7
commit
0d1183800e
8 changed files with 808 additions and 11 deletions
44
CLAUDE.md
44
CLAUDE.md
|
|
@ -40,10 +40,35 @@ marketplace plugin. Three components, one boundary:
|
|||
of two repos would not be enforcing it. `~/.claude/scripts/board.sh` is a
|
||||
deployed copy (the operator's `board()` shell function points at it), exactly
|
||||
as with the `coord-*` scripts — this repo is the source of truth.
|
||||
- **Skills (`skills/coord-send/`, `skills/board/`):** natural-language front
|
||||
- **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
|
||||
rationale, and prints one block of `key=value` lines: the rubric row, the rule
|
||||
that fired, the `next-cost` value, a pasteable startup command, the one-row
|
||||
cheaper fallback, and the STATE.md comment lines. Pinned by
|
||||
`route-selftest.sh` (47 checks).
|
||||
|
||||
**It is here because it is the WRITER for the field `board.sh` already reads.**
|
||||
`next-cost` had a reader and no writer, so it was hand-typed every session and
|
||||
drifted into several competing spellings — cleaning the data
|
||||
could not fix that, because the cause was the missing write path. The row
|
||||
table is a closed set of six values, so a seventh cannot enter circulation,
|
||||
and section 6 of the selftest runs the round trip (route emits → board parses)
|
||||
*inside* one repo rather than across two. `board.sh` itself is untouched: a
|
||||
calculator that prints to stdout writes nothing, and the session writes
|
||||
STATE.md.
|
||||
|
||||
**The row table is the operator's global rubric, moved here as the single
|
||||
copy.** It is not a second spec — `board.sh --help` documents the board line's
|
||||
*grammar* and points here for the *values*. Scoring the traits is judgement
|
||||
and belongs to the skill; turning scores into a row is a lookup and takes zero
|
||||
model calls.
|
||||
- **Skills (`skills/coord-send/`, `skills/board/`, `skills/route/`):** natural-language front
|
||||
doors mapping user intent to engine invocations. No mailbox logic lives here
|
||||
either. `board` additionally owns the *ranking* — which repo wins and why —
|
||||
since `board.sh` deliberately prints evidence and takes no position.
|
||||
since `board.sh` deliberately prints evidence and takes no position. `route`
|
||||
likewise owns the *scoring*: the calculator is deterministic, so all judgement
|
||||
sits in choosing the four trait values, and the skill must never reason its
|
||||
way to a model instead.
|
||||
|
||||
**Boundary rule:** the mailbox is transport, not state. Durable decisions
|
||||
live in the owning repo's docs/git history; messages are notices pointing at
|
||||
|
|
@ -77,24 +102,27 @@ halves together for exactly that reason.
|
|||
- Zero dependencies everywhere: bash + coreutils in the engine, `node:`
|
||||
builtins only in hook and tests.
|
||||
- TDD: no behavior change without a failing selftest check first.
|
||||
`bash scripts/coord-selftest.sh` must exit 0 (136/136) and
|
||||
`bash scripts/board-selftest.sh` must exit 0 (30/30).
|
||||
`bash scripts/coord-selftest.sh` must exit 0 (136/136),
|
||||
`bash scripts/board-selftest.sh` must exit 0 (30/30) and
|
||||
`bash scripts/route-selftest.sh` must exit 0 (47/47).
|
||||
- English for all code, docs, and commit messages (public repo). Norwegian
|
||||
trigger aliases in the skill description are deliberate.
|
||||
- Conventional Commits: `type(scope): description`.
|
||||
|
||||
## Commands
|
||||
|
||||
- Test: `bash scripts/coord-selftest.sh` and `bash scripts/board-selftest.sh`
|
||||
(or `npm test`, the Node wrapper around both)
|
||||
- Test: `bash scripts/coord-selftest.sh`, `bash scripts/board-selftest.sh` and
|
||||
`bash scripts/route-selftest.sh` (or `npm test`, the Node wrapper around all three)
|
||||
- Hook smoke test: `node hooks/scripts/session-start.mjs` (expects JSON on stdout)
|
||||
- Board smoke test: `bash scripts/board.sh` (read-only, ~3s over the real tree)
|
||||
- Route smoke test: `bash scripts/route.sh --path known --verification strong
|
||||
--reversibility cheap --scope local --rationale x` (writes nothing, instant)
|
||||
|
||||
## Release
|
||||
|
||||
Version must agree across: `.claude-plugin/plugin.json`, `package.json`,
|
||||
README version badge, `skills/coord-send/SKILL.md` and `skills/board/SKILL.md`
|
||||
frontmatter, git tag `vX.Y.Z`, and the catalog `ref` in
|
||||
README version badge, `skills/coord-send/SKILL.md`, `skills/board/SKILL.md` and
|
||||
`skills/route/SKILL.md` frontmatter, git tag `vX.Y.Z`, and the catalog `ref` in
|
||||
`ktg-plugin-marketplace/catalog/.claude-plugin/marketplace.json`. Release via
|
||||
the catalog's `scripts/release-plugin.mjs repo-mailbox` (tag + ref bump
|
||||
together);
|
||||
|
|
|
|||
13
README.md
13
README.md
|
|
@ -77,7 +77,13 @@ Since v0.8.0 it closes with one aggregate line about mail pending in *other* mai
|
|||
|
||||
The mailbox is one of its three inputs, which is why the board lives here. Note the axis: a repo's pending count means *others are waiting on it*, an obligation it owes outward. Who a repo waits *on* comes only from its own board line, because the message format has no reply-to field.
|
||||
|
||||
**CLI.** The engine is six bash scripts in the plugin's `scripts/` directory; resolve them as `"${CLAUDE_PLUGIN_ROOT:-$HOME/.claude}/scripts/coord-<name>.sh"` (from a terminal, use the plugin's install path):
|
||||
**Choosing what to run the next session with (the `route` skill).** The board's cost column has to come from somewhere. `route.sh` is a pure calculator: score four traits of the next task — is the solution path `known`, `partial` or `undetermined`; will `verification` be `strong`, `weak` or `none`; is it `cheap`, `costly` or `one-way` to reverse; is the `scope` `local`, `multi-file` or `cross-cutting` — and it returns the model and effort, a pasteable startup command, and the one-step-cheaper fallback for when quota is tight. Escalation is asymmetric: any single trait escalates, while the cheapest row needs all four at the cheap end. A free-text rationale is required, because that is where a misscore is found later.
|
||||
|
||||
It lives here because it is the **writer** for the cost field the board already reads. That field had a reader and no writer, so it was retyped by hand every session and drifted into several spellings; the row table is a closed set, so a new spelling cannot enter circulation, and the selftest runs the round trip — route emits, board parses — inside one repo instead of across two. Nothing about `board.sh` changes: a calculator printing to stdout writes nothing, and the session writes its own STATE.md.
|
||||
|
||||
Scoring is judgement and belongs to the skill; turning scores into a row is a lookup and costs no model calls. One deliberate side effect is worth more than the tokens saved: a next step that cannot be scored `known` or `partial`, with no design phase planned, is an **underspecified task description** — the answer is to rewrite the step, not to upgrade the model.
|
||||
|
||||
**CLI.** The engine is seven bash scripts in the plugin's `scripts/` directory; resolve them as `"${CLAUDE_PLUGIN_ROOT:-$HOME/.claude}/scripts/coord-<name>.sh"` (from a terminal, use the plugin's install path):
|
||||
|
||||
coord-send.sh --to <repo> --subject "<subject>" [--message "<text>"] # or body on stdin
|
||||
coord-send.sh --broadcast --subject "<subject>" <<'BODY' ... BODY
|
||||
|
|
@ -87,6 +93,8 @@ The mailbox is one of its three inputs, which is why the board lives here. Note
|
|||
coord-done.sh <filename>... | --all # archive without replying
|
||||
coord-count.sh [--exclude <mailbox>] # count pending per mailbox, delivering nothing
|
||||
board.sh [--roots <dir>[,<dir>...]] # cross-repo attention board (read-only)
|
||||
route.sh --path <v> --verification <v> --reversibility <v> \
|
||||
--scope <v> --rationale "<why>" # model + effort for the next session
|
||||
|
||||
The reply/resolve hints the hook injects (`-> reply: coord-send --reply-to … | done without reply: coord-done …`) refer to these scripts.
|
||||
|
||||
|
|
@ -125,7 +133,8 @@ Note that raising the inbox's priority (Rule 7) deliberately does **not** widen
|
|||
|
||||
bash scripts/coord-selftest.sh # 136 checks against a throwaway mailbox
|
||||
bash scripts/board-selftest.sh # 30 checks against a throwaway repo tree
|
||||
npm test # both selftests via node --test
|
||||
bash scripts/route-selftest.sh # 47 checks, incl. the route->board round trip
|
||||
npm test # all three selftests via node --test
|
||||
|
||||
TDD is the house rule: every behavior change lands with a failing selftest check first.
|
||||
|
||||
|
|
|
|||
|
|
@ -27,6 +27,10 @@
|
|||
# any spelling, but this field is compared across repos by eye, so
|
||||
# one form is the whole point - and a second spelling documented
|
||||
# here is how a field with no write path drifts.
|
||||
# The field now HAS a write path: route.sh emits it, and the set of
|
||||
# legal values is that script's row table - not this comment, which
|
||||
# shows the form only. `route.sh --help` is the authority; the two
|
||||
# ends are pinned together by route-selftest.sh section 6.
|
||||
#
|
||||
# ATTENTION AXIS, NOT A TOPIC AXIS. This status vocabulary is deliberately NOT
|
||||
# the vocabulary a cross-repo TOPIC register uses. A topic register answers
|
||||
|
|
|
|||
356
scripts/route-selftest.sh
Executable file
356
scripts/route-selftest.sh
Executable file
|
|
@ -0,0 +1,356 @@
|
|||
#!/bin/bash
|
||||
# route-selftest.sh - prove route.sh against the closed rubric row table, and
|
||||
# prove that what it emits is what board.sh parses back. Re-run after any edit
|
||||
# to route.sh or to the row table. ASCII only, bash 3.2 safe.
|
||||
#
|
||||
# The highest-value section here is 6, the round trip: route.sh WRITES the
|
||||
# next-cost value and board.sh READS it, and until now this repo owned only the
|
||||
# reader. A field with a reader and no writer drifts by construction - that is
|
||||
# the defect that put several competing spellings in circulation. One suite
|
||||
# now pins both ends, so a spelling change that breaks the parser fails here
|
||||
# instead of in the operator's eye three weeks later.
|
||||
set -u
|
||||
export LC_ALL=C
|
||||
|
||||
DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
ROUTE="$DIR/route.sh"
|
||||
BOARD="$DIR/board.sh"
|
||||
|
||||
PASS=0; FAIL=0
|
||||
check() { if [ "$2" -eq 0 ]; then PASS=$((PASS+1)); echo " ok - $1"; else FAIL=$((FAIL+1)); echo " FAIL - $1"; fi; }
|
||||
|
||||
# Multibyte building blocks (octal escapes keep this source ASCII).
|
||||
EMDASH="$(printf '\342\200\224')"
|
||||
HAND="$(printf '\360\237\221\211')"
|
||||
|
||||
R="$ROUTE"
|
||||
# Shorthand: run route.sh with the four traits + a rationale, print one field.
|
||||
# $1..$4 traits, $5 field name.
|
||||
field() {
|
||||
"$R" --path "$1" --verification "$2" --reversibility "$3" --scope "$4" \
|
||||
--rationale "selftest" 2>/dev/null | sed -n "s/^$5=//p"
|
||||
}
|
||||
|
||||
echo "route-selftest"
|
||||
|
||||
# --- 1. Every rubric row is reachable -------------------------------------
|
||||
# A row that no trait combination can produce is dead policy. All six must fire.
|
||||
|
||||
got="$(field known strong cheap local next-cost)"
|
||||
[ "$got" = "Sonnet 5/high" ]; check "row 1: known/strong/cheap/local -> Sonnet 5/high" $?
|
||||
|
||||
got="$(field known weak cheap local next-cost)"
|
||||
[ "$got" = "Sonnet 5/xhigh" ]; check "row 2: weak verification -> Sonnet 5/xhigh" $?
|
||||
|
||||
got="$(field partial strong cheap local next-cost)"
|
||||
[ "$got" = "Opus 5/high" ]; check "row 3: path=partial -> Opus 5/high" $?
|
||||
|
||||
got="$(field known strong cheap cross-cutting next-cost)"
|
||||
[ "$got" = "Opus 5/xhigh" ]; check "row 4: scope=cross-cutting -> Opus 5/xhigh" $?
|
||||
|
||||
got="$("$R" --path known --verification strong --reversibility cheap --scope local \
|
||||
--rationale x --opus-xhigh-failed 2>/dev/null | sed -n 's/^next-cost=//p')"
|
||||
[ "$got" = "Fable 5/high" ]; check "row 5: opus-xhigh-failed -> Fable 5/high" $?
|
||||
|
||||
got="$("$R" --path undetermined --verification strong --reversibility cheap --scope local \
|
||||
--rationale x --opus-xhigh-failed 2>/dev/null | sed -n 's/^next-cost=//p')"
|
||||
[ "$got" = "Fable 5/xhigh" ]; check "row 6: failed + undetermined -> Fable 5/xhigh" $?
|
||||
|
||||
# --- 2. Escalation is asymmetric ------------------------------------------
|
||||
# One trait escalates; a downgrade needs ALL of them. Underkill costs one
|
||||
# session, overkill costs quota every session - so the cheap row must be hard
|
||||
# to reach and the expensive rows easy.
|
||||
|
||||
got="$(field known strong one-way local next-cost)"
|
||||
[ "$got" = "Opus 5/xhigh" ]; check "one-way alone escalates to row 4" $?
|
||||
|
||||
got="$(field known strong costly local next-cost)"
|
||||
[ "$got" = "Opus 5/high" ]; check "costly alone escalates to row 3" $?
|
||||
|
||||
got="$(field known strong cheap multi-file next-cost)"
|
||||
[ "$got" = "Opus 5/high" ]; check "multi-file alone escalates to row 3" $?
|
||||
|
||||
got="$(field known none cheap local next-cost)"
|
||||
[ "$got" = "Sonnet 5/xhigh" ]; check "verification=none alone escalates to row 2" $?
|
||||
|
||||
got="$(field undetermined strong cheap local next-cost)"
|
||||
[ "$got" = "Opus 5/high" ]; check "path=undetermined stops at row 3, not row 4" $?
|
||||
|
||||
# --- 3. The emitted vocabulary is CLOSED ----------------------------------
|
||||
# Every one of the 3*3*3*3 trait combinations, in both history states, must
|
||||
# emit one of exactly six strings. This is what structurally prevents a
|
||||
# seventh spelling from ever entering circulation: not a convention, an
|
||||
# enumeration. The board line's drift was possible only because the field had
|
||||
# no writer with a closed range.
|
||||
VOCAB="|Sonnet 5/high|Sonnet 5/xhigh|Opus 5/high|Opus 5/xhigh|Fable 5/high|Fable 5/xhigh|"
|
||||
bad=0; n=0
|
||||
for p in known partial undetermined; do
|
||||
for v in strong weak none; do
|
||||
for r in cheap costly one-way; do
|
||||
for s in local multi-file cross-cutting; do
|
||||
for h in "" "--opus-xhigh-failed"; do
|
||||
n=$((n+1))
|
||||
out="$("$R" --path "$p" --verification "$v" --reversibility "$r" --scope "$s" \
|
||||
--rationale x $h 2>/dev/null | sed -n 's/^next-cost=//p')"
|
||||
case "$VOCAB" in *"|$out|"*) ;; *) bad=$((bad+1)); echo " out-of-vocab: $p/$v/$r/$s [$out]" ;; esac
|
||||
done
|
||||
done
|
||||
done
|
||||
done
|
||||
done
|
||||
[ "$n" -eq 162 ] && [ "$bad" -eq 0 ]
|
||||
check "all 162 trait combinations emit one of the 6 rubric rows" $?
|
||||
|
||||
# --- 4. Every trait is REQUIRED -------------------------------------------
|
||||
# verification carries the most signal and is the one most often left out, so
|
||||
# a missing trait must be a hard error, never a silent default. A default here
|
||||
# would be indistinguishable from a scored value when the log is read back.
|
||||
"$R" --verification strong --reversibility cheap --scope local --rationale x >/dev/null 2>&1
|
||||
[ $? -eq 2 ]; check "missing --path exits 2" $?
|
||||
"$R" --path known --reversibility cheap --scope local --rationale x >/dev/null 2>&1
|
||||
[ $? -eq 2 ]; check "missing --verification exits 2" $?
|
||||
"$R" --path known --verification strong --scope local --rationale x >/dev/null 2>&1
|
||||
[ $? -eq 2 ]; check "missing --reversibility exits 2" $?
|
||||
"$R" --path known --verification strong --reversibility cheap --rationale x >/dev/null 2>&1
|
||||
[ $? -eq 2 ]; check "missing --scope exits 2" $?
|
||||
"$R" --path known --verification strong --reversibility cheap --scope local >/dev/null 2>&1
|
||||
[ $? -eq 2 ]; check "missing --rationale exits 2" $?
|
||||
|
||||
# An unscored trait must not be smuggled in as empty text either.
|
||||
"$R" --path known --verification strong --reversibility cheap --scope local \
|
||||
--rationale "" >/dev/null 2>&1
|
||||
[ $? -eq 2 ]; check "empty --rationale exits 2" $?
|
||||
|
||||
# --- 5. Invalid trait values are rejected ---------------------------------
|
||||
"$R" --path maybe --verification strong --reversibility cheap --scope local \
|
||||
--rationale x >/dev/null 2>&1
|
||||
[ $? -eq 2 ]; check "invalid --path value exits 2" $?
|
||||
"$R" --path known --verification medium --reversibility cheap --scope local \
|
||||
--rationale x >/dev/null 2>&1
|
||||
[ $? -eq 2 ]; check "invalid --verification value exits 2" $?
|
||||
"$R" --path known --verification strong --reversibility cheap --scope global \
|
||||
--rationale x >/dev/null 2>&1
|
||||
[ $? -eq 2 ]; check "invalid --scope value exits 2" $?
|
||||
"$R" --nonsense >/dev/null 2>&1
|
||||
[ $? -eq 2 ]; check "unknown argument exits 2" $?
|
||||
|
||||
# --- 6. ROUND TRIP: what route WRITES, board READS ------------------------
|
||||
# The reason this repo owns the calculator at all. Splice route.sh's next-cost
|
||||
# into a board line, run the real board.sh over it, and require the KOST column
|
||||
# to show the same string back. Runs for all six rows.
|
||||
ROOT="$(mktemp -d)"
|
||||
MBOX="$(mktemp -d)"
|
||||
cleanup() { /bin/rm -rf "$ROOT" "$MBOX" 2>/dev/null; }
|
||||
trap cleanup EXIT
|
||||
|
||||
rt_bad=0
|
||||
rt_case() { # $1 repo name, $2 next-cost value
|
||||
mkdir -p "$ROOT/$1" && git -C "$ROOT/$1" init -q 2>/dev/null
|
||||
{ echo "# STATE - $1"
|
||||
printf '## %s NESTE %s START HER\n' "$HAND" "$EMDASH"
|
||||
echo "<!-- board: status=in-progress; blocked-on=-; next-cost=$2 -->"
|
||||
echo ""
|
||||
echo "**1.** en helt vanlig prosalinje her."
|
||||
} > "$ROOT/$1/STATE.md"
|
||||
}
|
||||
i=0
|
||||
for combo in "known strong cheap local" "known weak cheap local" \
|
||||
"partial strong cheap local" "known strong cheap cross-cutting"; do
|
||||
set -- $combo
|
||||
i=$((i+1))
|
||||
cost="$(field "$1" "$2" "$3" "$4" next-cost)"
|
||||
rt_case "rt-$i" "$cost"
|
||||
done
|
||||
for h in 5 6; do
|
||||
if [ "$h" = "5" ]; then pp=known; else pp=undetermined; fi
|
||||
cost="$("$R" --path "$pp" --verification strong --reversibility cheap --scope local \
|
||||
--rationale x --opus-xhigh-failed 2>/dev/null | sed -n 's/^next-cost=//p')"
|
||||
rt_case "rt-$h" "$cost"
|
||||
done
|
||||
|
||||
OUT="$(CLAUDE_COORD_DIR="$MBOX" "$BOARD" --roots "$ROOT" 2>/dev/null)"
|
||||
for want in "Sonnet 5/high" "Sonnet 5/xhigh" "Opus 5/high" "Opus 5/xhigh" \
|
||||
"Fable 5/high" "Fable 5/xhigh"; do
|
||||
printf '%s' "$OUT" | grep -q "$want" || { rt_bad=$((rt_bad+1)); echo " board lost: [$want]"; }
|
||||
done
|
||||
[ "$rt_bad" -eq 0 ]; check "round trip: board.sh parses back all 6 emitted values" $?
|
||||
|
||||
# board.sh renders KOST with %-14s; a longer value shoves the whole row right
|
||||
# even though it parsed fine. Measure the widest string the table can emit -
|
||||
# not whatever the loop above happened to leave behind.
|
||||
widest=0
|
||||
for v in "Sonnet 5/high" "Sonnet 5/xhigh" "Opus 5/high" "Opus 5/xhigh" \
|
||||
"Fable 5/high" "Fable 5/xhigh"; do
|
||||
[ "${#v}" -gt "$widest" ] && widest="${#v}"
|
||||
done
|
||||
[ "$widest" -le 14 ]; check "widest emitted next-cost ($widest) fits the KOST column" $?
|
||||
|
||||
# --- 7. The route line does not steal the NESTE column --------------------
|
||||
# Measured before writing route.sh: board.sh's NESTE extractor skips blanks,
|
||||
# lines STARTING with '<!--', and headings, then prints the first line left. A
|
||||
# YAML block or a MULTI-line comment therefore becomes the NESTE column and
|
||||
# silently replaces the operator's next step with "next_task:". A single-line
|
||||
# comment is the only form that survives, which is why the trait line reuses
|
||||
# the board line's one-line grammar instead of inventing a block format.
|
||||
mkdir -p "$ROOT/rt-neste" && git -C "$ROOT/rt-neste" init -q 2>/dev/null
|
||||
LINE="$("$R" --path known --verification strong --reversibility cheap --scope local \
|
||||
--rationale "monsteret finnes i handlers" 2>/dev/null | sed -n 's/^route-line=//p')"
|
||||
{ echo "# STATE - rt-neste"
|
||||
printf '## %s NESTE %s START HER\n' "$HAND" "$EMDASH"
|
||||
echo "<!-- board: status=in-progress; blocked-on=-; next-cost=Sonnet 5/high -->"
|
||||
echo "$LINE"
|
||||
echo ""
|
||||
echo "**1.** prosalinjen som skal overleve."
|
||||
} > "$ROOT/rt-neste/STATE.md"
|
||||
OUT2="$(CLAUDE_COORD_DIR="$MBOX" "$BOARD" --roots "$ROOT" 2>/dev/null)"
|
||||
printf '%s' "$OUT2" | grep -q 'prosalinjen som skal overleve'
|
||||
check "route line under NESTE leaves the prose in the NESTE column" $?
|
||||
|
||||
printf '%s' "$LINE" | grep -q '^<!-- route: .* -->$'
|
||||
check "route line is a single-line HTML comment" $?
|
||||
|
||||
# --- 8. Rationale is untrusted text on a comment line ---------------------
|
||||
# It is free text typed by a session and it lands inside an HTML comment on one
|
||||
# line. A newline splits the line (and hands the next line to the NESTE
|
||||
# extractor); a literal '-->' closes the comment early and dumps the rest into
|
||||
# the rendered STATE.md. Same line-oriented sanitizing the send side does.
|
||||
LINE2="$("$R" --path known --verification strong --reversibility cheap --scope local \
|
||||
--rationale "$(printf 'first\nsecond')" 2>/dev/null | sed -n 's/^route-line=//p')"
|
||||
[ "$(printf '%s' "$LINE2" | wc -l | tr -d ' ')" -eq 0 ]
|
||||
check "newline in rationale does not split the route line" $?
|
||||
|
||||
LINE3="$("$R" --path known --verification strong --reversibility cheap --scope local \
|
||||
--rationale 'oops --> loose text' 2>/dev/null | sed -n 's/^route-line=//p')"
|
||||
[ "$(printf '%s' "$LINE3" | grep -c -- '-->')" -eq 1 ]
|
||||
check "'-->' in rationale cannot close the comment early" $?
|
||||
|
||||
# --- 8b. The last-session record ------------------------------------------
|
||||
# Rows 5 and 6 are history-dependent, so without a record of how the previous
|
||||
# session went they are dead policy. The record is also the cheap proxy for
|
||||
# whether the routing itself is any good: systematically high corrections on
|
||||
# row 1 means the cheap row is too easy to reach, systematically zero on row 4
|
||||
# means escalation fires too readily. It gets a WRITER here for the same
|
||||
# reason next-cost needed one - a format with only a reader drifts.
|
||||
LAST="$("$R" --path known --verification strong --reversibility cheap --scope local \
|
||||
--rationale x --last-model "Opus 5" --last-effort xhigh \
|
||||
--last-completed no --last-corrections 3 2>/dev/null | sed -n 's/^route-last=//p')"
|
||||
printf '%s' "$LAST" | grep -q '^<!-- route-last: model=Opus 5; effort=xhigh; completed=no; corrections=3 -->$'
|
||||
check "route-last line is emitted in the pinned single-line form" $?
|
||||
|
||||
# Omitted record must emit no line at all rather than a half-filled one: a
|
||||
# blank record read back later is indistinguishable from a real measurement.
|
||||
out_norec="$("$R" --path known --verification strong --reversibility cheap \
|
||||
--scope local --rationale x 2>/dev/null)"
|
||||
if printf '%s' "$out_norec" | grep -q '^route-last='; then rc=1; else rc=0; fi
|
||||
check "no route-last line when the record is omitted" "$rc"
|
||||
|
||||
"$R" --path known --verification strong --reversibility cheap --scope local \
|
||||
--rationale x --last-completed maybe >/dev/null 2>&1
|
||||
[ $? -eq 2 ]; check "invalid --last-completed exits 2" $?
|
||||
"$R" --path known --verification strong --reversibility cheap --scope local \
|
||||
--rationale x --last-corrections three >/dev/null 2>&1
|
||||
[ $? -eq 2 ]; check "non-numeric --last-corrections exits 2" $?
|
||||
|
||||
# The record is telemetry and must NOT silently fire the Fable rows. Only the
|
||||
# explicit flag does, because "an opus/xhigh session did not finish" also
|
||||
# covers context exhaustion, an operator interrupt and a block on another repo
|
||||
# - none of which mean the MODEL failed at this step. Fable runs without an
|
||||
# advisor, so this auto-escalation has to stay a judgement, not an inference.
|
||||
got="$("$R" --path known --verification strong --reversibility cheap --scope local \
|
||||
--rationale x --last-model "Opus 5" --last-effort xhigh --last-completed no \
|
||||
--last-corrections 4 2>/dev/null | sed -n 's/^next-cost=//p')"
|
||||
[ "$got" = "Sonnet 5/high" ]; check "a failed opus/xhigh record alone does NOT reach Fable" $?
|
||||
|
||||
# A record is all four fields or none. A partial one emits `corrections=` with
|
||||
# nothing after it, which reads back later exactly like a measured zero.
|
||||
"$R" --path known --verification strong --reversibility cheap --scope local \
|
||||
--rationale x --last-model "Opus 5" --last-effort xhigh >/dev/null 2>&1
|
||||
[ $? -eq 2 ]; check "a partial last-session record exits 2" $?
|
||||
|
||||
# All three comment lines stacked under the heading must still leave the prose
|
||||
# in the NESTE column - that is the arrangement a real STATE.md ends up with.
|
||||
mkdir -p "$ROOT/rt-three" && git -C "$ROOT/rt-three" init -q 2>/dev/null
|
||||
{ echo "# STATE - rt-three"
|
||||
printf '## %s NESTE %s START HER\n' "$HAND" "$EMDASH"
|
||||
echo "<!-- board: status=in-progress; blocked-on=-; next-cost=Sonnet 5/high -->"
|
||||
echo "$LINE"
|
||||
echo "$LAST"
|
||||
echo ""
|
||||
# Kept well under board.sh's 38-char NESTE truncation: a longer line would
|
||||
# be cut mid-word and fail this grep for a reason that has nothing to do
|
||||
# with what is being tested.
|
||||
echo "**1.** tredje prosalinje."
|
||||
} > "$ROOT/rt-three/STATE.md"
|
||||
OUT3="$(CLAUDE_COORD_DIR="$MBOX" "$BOARD" --roots "$ROOT" 2>/dev/null)"
|
||||
printf '%s' "$OUT3" | grep -q 'tredje prosalinje'
|
||||
check "board + route + route-last stacked still yield prose in NESTE" $?
|
||||
|
||||
# --- 9. Startup command and fallback --------------------------------------
|
||||
# Two spellings of ONE decision: the rubric name for the board line, the CLI
|
||||
# alias for the command the operator pastes. They must never disagree.
|
||||
cmd="$(field partial strong cheap local command)"
|
||||
[ "$cmd" = "claude --model opus --effort high" ]; check "command mirrors the row (opus/high)" $?
|
||||
|
||||
cmd="$(field known strong cheap local command)"
|
||||
[ "$cmd" = "claude --model sonnet --effort high" ]; check "command mirrors the row (sonnet/high)" $?
|
||||
|
||||
# The rubric requires ALWAYS naming one row cheaper as the quota fallback.
|
||||
fb="$(field partial strong cheap local fallback)"
|
||||
[ "$fb" = "Sonnet 5/xhigh" ]; check "fallback is exactly one row cheaper" $?
|
||||
|
||||
fb="$(field known strong cheap local fallback)"
|
||||
[ "$fb" = "Sonnet 5/high" ]; check "row 1 fallback floors at row 1, never below" $?
|
||||
|
||||
# The fallback needs its own pasteable command or the operator translates by
|
||||
# hand at exactly the moment they are under quota pressure.
|
||||
fbc="$(field partial strong cheap local fallback-command)"
|
||||
[ "$fbc" = "claude --model sonnet --effort xhigh" ]; check "fallback ships its own command" $?
|
||||
|
||||
# --- 10. The command carries no 'cd' --------------------------------------
|
||||
# One repo per terminal tab: a startup command prefixed with cd is wrong by
|
||||
# construction, and another directory means another tab.
|
||||
out="$("$R" --path known --verification strong --reversibility cheap --scope local \
|
||||
--rationale x 2>/dev/null)"
|
||||
if printf '%s' "$out" | grep -q 'cd '; then rc=1; else rc=0; fi
|
||||
check "no emitted command contains a cd prefix" "$rc"
|
||||
|
||||
# --- 11. Effort and model values are the VERIFIED sets --------------------
|
||||
# Effort levels are pinned in this marketplace at
|
||||
# config-audit/scanners/settings-validator.mjs (low|medium|high|xhigh|max).
|
||||
# Model aliases are whatever the INSTALLED claude accepts - never hardcoded
|
||||
# without a gate, because an alias that stops resolving turns every emitted
|
||||
# command into a paste that fails.
|
||||
efforts="$(printf '%s' "$out" | sed -n 's/^command=claude --model [a-z]* --effort //p')"
|
||||
case "|low|medium|high|xhigh|max|" in *"|$efforts|"*) rc=0 ;; *) rc=1 ;; esac
|
||||
check "emitted effort is in the verified effort set" "$rc"
|
||||
|
||||
if command -v claude >/dev/null 2>&1; then
|
||||
HELPTXT="$(claude --help 2>&1)"
|
||||
miss=0
|
||||
for alias in fable opus sonnet; do
|
||||
printf '%s' "$HELPTXT" | grep -q "'$alias'" || \
|
||||
printf '%s' "$HELPTXT" | grep -q "$alias" || miss=$((miss+1))
|
||||
done
|
||||
[ "$miss" -eq 0 ]; check "installed claude still accepts fable/opus/sonnet aliases" $?
|
||||
else
|
||||
echo " skip - claude not on PATH, model alias gate not run"
|
||||
fi
|
||||
|
||||
# --- 12. One spec, and it is the row table --------------------------------
|
||||
# The last board fix closed a defect whose root cause was this repo's own
|
||||
# --help being the SECOND spec for next-cost. route.sh must not reopen it: its
|
||||
# help may describe the row table (it owns it now) but must not restate the
|
||||
# board line grammar, which board.sh --help owns.
|
||||
HELP="$("$R" --help 2>/dev/null)"; rc=$?
|
||||
[ "$rc" -eq 0 ]; check "--help exits 0" $?
|
||||
printf '%s' "$HELP" | grep -q 'Sonnet 5/high'
|
||||
check "--help shows the canonical rubric spelling" $?
|
||||
if printf '%s' "$HELP" | grep -qE '(sonnet|opus|fable) ?5?/(high|xhigh)'; then rc=1; else rc=0; fi
|
||||
check "--help shows no versionless model example" "$rc"
|
||||
if printf '%s' "$HELP" | grep -q 'blocked-on='; then rc=1; else rc=0; fi
|
||||
check "--help does not restate the board line grammar" "$rc"
|
||||
|
||||
echo ""
|
||||
echo "route-selftest: $PASS passed, $FAIL failed"
|
||||
[ "$FAIL" -eq 0 ] || exit 1
|
||||
exit 0
|
||||
239
scripts/route.sh
Executable file
239
scripts/route.sh
Executable file
|
|
@ -0,0 +1,239 @@
|
|||
#!/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.
|
||||
#
|
||||
# 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 <name> --last-effort <level>
|
||||
# --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.
|
||||
#
|
||||
# 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"
|
||||
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_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
|
||||
}
|
||||
|
||||
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 -->"
|
||||
if [ "$L_SET" -eq 1 ]; then
|
||||
LM="$(printf '%s' "$L_MODEL" | tr '\r\n' ' ' | tr -d '\000-\037' | sed 's/-->/-- >/g')"
|
||||
LE="$(printf '%s' "$L_EFFORT" | tr '\r\n' ' ' | tr -d '\000-\037' | sed 's/-->/-- >/g')"
|
||||
echo "route-last=<!-- route-last: model=$LM; effort=$LE; completed=$L_DONE; corrections=$L_CORR -->"
|
||||
fi
|
||||
exit 0
|
||||
|
|
@ -55,7 +55,7 @@ rule exists to stop. The run costs about three seconds.
|
|||
| Column | Meaning |
|
||||
|---|---|
|
||||
| `STATUS` | `planned` / `in-progress` / `blocked` / `deferred` / `done`, or `blocked>X` naming the repo it waits on. `?` means the STATE.md has no board line. |
|
||||
| `KOST` | Model/effort for the next step, from the global rubric. |
|
||||
| `KOST` | Model/effort for the next step, from the rubric row table in `route.sh` (the `route` skill writes it; this one only reads it). |
|
||||
| `INN` | Unhandled inbox: **other repos are waiting on THIS one**. An obligation it owes outward. |
|
||||
| `DRT` | Uncommitted files. |
|
||||
| `ALDER` | Days since STATE.md last changed. |
|
||||
|
|
|
|||
152
skills/route/SKILL.md
Normal file
152
skills/route/SKILL.md
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
---
|
||||
name: route
|
||||
description: >-
|
||||
Decide which model and reasoning effort the NEXT session should run with, by
|
||||
scoring four traits of the next task and running them through the rubric row
|
||||
table (`route.sh`). Use at session end, whenever STATE.md's next step is
|
||||
written or rewritten, and whenever the operator asks what to launch the next
|
||||
session with: "what model should I use next", "which effort level", "close
|
||||
the session", "wrap up", "update STATE", "what should the next session run
|
||||
with", "is opus overkill here", "give me the startup command". Also triggers
|
||||
on Norwegian phrasings: "hvilken modell neste økt", "hvilken effort", "avslutt
|
||||
sesjonen", "oppdater STATE", "hva skal neste økt kjøre med", "er opus
|
||||
overkill", "gi meg oppstartskommandoen", "modell og effort". Trigger even when
|
||||
the operator names no model and no tool — choosing the model for the next
|
||||
session IS this skill. Not for choosing which REPO gets the next session:
|
||||
that is the `board` skill.
|
||||
version: "0.9.0"
|
||||
---
|
||||
|
||||
# route — what the next session should run with
|
||||
|
||||
The operator's closing line requires two things every single session: the model
|
||||
and effort for the next session, and a pasteable startup command. This skill
|
||||
produces both from evidence instead of from a hunch, and leaves the scoring
|
||||
behind in STATE.md so a wrong call can be found later.
|
||||
|
||||
**The division of labour is the whole design.** Scoring the four traits is
|
||||
judgement and belongs to you. Turning scores into a model is a lookup, and
|
||||
`route.sh` does it deterministically — same scores, same answer, every time,
|
||||
at zero token cost. Never "reason your way" to a model. If you find yourself
|
||||
weighing whether the task feels hard enough for Opus, you have skipped the
|
||||
scoring step and are doing the thing this skill replaces.
|
||||
|
||||
## The engine
|
||||
|
||||
ROUTE="${CLAUDE_PLUGIN_ROOT}/scripts/route.sh"
|
||||
|
||||
"$ROUTE" --path <known|partial|undetermined> \
|
||||
--verification <strong|weak|none> \
|
||||
--reversibility <cheap|costly|one-way> \
|
||||
--scope <local|multi-file|cross-cutting> \
|
||||
--rationale "why these four scores" \
|
||||
[--opus-xhigh-failed] \
|
||||
[--last-model <name> --last-effort <level> \
|
||||
--last-completed <yes|no> --last-corrections <n>]
|
||||
|
||||
There is no deployed copy anywhere else and no fallback path — one entry point
|
||||
is deliberate. Exit 2 means a bad or missing argument; read stderr and fix the
|
||||
call rather than dropping the flag. `route.sh --help` carries the full row table
|
||||
and the reasoning behind it.
|
||||
|
||||
The script writes nothing. It prints `row`, `rule`, `next-cost`, `command`,
|
||||
`fallback`, `fallback-command`, `route-line` and (when the record is given)
|
||||
`route-last`. **You** paste those into STATE.md.
|
||||
|
||||
## Scoring the four traits
|
||||
|
||||
Score the task the NEXT session will do — the one in the `👉 NESTE` block —
|
||||
not the one that just finished. Read the next step as written before scoring;
|
||||
if you cannot score it from what is written, that is a finding (see below).
|
||||
|
||||
| Trait | Score it by asking |
|
||||
|---|---|
|
||||
| `path` | Is the solution route already described? `known` = the steps are written down or the pattern exists in this repo. `partial` = the shape is clear, one real decision is open. `undetermined` = it has to be found first. |
|
||||
| `verification` | Will a machine catch the error? `strong` = tests, types or a compiler fail on it. `weak` = a smoke test or manual run would probably surface it. `none` = prose, API shape, a security judgement — a wrong answer just sits there. |
|
||||
| `reversibility` | `cheap` = a commit away. `costly` = touches published state, needs a migration or a follow-up release. `one-way` = a pushed tag, a public interface, a deletion. |
|
||||
| `scope` | `local` = one file or one function. `multi-file` = several files, one repo. `cross-cutting` = many subsystems, or more than one repo. |
|
||||
|
||||
`verification` carries the most signal and is the trait most often skipped.
|
||||
Strong verification means a cheap model's mistakes get caught and corrected —
|
||||
cheap model plus tight feedback beats an expensive model without it. When
|
||||
nothing verifies the output, model quality is the only defence left.
|
||||
|
||||
Three rules that keep the scoring honest:
|
||||
|
||||
- **Score the task, never the feeling.** "Hard", "complex" and "important" are
|
||||
not traits here. They are unfalsifiable, and they always resolve upward.
|
||||
- **`--rationale` is required and is the point.** It is where a misscore is
|
||||
caught weeks later, when the recommendation turns out to have been wrong. One
|
||||
sentence naming the evidence: "the pattern exists in handlers/, but the error
|
||||
handling is undecided" — not "medium difficulty".
|
||||
- **When torn between two scores, take the more expensive one.** Escalation is
|
||||
asymmetric by design: any one trait escalates, and row 1 needs all four at
|
||||
the cheap end.
|
||||
|
||||
## The specification check you get for free
|
||||
|
||||
If the next step is scored `path=undetermined` and no design phase is planned,
|
||||
**the task description is underspecified — the model is not too small.** Say so,
|
||||
and rewrite the next step until it can be scored. Upgrading the model to
|
||||
compensate for a vague specification is the most expensive form of
|
||||
procrastination available, and it hides the real defect.
|
||||
|
||||
This check is worth more than the tokens the routing saves. Do not skip it by
|
||||
scoring `partial` to keep things moving.
|
||||
|
||||
## The last-session record
|
||||
|
||||
Rows 5 and 6 (Fable) are history-dependent, so without a record they are dead
|
||||
policy. Write it every session, from what actually happened in the session that
|
||||
is ending:
|
||||
|
||||
- `--last-model` / `--last-effort` — what this session actually ran with.
|
||||
- `--last-completed yes|no` — did this session finish the next step the previous
|
||||
STATE.md set out? Answer about that step, not about the session in general.
|
||||
- `--last-corrections <n>` — how many rounds of rework it took. This is the
|
||||
cheap proxy for whether the routing was right.
|
||||
|
||||
Read the previous `route-last` line out of STATE.md before overwriting it.
|
||||
Pass `--opus-xhigh-failed` **only** when it says an `Opus 5`/`xhigh` session ran
|
||||
at this same next step and did not complete it. A session that ran out of
|
||||
context, was interrupted, or stalled waiting on another repo is not a model
|
||||
failure — and Fable runs without an advisor, so a wrong escalation here is
|
||||
expensive. When it is ambiguous, leave the flag off.
|
||||
|
||||
## Writing it into STATE.md
|
||||
|
||||
Three single-line HTML comments sit directly under the `👉 NESTE` heading, in
|
||||
this order:
|
||||
|
||||
<!-- board: status=in-progress; blocked-on=-; next-cost=Opus 5/high -->
|
||||
<!-- route: path=partial; verification=strong; ...; rationale=... -->
|
||||
<!-- route-last: model=Opus 5; effort=xhigh; completed=yes; corrections=1 -->
|
||||
|
||||
Splice the emitted `next-cost` value into the existing board line — leave
|
||||
`status` and `blocked-on` alone, they answer a different question and this skill
|
||||
knows nothing about them.
|
||||
|
||||
**They must stay single-line comments.** `board.sh` reads the first line under
|
||||
the heading that is not blank, not a heading, and does not *start* with `<!--`,
|
||||
and shows it as that repo's next step across every repo. A YAML block or a
|
||||
comment broken across lines therefore replaces the repo's next step on the board
|
||||
with `next_task:`. Measured, not assumed — `route-selftest.sh` section 7 pins it.
|
||||
|
||||
## Reporting it
|
||||
|
||||
Give the operator the two closing-line items and nothing more:
|
||||
|
||||
- **Modell neste økt:** the `next-cost` value, plus the `fallback` one row
|
||||
cheaper for quota pressure. Name the `rule` that fired — "path=partial" — so
|
||||
the call is auditable rather than asserted.
|
||||
- **Oppstartskommando:** the `command` string, in its own code block, with
|
||||
`/exit` named. **Never prefix it with `cd`:** one repo per terminal tab, so
|
||||
the working directory is already right. If the next step belongs in a
|
||||
different repo, say so in plain words — that is a different tab, not a `cd`.
|
||||
|
||||
If `command` and `fallback-command` are the same as the current session's model,
|
||||
say `/clear` is enough instead — but only if no newly installed plugin or skill
|
||||
needs a fresh process to be picked up.
|
||||
|
||||
Do not paste the whole output block. One row, the rule that produced it, the
|
||||
command.
|
||||
|
|
@ -25,6 +25,15 @@ test('board bash selftest passes', () => {
|
|||
execFileSync('bash', [join(root, 'scripts', 'board-selftest.sh')], { encoding: 'utf8' });
|
||||
});
|
||||
|
||||
// route.sh is the WRITER for the next-cost field board.sh already reads, so its
|
||||
// suite runs the round trip across both scripts. Pinned from the plugin root
|
||||
// for the same reason as the board: the skill resolves the engine through
|
||||
// CLAUDE_PLUGIN_ROOT, and a calculator proven only elsewhere is unproven on the
|
||||
// one path production uses.
|
||||
test('route bash selftest passes', () => {
|
||||
execFileSync('bash', [join(root, 'scripts', 'route-selftest.sh')], { encoding: 'utf8' });
|
||||
});
|
||||
|
||||
// The engine refuses to invent an identity from the cwd, but the hook is the
|
||||
// FOURTH place repo identity is derived, and a rule enforced in three of four
|
||||
// places is not a rule: as long as the hook resolved the name itself and passed
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue