From eaa36abb9a037bad75a68c528f2fa6390e5292ab Mon Sep 17 00:00:00 2001 From: Gabriel Borges Date: Thu, 10 Sep 2026 13:41:19 -0400 Subject: [PATCH] feat(routing): unlock the Fable agent on any mention of Fable The spawn hook let gborges-standard:fable-xhigh run only after a message that contained one of three exact @-mention strings. A user who typed 'send this to fable xhigh' still got the spawn denied. The Astra rule on the Codex side already unlocks on the bare word, and the two rules exist for the same reason: the model must not pick the expensive rung on its own. The user asking casually was never the problem. The mention check is now a case-insensitive word match on 'fable', the same shape as the Astra match. The deny reason, the agent description, the style's routing rule, the README, and the two routing docs describe the loosened rule. Three tests cover 'fable xhigh', a capitalized 'Fable', and the non-match on 'fables'. Rev 33 becomes 34. --- README.md | 11 +++++----- docs/model-routing.md | 2 +- docs/subagent-routing.md | 13 ++++++----- .../gborges-standard/agents/fable-xhigh.md | 2 +- .../gborges-standard/hooks/plugin_config.py | 22 +++++++++---------- .../gborges-standard/hooks/route-spawns.py | 10 +++++---- .../output-styles/plain-english.md | 3 ++- scripts/cloud-bootstrap.sh | 2 +- tests/test_hooks.py | 15 +++++++++++++ 9 files changed, 49 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index 7d6d637..1673fb1 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ Paste this loader rather than the body of `scripts/cloud-bootstrap.sh`, so the l ```bash #!/bin/bash -# rev: 33 +# rev: 34 curl -fsSL https://raw.githubusercontent.com/gborges0727/claude-plugins/main/scripts/cloud-bootstrap.sh | bash || true exit 0 ``` @@ -63,7 +63,7 @@ Every PR bumps the `rev`, in the snippet above and in `scripts/cloud-bootstrap.s | `strip-attribution.py` | `PreToolUse` hook | Removes AI-attribution footers from GitHub writes. Enforces the style's ban mechanically | | `flag-server-attribution.py` | `PostToolUse` hook | Tells the session to delete the footer the GitHub server adds to a new PR body, which the `PreToolUse` hook cannot reach | | `route-spawns.py` | `PreToolUse` hook | Decides which agent every spawn runs on, then appends the style's rules to its prompt, since the output style never reaches a subagent. Rewrites an unpinned type (`general-purpose`, `claude`, `default-agent`, or none) to `opus-medium`. Refuses a `fork` on a Fable session, since a fork copies the whole transcript onto the session's model, unless the user's latest message asked for one. Refuses a `fable-xhigh` dispatch the user did not summon, and rewrites it to `opus-xhigh` when `~/.claude/gborges-standard.json` says the account cannot run Fable. Appends Anthropic's long-output note to a `fable-xhigh` prompt, so Fable writes a long deliverable once instead of drafting it in thinking and again as the reply | -| `remind-writing-rules.py` | `UserPromptSubmit` hook | Returns the style's Reminder paragraph as context with every user message, so the rules sit next to the reply being written. Records whether the message named `@agent-fable-xhigh`, used the word fork, or named Astra. An invocation of `pair-debate` records both Fable and Astra | +| `remind-writing-rules.py` | `UserPromptSubmit` hook | Returns the style's Reminder paragraph as context with every user message, so the rules sit next to the reply being written. Records whether the message named Fable, used the word fork, or named Astra. An invocation of `pair-debate` records both Fable and Astra | | `writing-voice` | Skill | Two-pass ritual for every artifact (a file, a PR body, a commit message, a comment), whatever its length. The style alone shapes chat replies | | `read-aloud-prep` | Skill | Rewriting documents so a TTS voice reads them cleanly | | `bear-notes` | Skill | Writing into Bear without minting junk tags and wikilinks | @@ -76,7 +76,7 @@ Every PR bumps the `rev`, in the snippet above and in `scripts/cloud-bootstrap.s | `sonnet-medium` | Agent | Sonnet 5 at medium effort. Edits and runs with a command check in the brief, parallel copies of one such task, and fetching a named doc page | | `opus-medium` | Agent | Opus 5 at medium effort. The default, and the floor for anything that reads code to reach a conclusion | | `opus-xhigh` | Agent | Opus 5 at xhigh effort. One escalation step for a task that failed below it, and the stand-in for Fable on an account without it | -| `fable-xhigh` | Agent | Fable 5.1 at xhigh effort. Runs only when the user's message names `@agent-fable-xhigh`. See [docs/subagent-routing.md](docs/subagent-routing.md) for the routing rule and the cost reasoning | +| `fable-xhigh` | Agent | Fable 5.1 at xhigh effort. Runs only when the user's message names Fable. See [docs/subagent-routing.md](docs/subagent-routing.md) for the routing rule and the cost reasoning | | `frontend-design` | Dependency | From `claude-plugins-official` | | `mattpocock-skills` | Dependency | From `claude-plugins-official` | | `context7` | Dependency | From `claude-plugins-official` | @@ -231,9 +231,8 @@ The definitions alone change nothing, because Claude reaches for rule. `opus-medium` is the default and the floor for any task that reads code to reach a conclusion. `sonnet-medium` takes work a command can check. `opus-xhigh` is one escalation step, taken once, after a failed check. -`fable-xhigh` runs only when the user's own message names -`@agent-fable-xhigh`, and `route-spawns.py` refuses every other -dispatch of it. [docs/subagent-routing.md](docs/subagent-routing.md) holds +`fable-xhigh` runs only when the user's own message names Fable, in any +form, and `route-spawns.py` refuses every other dispatch of it. [docs/subagent-routing.md](docs/subagent-routing.md) holds the cost reasoning. `/gborges-standard:setup` writes `~/.claude/gborges-standard.json`, two diff --git a/docs/model-routing.md b/docs/model-routing.md index 65e6823..cd28d67 100644 --- a/docs/model-routing.md +++ b/docs/model-routing.md @@ -23,7 +23,7 @@ dispatch in the name it types. | mechanical | `sonnet-medium` | `luna-xhigh` | An edit or a run whose brief names the exact change and a command that checks it. Parallel copies of one such task. Fetching a named doc page | | default | `opus-medium` | `sol-xhigh` | Any task that reads code to reach a conclusion (an investigation, a diagnosis, a review, a design choice). On Codex, also any brief that must read past 272K tokens | | escalation | `opus-xhigh` | `astra-medium` | A task that failed once below it. One long dependent chain the orchestrator cannot split | -| summoned | `fable-xhigh` | `astra-xhigh` | Only when the user's own message names the agent. On Codex, any Astra call above medium effort | +| summoned | `fable-xhigh` | `astra-xhigh` | Only when the user's own message names the model, in any form. On Codex, any Astra call above medium effort | On Claude Code, a Codex rung is not a Claude agent. It is a `codex exec` command with the rung's model and effort, run in the background through diff --git a/docs/subagent-routing.md b/docs/subagent-routing.md index 9f9c7a2..d0aed07 100644 --- a/docs/subagent-routing.md +++ b/docs/subagent-routing.md @@ -14,7 +14,7 @@ Fable 5.1 prompting guide as of 2026-09-01. | `sonnet-medium` | Sonnet 5 | medium | An edit or a run whose brief names the exact change and a command that checks it. Parallel copies of one such task across files. Fetching a named doc page outside the codebase | | `opus-medium` | Opus 5 | medium | The default. The floor for any task that reads code to reach a conclusion | | `opus-xhigh` | Opus 5 | xhigh | A task that failed once below it. A task that is one dependent chain the orchestrator cannot split. The stand-in for Fable on an account without it | -| `fable-xhigh` | Fable 5.1 | xhigh | Only when the user's message names `@agent-fable-xhigh` | +| `fable-xhigh` | Fable 5.1 | xhigh | Only when the user's message names Fable | Each name states its model and effort so the orchestrator sees the cost of a dispatch in the name it types. @@ -122,9 +122,12 @@ against `opus-medium` on real briefs, and the rule stands until then. The orchestrator judging a task "hard enough for Fable" is the guess that spends the most money when wrong, so no judgment is allowed. Cheaper cache hits do not change that, because the guess is about which task deserves -the model, not about the per-token price. The user types -`@agent-fable-xhigh`, and a hook refuses every dispatch that did not follow -such a message. +the model, not about the per-token price. The user names Fable in their +message, in any form (fable, fable xhigh, an @-mention), and a hook refuses +every dispatch that did not follow such a message. The rule stops the +orchestrator from choosing Fable. It does not make the user type the full +agent name, so the same word match that unlocks Astra on Codex unlocks +Fable here. ## What the dispatch history says @@ -193,7 +196,7 @@ Anthropic's long-output note, which tells Fable at xhigh to write a long deliverable once instead of drafting it in thinking and again as the reply. `hooks/remind-writing-rules.py` runs on every user message. It records -whether the message named `@agent-fable-xhigh`, and whether it used the +whether the message named Fable, and whether it used the word fork, in two per-session state files under `~/.claude/gborges-standard/state/`. diff --git a/plugins/gborges-standard/agents/fable-xhigh.md b/plugins/gborges-standard/agents/fable-xhigh.md index 6216080..3def43f 100644 --- a/plugins/gborges-standard/agents/fable-xhigh.md +++ b/plugins/gborges-standard/agents/fable-xhigh.md @@ -1,6 +1,6 @@ --- name: fable-xhigh -description: Fable 5.1 at xhigh effort. Runs only when the user's own message names @agent-fable-xhigh. A hook refuses every other dispatch and points it at opus-xhigh. +description: Fable 5.1 at xhigh effort. Runs only when the user's own message names Fable. A hook refuses every other dispatch and points it at opus-xhigh. model: fable effort: xhigh --- diff --git a/plugins/gborges-standard/hooks/plugin_config.py b/plugins/gborges-standard/hooks/plugin_config.py index ccf37ad..f02aa12 100644 --- a/plugins/gborges-standard/hooks/plugin_config.py +++ b/plugins/gborges-standard/hooks/plugin_config.py @@ -13,8 +13,8 @@ non-boolean key all fall back to fable = True and codex = False. Hooks read this on every event, so a broken file must never stop a spawn or a turn. -The same folder also tracks whether the user's latest message named the -Fable agent. mention_from_prompt() decides that from the message text, +The same folder also tracks whether the user's latest message named +Fable. mention_from_prompt() decides that from the message text, write_mention() records the answer for one session, and read_mention() reports it back. The record is one file per session under ~/.claude/gborges-standard/state, holding the single character 1 or 0. The @@ -39,14 +39,12 @@ DEFAULTS = {"fable": True, "codex": False} -# The three ways a user can name the Fable agent in a message. The first is -# what Claude Code inserts for an @-mention of an agent. The other two are -# what a person types by hand. -MENTIONS = ( - "@agent-fable-xhigh", - '@"fable-xhigh (agent)"', - "@gborges-standard:fable-xhigh", -) +# Any mention of Fable counts, the same way any mention of Astra unlocks +# Astra. The rule exists to stop the model from picking Fable on its own, +# not to make the user type the agent's full @-mention. The word match +# catches "fable", "fable xhigh", "fable-xhigh", and the @-mention forms +# Claude Code inserts, since each of those contains the word. +FABLE_WORD = re.compile(r"\bfable\b", re.IGNORECASE) def load(): @@ -69,10 +67,10 @@ def load(): def mention_from_prompt(prompt): - """Say whether this message text names the Fable agent.""" + """Say whether this message text names Fable.""" if not isinstance(prompt, str): return False - return any(form in prompt for form in MENTIONS) + return FABLE_WORD.search(prompt) is not None FORK_WORD = re.compile(r"\bfork\b", re.IGNORECASE) diff --git a/plugins/gborges-standard/hooks/route-spawns.py b/plugins/gborges-standard/hooks/route-spawns.py index ec76ae5..126462d 100644 --- a/plugins/gborges-standard/hooks/route-spawns.py +++ b/plugins/gborges-standard/hooks/route-spawns.py @@ -63,8 +63,10 @@ would break forks on every host whose transcript format differs. The same hook also decides who may run the Fable agent. Fable is the -expensive model, so gborges-standard:fable-xhigh runs only when the user -asked for it by name in their latest message. remind-writing-rules.py +expensive model, so gborges-standard:fable-xhigh runs only when the user's +latest message used the word Fable. Any mention counts, the same way any +mention of Astra unlocks Astra in route-codex.py. The rule stops the main +agent from picking Fable on its own, not the user from asking casually. remind-writing-rules.py records that answer per session, and this hook reads the record. A Fable spawn with no such record comes back denied, and the reason tells the main agent to send the same brief to gborges-standard:opus-xhigh instead. @@ -206,8 +208,8 @@ def on_fable(transcript_path): ) DENY_REASON = ( - "The Fable agent runs only when the user's own message names " - "@agent-fable-xhigh. This message did not, so send the same brief to " + "The Fable agent runs only when the user's own message names Fable. " + "This message did not, so send the same brief to " "gborges-standard:opus-xhigh instead." ) diff --git a/plugins/gborges-standard/output-styles/plain-english.md b/plugins/gborges-standard/output-styles/plain-english.md index 3c690fa..231bd23 100644 --- a/plugins/gborges-standard/output-styles/plain-english.md +++ b/plugins/gborges-standard/output-styles/plain-english.md @@ -174,7 +174,8 @@ Spend words on clarity, save them on scope. without asking, say so in the report, and after a second failure report to the user instead of climbing again. - `gborges-standard:fable-xhigh` runs only when the user's own message names - `@agent-fable-xhigh`. Never pick it yourself. If a dispatch of it errors + Fable, in any form (fable, fable xhigh, an @-mention). Never pick it + yourself. A hook refuses any dispatch the user did not ask for. If a dispatch of it errors because the account cannot run the model, send the same brief to `opus-xhigh` and say you substituted. - The per-machine setup file, `~/.claude/gborges-standard.json`, says diff --git a/scripts/cloud-bootstrap.sh b/scripts/cloud-bootstrap.sh index 87dd40b..f63d32c 100755 --- a/scripts/cloud-bootstrap.sh +++ b/scripts/cloud-bootstrap.sh @@ -11,7 +11,7 @@ # missing or the two numbers differ. After the merge, paste the new rev into # each environment's Setup script field when the change cannot wait out the # snapshot expiry. -# rev: 33 +# rev: 34 set -u diff --git a/tests/test_hooks.py b/tests/test_hooks.py index 1d507bd..873f1d9 100644 --- a/tests/test_hooks.py +++ b/tests/test_hooks.py @@ -134,6 +134,21 @@ def test_full_name_mention_form_also_unlocks_fable(self): out = self.spawn(FABLE) self.assertEqual(out["hookSpecificOutput"]["permissionDecision"], "allow") + def test_the_bare_word_fable_unlocks_fable(self): + self.submit("Send this one to fable xhigh, it's a hard one.") + out = self.spawn(FABLE) + self.assertEqual(out["hookSpecificOutput"]["permissionDecision"], "allow") + + def test_the_word_fable_in_any_case_unlocks_fable(self): + self.submit("Have Fable do it.") + out = self.spawn(FABLE) + self.assertEqual(out["hookSpecificOutput"]["permissionDecision"], "allow") + + def test_a_word_that_only_contains_fable_does_not_unlock_it(self): + self.submit("Read the fables folder and summarize it.") + out = self.spawn(FABLE) + self.assertEqual(out["hookSpecificOutput"]["permissionDecision"], "deny") + def test_a_mention_in_one_session_does_not_unlock_another(self): self.submit("Use @agent-fable-xhigh.", session_id="s1") out = self.spawn(FABLE, session_id="s2")