magus-cli 0.0.0-placeholder → 7.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +309 -0
- package/bin/magus.js +170 -0
- package/package.json +67 -3
- package/scripts/build-binaries.ts +113 -0
- package/scripts/capture-builtin.ts +412 -0
- package/scripts/check-optional-deps.ts +49 -0
- package/scripts/test-isolated.ts +49 -0
- package/scripts/verify-binary.ts +95 -0
- package/scripts/verify-community-registry.ts +272 -0
- package/src/__tests__/active-closure.test.ts +184 -0
- package/src/__tests__/alias-adopt.test.ts +365 -0
- package/src/__tests__/alias-parser.test.ts +417 -0
- package/src/__tests__/alias-shell-writer.test.ts +681 -0
- package/src/__tests__/alias-store.test.ts +163 -0
- package/src/__tests__/binary-signing.test.ts +120 -0
- package/src/__tests__/blackbox-active-closure.test.ts +119 -0
- package/src/__tests__/blackbox-convergence.test.ts +334 -0
- package/src/__tests__/blackbox-parity.test.ts +725 -0
- package/src/__tests__/blackbox-plugin-state.test.ts +545 -0
- package/src/__tests__/blackbox-tool-state.test.ts +669 -0
- package/src/__tests__/cache-provenance.test.ts +84 -0
- package/src/__tests__/capture-builtin.test.ts +92 -0
- package/src/__tests__/catalog-cache-store.test.ts +309 -0
- package/src/__tests__/catalog-notice.test.ts +155 -0
- package/src/__tests__/cli-ansi.test.ts +297 -0
- package/src/__tests__/cli-apply-seams.test.ts +288 -0
- package/src/__tests__/cli-live.test.ts +391 -0
- package/src/__tests__/cli-router.test.ts +240 -0
- package/src/__tests__/cli-tool-commands.test.ts +239 -0
- package/src/__tests__/cli-tools-adapter.test.ts +294 -0
- package/src/__tests__/cli-update-view.test.ts +524 -0
- package/src/__tests__/community-fetch.test.ts +545 -0
- package/src/__tests__/community-registry.test.ts +269 -0
- package/src/__tests__/community-staleness.test.ts +720 -0
- package/src/__tests__/component-badge.test.ts +98 -0
- package/src/__tests__/component-icons.test.ts +94 -0
- package/src/__tests__/content-drift.test.ts +353 -0
- package/src/__tests__/conventions-integration.test.ts +830 -0
- package/src/__tests__/conventions-manager.test.ts +1236 -0
- package/src/__tests__/doctor-bins.test.ts +43 -0
- package/src/__tests__/doctor.test.ts +463 -0
- package/src/__tests__/dotenv.test.ts +195 -0
- package/src/__tests__/enabled-not-installed.test.ts +94 -0
- package/src/__tests__/file-locking.test.ts +208 -0
- package/src/__tests__/fixtures/tool-probe/brew-list-versions-tmux.txt +2 -0
- package/src/__tests__/fixtures/tool-probe/brew-outdated-behind-homebrew-spec.txt +29 -0
- package/src/__tests__/fixtures/tool-probe/brew-outdated-tmux-current.txt +7 -0
- package/src/__tests__/fixtures/tool-probe/brew-outdated-unknown-formula.txt +6 -0
- package/src/__tests__/fixtures/tool-probe/go-version-m-tmux-mcp.txt +27 -0
- package/src/__tests__/fixtures/tool-probe/npm-dist-tags-missing-scoped.txt +4 -0
- package/src/__tests__/fixtures/tool-probe/npm-dist-tags-openai-codex.txt +4 -0
- package/src/__tests__/fixtures/tool-probe/pypi-cowsay.txt +3 -0
- package/src/__tests__/fixtures/tool-probe/uv-receipt-browser-use.toml +10 -0
- package/src/__tests__/fixtures/tool-probe/uv-receipt-cowsay-git.toml +8 -0
- package/src/__tests__/fixtures/tool-probe/uv-receipt-cowsay-pinned.toml +8 -0
- package/src/__tests__/fixtures/tool-probe/uv-receipt-graphifyy.toml +7 -0
- package/src/__tests__/fixtures/tool-probe/uv-receipt-mcp.toml +6 -0
- package/src/__tests__/fixtures/tool-probe/uv-receipt-serena-agent.toml +9 -0
- package/src/__tests__/fixtures/tool-probe/uv-tool-list-outdated-pinned.txt +5 -0
- package/src/__tests__/fixtures/tool-probe/uv-tool-list-outdated.txt +11 -0
- package/src/__tests__/fixtures/tool-probe/uv-tool-list.txt +22 -0
- package/src/__tests__/footer-hints.test.ts +40 -0
- package/src/__tests__/format-command.test.ts +78 -0
- package/src/__tests__/git-worktree.test.ts +108 -0
- package/src/__tests__/github-budget.test.ts +206 -0
- package/src/__tests__/gitignore-check.test.ts +71 -0
- package/src/__tests__/gitignore-detector.test.ts +176 -0
- package/src/__tests__/gitignore-fixer.test.ts +317 -0
- package/src/__tests__/gitignore-resolver.test.ts +193 -0
- package/src/__tests__/gitignore-service.test.ts +137 -0
- package/src/__tests__/go-module.test.ts +43 -0
- package/src/__tests__/hook-import-policy.test.ts +90 -0
- package/src/__tests__/hook-process.test.ts +256 -0
- package/src/__tests__/hook-registration.test.ts +224 -0
- package/src/__tests__/install-command.test.ts +81 -0
- package/src/__tests__/install-plan.test.ts +104 -0
- package/src/__tests__/launcher-signal.test.ts +308 -0
- package/src/__tests__/local-marketplace-scan.test.ts +248 -0
- package/src/__tests__/manifest.test.ts +281 -0
- package/src/__tests__/markdown-renderer.test.ts +142 -0
- package/src/__tests__/marketplace-badge.test.ts +140 -0
- package/src/__tests__/marketplace-refresh.test.ts +340 -0
- package/src/__tests__/marketplace-version.test.ts +84 -0
- package/src/__tests__/marketplaces.test.ts +128 -0
- package/src/__tests__/mate-availability.test.ts +156 -0
- package/src/__tests__/mate-catalog.test.ts +295 -0
- package/src/__tests__/mcp-registry.test.ts +158 -0
- package/src/__tests__/model-visuals.test.tsx +2466 -0
- package/src/__tests__/models-adapter.test.ts +332 -0
- package/src/__tests__/models-cli.test.ts +273 -0
- package/src/__tests__/models-core.test.ts +802 -0
- package/src/__tests__/models-manager.test.ts +500 -0
- package/src/__tests__/models-presets-marketplace.test.ts +168 -0
- package/src/__tests__/models-screen-state.test.ts +315 -0
- package/src/__tests__/open-file.test.ts +59 -0
- package/src/__tests__/owned-marketplaces.test.ts +93 -0
- package/src/__tests__/plugin-checkout.test.ts +66 -0
- package/src/__tests__/plugin-cli-argv.test.ts +109 -0
- package/src/__tests__/plugin-contents.test.ts +371 -0
- package/src/__tests__/plugin-manager-fallback.test.ts +320 -0
- package/src/__tests__/plugin-releases.test.ts +98 -0
- package/src/__tests__/plugin-requires.test.ts +187 -0
- package/src/__tests__/plugin-row-text.test.ts +345 -0
- package/src/__tests__/plugin-setup.test.ts +1016 -0
- package/src/__tests__/profile-adopt.test.ts +177 -0
- package/src/__tests__/profile-command.test.ts +110 -0
- package/src/__tests__/profile-materializer.test.ts +238 -0
- package/src/__tests__/profile-sync.test.ts +154 -0
- package/src/__tests__/registry-version-resolution.test.ts +88 -0
- package/src/__tests__/resolve-executable.test.ts +42 -0
- package/src/__tests__/resolver.test.ts +351 -0
- package/src/__tests__/scope-action.test.ts +134 -0
- package/src/__tests__/scope-squares.test.tsx +164 -0
- package/src/__tests__/selection-scope.test.ts +100 -0
- package/src/__tests__/settings-file.test.ts +179 -0
- package/src/__tests__/shell-script-callers.test.ts +92 -0
- package/src/__tests__/skill-install-files.test.ts +136 -0
- package/src/__tests__/skills-catalog-client.test.ts +48 -0
- package/src/__tests__/style-wrap.test.ts +220 -0
- package/src/__tests__/styles-manager.test.ts +1098 -0
- package/src/__tests__/styles-origins.test.ts +414 -0
- package/src/__tests__/styles-screen-state.test.ts +457 -0
- package/src/__tests__/styles-status-line.test.ts +72 -0
- package/src/__tests__/styles-sync.test.ts +452 -0
- package/src/__tests__/symlink-manager.test.ts +180 -0
- package/src/__tests__/tabbar-layout.test.ts +100 -0
- package/src/__tests__/terminology-filler.test.ts +214 -0
- package/src/__tests__/theme-adaptive-colors.test.ts +354 -0
- package/src/__tests__/theme-env.test.ts +310 -0
- package/src/__tests__/theme-resolve.test.ts +380 -0
- package/src/__tests__/tool-probe.test.ts +1400 -0
- package/src/__tests__/toolchain.test.ts +232 -0
- package/src/__tests__/ui-version-writers.test.ts +88 -0
- package/src/__tests__/update-apply.test.ts +637 -0
- package/src/__tests__/update-plan.test.ts +1396 -0
- package/src/__tests__/uppercase-keybindings.test.ts +101 -0
- package/src/__tests__/useGitignoreModal.test.ts +79 -0
- package/src/__tests__/version-check-refresh.test.ts +45 -0
- package/src/__tests__/version-snapshot.test.ts +212 -0
- package/src/__tests__/version-unknown-state.test.ts +403 -0
- package/src/__tests__/worktree-registry-resolution.test.ts +107 -0
- package/src/cli/ansi.ts +528 -0
- package/src/cli/bootstrap.ts +134 -0
- package/src/cli/doctor.ts +218 -0
- package/src/cli/hook.ts +129 -0
- package/src/cli/install.ts +382 -0
- package/src/cli/live.ts +374 -0
- package/src/cli/models.ts +297 -0
- package/src/cli/profile.ts +197 -0
- package/src/cli/prompt.ts +32 -0
- package/src/cli/router.ts +264 -0
- package/src/cli/update-view.ts +717 -0
- package/src/cli/update.ts +600 -0
- package/src/cli/upgrade.ts +97 -0
- package/src/data/alias-flags.ts +301 -0
- package/src/data/cli-tools.ts +146 -0
- package/src/data/community-styles.ts +531 -0
- package/src/data/gitignore-defaults.ts +33 -0
- package/src/data/gitignore-reasons.ts +93 -0
- package/src/data/gitignore-templates.ts +25 -0
- package/src/data/marketplaces.ts +278 -0
- package/src/data/mcp-servers.ts +51 -0
- package/src/data/models-presets.ts +329 -0
- package/src/data/predefined-profiles.ts +252 -0
- package/src/data/settings-catalog.ts +714 -0
- package/src/data/skill-repos.ts +91 -0
- package/src/data/styles/asd-ste100.md +38 -0
- package/src/data/styles/calibrated.md +24 -0
- package/src/data/styles/direct.md +23 -0
- package/src/data/styles/evidence-first.md +22 -0
- package/src/data/styles/explanatory.md +23 -0
- package/src/data/styles/index.ts +57 -0
- package/src/data/styles/no-slop.md +32 -0
- package/src/data/styles/plain-language.md +23 -0
- package/src/data/styles/structured.md +30 -0
- package/src/data/styles/terminology.md +28 -0
- package/src/data/styles/terse.md +20 -0
- package/src/main.tsx +110 -0
- package/src/markdown.d.ts +13 -0
- package/src/opentui.d.ts +203 -0
- package/src/services/active-closure.ts +117 -0
- package/src/services/alias-settings.ts +60 -0
- package/src/services/alias-shell-writer.ts +1244 -0
- package/src/services/alias-store.ts +172 -0
- package/src/services/binary-signing.ts +78 -0
- package/src/services/catalog-cache-store.ts +316 -0
- package/src/services/claude-cli.ts +304 -0
- package/src/services/claude-runner.ts +31 -0
- package/src/services/claude-settings.ts +1564 -0
- package/src/services/cli-tool-commands.ts +133 -0
- package/src/services/community-fetcher.ts +90 -0
- package/src/services/community-styles.ts +1194 -0
- package/src/services/content-drift.ts +208 -0
- package/src/services/conventions-manager.ts +945 -0
- package/src/services/doctor-bins.ts +63 -0
- package/src/services/doctor.ts +570 -0
- package/src/services/dotenv.ts +181 -0
- package/src/services/git-worktree.ts +129 -0
- package/src/services/github-budget.ts +274 -0
- package/src/services/gitignore-check.ts +63 -0
- package/src/services/gitignore-detector.ts +167 -0
- package/src/services/gitignore-fixer.ts +280 -0
- package/src/services/gitignore-resolver.ts +208 -0
- package/src/services/gitignore-service.ts +167 -0
- package/src/services/hook-registration.ts +218 -0
- package/src/services/install-plan.ts +107 -0
- package/src/services/local-marketplace.ts +657 -0
- package/src/services/manifest.ts +250 -0
- package/src/services/marketplace-catalog-git.ts +173 -0
- package/src/services/marketplace-catalog.ts +129 -0
- package/src/services/marketplace-fetcher.ts +400 -0
- package/src/services/marketplace-refresh.ts +245 -0
- package/src/services/marketplace-sync.ts +145 -0
- package/src/services/mate-availability.ts +133 -0
- package/src/services/mate-catalog.ts +265 -0
- package/src/services/mcp-registry.ts +171 -0
- package/src/services/models-core.ts +969 -0
- package/src/services/models-manager.ts +606 -0
- package/src/services/plugin-checkout.ts +186 -0
- package/src/services/plugin-manager.ts +847 -0
- package/src/services/plugin-mcp-config.ts +242 -0
- package/src/services/plugin-releases.ts +47 -0
- package/src/services/plugin-requires.ts +227 -0
- package/src/services/plugin-setup.ts +965 -0
- package/src/services/profile-adopt.ts +206 -0
- package/src/services/profile-materializer.ts +133 -0
- package/src/services/profile-sync.ts +158 -0
- package/src/services/profiles.ts +225 -0
- package/src/services/resolver.ts +377 -0
- package/src/services/settings-file.ts +69 -0
- package/src/services/settings-manager.ts +307 -0
- package/src/services/skills-manager.ts +1111 -0
- package/src/services/skillsmp-client.ts +193 -0
- package/src/services/styles-manager.ts +1294 -0
- package/src/services/symlink-manager.ts +189 -0
- package/src/services/terminology-filler.ts +266 -0
- package/src/services/tool-probe.ts +1637 -0
- package/src/services/toolchain.ts +431 -0
- package/src/services/update-engine.ts +691 -0
- package/src/services/update-plan.ts +820 -0
- package/src/services/version-check.ts +127 -0
- package/src/services/version-snapshot.ts +189 -0
- package/src/tui.tsx +112 -0
- package/src/types/bun.d.ts +27 -0
- package/src/types/gitignore.ts +68 -0
- package/src/types/index.ts +447 -0
- package/src/ui/App.tsx +535 -0
- package/src/ui/adapters/catalogNotice.ts +122 -0
- package/src/ui/adapters/cliToolsAdapter.ts +176 -0
- package/src/ui/adapters/modelsAdapter.ts +213 -0
- package/src/ui/adapters/pluginsAdapter.ts +413 -0
- package/src/ui/adapters/settingsAdapter.ts +168 -0
- package/src/ui/adapters/skillsAdapter.ts +229 -0
- package/src/ui/adapters/stylesAdapter.ts +403 -0
- package/src/ui/components/CategoryHeader.tsx +64 -0
- package/src/ui/components/EmptyFilterState.tsx +32 -0
- package/src/ui/components/FlagDetailEditor.tsx +0 -0
- package/src/ui/components/ScopeIndicator.tsx +63 -0
- package/src/ui/components/ScrollableList.tsx +95 -0
- package/src/ui/components/SearchInput.tsx +57 -0
- package/src/ui/components/StyledText.tsx +75 -0
- package/src/ui/components/TabBar.tsx +101 -0
- package/src/ui/components/layout/FooterHints.tsx +78 -0
- package/src/ui/components/layout/Panel.tsx +63 -0
- package/src/ui/components/layout/ProgressBar.tsx +44 -0
- package/src/ui/components/layout/ScopeTabs.tsx +54 -0
- package/src/ui/components/layout/ScreenLayout.tsx +321 -0
- package/src/ui/components/layout/index.ts +9 -0
- package/src/ui/components/modals/ConfirmModal.tsx +62 -0
- package/src/ui/components/modals/InputModal.tsx +75 -0
- package/src/ui/components/modals/LoadingModal.tsx +51 -0
- package/src/ui/components/modals/MessageModal.tsx +66 -0
- package/src/ui/components/modals/ModalContainer.tsx +159 -0
- package/src/ui/components/modals/SelectModal.tsx +90 -0
- package/src/ui/components/modals/index.ts +6 -0
- package/src/ui/components/primitives/ActionHints.tsx +44 -0
- package/src/ui/components/primitives/DetailSection.tsx +23 -0
- package/src/ui/components/primitives/KeyValueLine.tsx +26 -0
- package/src/ui/components/primitives/ListCategoryRow.tsx +45 -0
- package/src/ui/components/primitives/MetaText.tsx +40 -0
- package/src/ui/components/primitives/ScopeDetail.tsx +67 -0
- package/src/ui/components/primitives/ScopeSquares.tsx +58 -0
- package/src/ui/components/primitives/SelectableRow.tsx +30 -0
- package/src/ui/components/primitives/index.ts +9 -0
- package/src/ui/hooks/index.ts +2 -0
- package/src/ui/hooks/useGitignoreModal.ts +95 -0
- package/src/ui/hooks/useKeyboard.ts +24 -0
- package/src/ui/hooks/useKeyboardHandler.ts +63 -0
- package/src/ui/registry.ts +33 -0
- package/src/ui/renderers/cliToolRenderers.tsx +326 -0
- package/src/ui/renderers/gitignoreRenderers.tsx +80 -0
- package/src/ui/renderers/markdownRenderer.tsx +302 -0
- package/src/ui/renderers/mcpRenderers.tsx +149 -0
- package/src/ui/renderers/modelRenderers.tsx +1309 -0
- package/src/ui/renderers/modelVisuals.tsx +1402 -0
- package/src/ui/renderers/pluginRenderers.tsx +1089 -0
- package/src/ui/renderers/pluginRowText.ts +70 -0
- package/src/ui/renderers/pluginStatusText.ts +56 -0
- package/src/ui/renderers/profileRenderers.tsx +406 -0
- package/src/ui/renderers/settingsRenderers.tsx +218 -0
- package/src/ui/renderers/skillRenderers.tsx +479 -0
- package/src/ui/renderers/styleRenderers.tsx +813 -0
- package/src/ui/screens/AliasScreen.tsx +1558 -0
- package/src/ui/screens/CliToolsScreen.tsx +367 -0
- package/src/ui/screens/EnvVarsScreen.tsx +221 -0
- package/src/ui/screens/GitignoreScreen.tsx +562 -0
- package/src/ui/screens/McpRegistryScreen.tsx +366 -0
- package/src/ui/screens/McpScreen.tsx +256 -0
- package/src/ui/screens/ModelsScreen.tsx +542 -0
- package/src/ui/screens/PluginsScreen.tsx +1423 -0
- package/src/ui/screens/ProfilesScreen.tsx +438 -0
- package/src/ui/screens/SkillsScreen.tsx +853 -0
- package/src/ui/screens/StylesScreen.tsx +1064 -0
- package/src/ui/screens/index.ts +11 -0
- package/src/ui/state/AppContext.tsx +233 -0
- package/src/ui/state/DimensionsContext.tsx +154 -0
- package/src/ui/state/reducer.ts +810 -0
- package/src/ui/state/types.ts +503 -0
- package/src/ui/theme-mode.ts +440 -0
- package/src/ui/theme-resolve.ts +147 -0
- package/src/ui/theme.ts +175 -0
- package/src/utils/clipboard.ts +58 -0
- package/src/utils/command-utils.ts +149 -0
- package/src/utils/config-dir.ts +45 -0
- package/src/utils/file-locking.ts +146 -0
- package/src/utils/fuzzy-search.ts +138 -0
- package/src/utils/go-module.ts +36 -0
- package/src/utils/open-file.ts +84 -0
- package/src/utils/run.ts +67 -0
- package/src/utils/string-utils.ts +88 -0
|
@@ -0,0 +1,969 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-subagent model routing — the pure core.
|
|
3
|
+
*
|
|
4
|
+
* Claude Code has no per-agent model or effort knob that a project can set. Agent frontmatter
|
|
5
|
+
* is static, settings has one global `model`/`effortLevel`, and `CLAUDE_CODE_SUBAGENT_MODEL`
|
|
6
|
+
* is one model for every subagent. The one dynamic seam is a `PreToolUse` hook, which may
|
|
7
|
+
* rewrite the `Agent` tool's input.
|
|
8
|
+
*
|
|
9
|
+
* EVERY RULE BELOW IS MEASURED, not inferred. `benches/agent-model-routing/` (AMR-1) ran nine
|
|
10
|
+
* variants over one scenario on the interactive path, 2026-09-09, Claude Code 2.1.263,
|
|
11
|
+
* n=5 per run, 50 sessions, every run 5/5 or 0/5:
|
|
12
|
+
*
|
|
13
|
+
* 1. Injecting `updatedInput.model` DOES decide the spawned subagent's model.
|
|
14
|
+
* `hook-named` 5/5 (subagent on claude-haiku-4-5-20251001) vs `nohook` 0/5 (inherited
|
|
15
|
+
* claude-sonnet-5). One-sided Fisher p = 0.004.
|
|
16
|
+
* 2. `hookEventName` is REQUIRED. The payload without it ran on all five passes and changed
|
|
17
|
+
* nothing — so ALWAYS emit it.
|
|
18
|
+
* 3. `permissionDecision: "allow"` is neither required nor harmful, so it is NOT emitted:
|
|
19
|
+
* it would widen permission for the call in exchange for nothing.
|
|
20
|
+
* 4. The value is SCHEMA-CHECKED. A non-alias string refused the spawn outright on all five
|
|
21
|
+
* passes — the session reports a tool error and gets no subagent at all. Hence
|
|
22
|
+
* `isRoutableModel` below, and hence the hook re-checks the value even though the writer
|
|
23
|
+
* already validated it (see `evaluateAgentHook`).
|
|
24
|
+
* 5. Per-model effort DOES reach a subagent, and ALIAS keys work:
|
|
25
|
+
* `modelSettings: {"opus": {effortLevel: "low"}}` put the opus subagent at `low` while
|
|
26
|
+
* the sonnet main thread stayed `high` (5/5), against 0/5 with no modelSettings.
|
|
27
|
+
* 6. When a full model id and an alias key are BOTH present for one model and disagree, the
|
|
28
|
+
* FULL ID WINS (`effort-conflict` came back `xhigh`, the full-id value, 5/5). This is why
|
|
29
|
+
* `buildSettingsPatch` emits both forms with the same value rather than picking one:
|
|
30
|
+
* whichever the runtime prefers, they agree, and a user whose `/effort` already wrote a
|
|
31
|
+
* full-id entry cannot silently override the project's intent.
|
|
32
|
+
*
|
|
33
|
+
* This module is PURE: no fs, no process, no network. Everything that touches disk lives in
|
|
34
|
+
* `models-manager.ts`, and the hook entry point is `cli/hook.ts`. That split is what lets the
|
|
35
|
+
* hook — which Claude Code spawns on every single Agent call — stay small and fast, and lets
|
|
36
|
+
* every rule above be tested without a Claude session.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
/** The model aliases Claude Code's Agent tool accepts. Measured: anything else is refused. */
|
|
40
|
+
export const MODEL_ALIASES = ["opus", "sonnet", "haiku", "fable"] as const;
|
|
41
|
+
export type ModelAlias = (typeof MODEL_ALIASES)[number];
|
|
42
|
+
|
|
43
|
+
/** Effort levels the settings vocabulary accepts. `max` is frontmatter-only; we never write it. */
|
|
44
|
+
export const EFFORTS = ["low", "medium", "high", "xhigh"] as const;
|
|
45
|
+
export type Effort = (typeof EFFORTS)[number];
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Grades are named for the ROLE they serve, not for a rank.
|
|
49
|
+
*
|
|
50
|
+
* `critical` is the smartest model, and it is deliberately not the orchestrator's: the work
|
|
51
|
+
* that benefits from the strongest model is architecture, planning and review, while the
|
|
52
|
+
* thread that dispatches them mostly routes and summarises.
|
|
53
|
+
*/
|
|
54
|
+
export const GRADES = ["smart", "normal", "cheap"] as const;
|
|
55
|
+
export type Grade = (typeof GRADES)[number];
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Subagents whose model is a third-party external one, reached through claudish rather than
|
|
59
|
+
* through Claude Code's own aliases.
|
|
60
|
+
*
|
|
61
|
+
* Named for what they are to this tool: someone else's model, helping out. `kangaroo` is
|
|
62
|
+
* `mate3` — it is a joke, and it is load-bearing only in the sense that nobody mistakes it
|
|
63
|
+
* for a Claude alias.
|
|
64
|
+
*
|
|
65
|
+
* These are NOT routable. `MODEL_ALIASES` is the entire set the Agent tool accepts, and
|
|
66
|
+
* AMR-1 measured that anything else refuses the spawn (5/5). A mate therefore behaves like
|
|
67
|
+
* `inherit` at the hook: magus declines to rewrite the call, and whatever claudish or the
|
|
68
|
+
* agent's own frontmatter decides is what runs.
|
|
69
|
+
*/
|
|
70
|
+
export const MATES = ["mate1", "mate2", "kangaroo"] as const;
|
|
71
|
+
export type Mate = (typeof MATES)[number];
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* An agent's model: an alias, `inherit` to leave the call alone, or a mate.
|
|
75
|
+
*
|
|
76
|
+
* A mate is the second opt-out, and it says something `inherit` does not: `inherit` means the
|
|
77
|
+
* author declined to decide, a mate means the author sent this agent somewhere magus has
|
|
78
|
+
* no say over. Both produce the same hook behaviour and different words on screen.
|
|
79
|
+
*/
|
|
80
|
+
export type AgentModel = ModelAlias | "inherit" | Mate;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* What a slot is BOUND to: a real external model, and optionally an effort for it.
|
|
84
|
+
*
|
|
85
|
+
* A slot is a ROLE — "the first external helper" — and a role is not a model. Until this
|
|
86
|
+
* existed the screen had nothing else to print in its Model column and printed the slot name
|
|
87
|
+
* there, so a row read `mate1 mate1 —`: the same word twice, neither of them naming a model.
|
|
88
|
+
*
|
|
89
|
+
* `model` is a BARE CATALOG ID (`grok-4.6`), never an `openrouterId` (`moonshotai/kimi-k3`)
|
|
90
|
+
* and never an Access route (`kc@kimi-k3`). Both of those forms PIN THE PROVIDER, which
|
|
91
|
+
* bypasses claudish's subscription-aware routing — the committed file should record the
|
|
92
|
+
* model's IDENTITY and leave the route to the thing that owns routing. The validator rejects
|
|
93
|
+
* `/` and `@` for exactly that reason, and says so.
|
|
94
|
+
*/
|
|
95
|
+
export interface MateBinding {
|
|
96
|
+
/** A bare catalog id. No `@`, no `/` — see above. */
|
|
97
|
+
model: string;
|
|
98
|
+
/**
|
|
99
|
+
* DECLARED, not applied. magus writes it and nothing in magus reads it back:
|
|
100
|
+
* effort reaches a Claude subagent through `settings.modelSettings`, which is keyed by
|
|
101
|
+
* models Claude Code runs, and an external model has no key there. It is here for
|
|
102
|
+
* claudish to read. Every place it is drawn says so — see `effortWord`.
|
|
103
|
+
*/
|
|
104
|
+
effort?: Effort;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The bindings, by slot. A slot may be absent: unbound is legal, and renders as unset. */
|
|
108
|
+
export type MatesConfig = Partial<Record<Mate, MateBinding>>;
|
|
109
|
+
/**
|
|
110
|
+
* The main thread may also ask for a long-context variant.
|
|
111
|
+
*
|
|
112
|
+
* Deliberately NOT widened to `Mate`. magus writes `main.model` straight into
|
|
113
|
+
* `settings.model`, which has to name a model Claude Code itself runs — a mate names one it
|
|
114
|
+
* has never heard of. The validator rejects it and says so; see `validateModelsConfig`.
|
|
115
|
+
*/
|
|
116
|
+
export type MainModel = ModelAlias | `${ModelAlias}[1m]`;
|
|
117
|
+
|
|
118
|
+
export interface GradeSpec {
|
|
119
|
+
model: AgentModel;
|
|
120
|
+
/** Applied through `modelSettings`, keyed by this model. See rule 5 above. */
|
|
121
|
+
effort?: Effort;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export interface MainSpec {
|
|
125
|
+
model: MainModel;
|
|
126
|
+
effort?: Effort;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** An agent is assigned a grade by name, or overridden with its own model. */
|
|
130
|
+
export type AgentAssignment = Grade | { model: AgentModel; effort?: Effort };
|
|
131
|
+
|
|
132
|
+
export interface ModelsConfig {
|
|
133
|
+
version: 1;
|
|
134
|
+
/** A built-in preset's name, or "custom" once hand-edited. Never load-bearing. */
|
|
135
|
+
preset: string;
|
|
136
|
+
main: MainSpec;
|
|
137
|
+
grades: Record<Grade, GradeSpec>;
|
|
138
|
+
agents: Record<string, AgentAssignment>;
|
|
139
|
+
/** Grade for an agent with no assignment. */
|
|
140
|
+
fallback: Grade;
|
|
141
|
+
/**
|
|
142
|
+
* Which external model each slot names. Optional in full: every config written before
|
|
143
|
+
* this existed stays valid, and an unbound slot renders as unset rather than erroring.
|
|
144
|
+
*/
|
|
145
|
+
mates?: MatesConfig;
|
|
146
|
+
/**
|
|
147
|
+
* The effort every slot runs at unless it names its own.
|
|
148
|
+
*
|
|
149
|
+
* A PRESET DECISION, like the grades above it. A preset cannot bind a slot to a model —
|
|
150
|
+
* it ships in this repo and a catalogue id would go stale — but it can say how hard the
|
|
151
|
+
* external work should try, and that is the half of the answer that does not depend on
|
|
152
|
+
* which model serves it.
|
|
153
|
+
*
|
|
154
|
+
* A slot's own `mates[slot].effort` wins where it is set, so a project can raise or lower
|
|
155
|
+
* one slot without leaving the preset. `mateEffort()` is the single place that resolution
|
|
156
|
+
* happens.
|
|
157
|
+
*/
|
|
158
|
+
mateEffort?: Effort;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export interface ValidationError {
|
|
162
|
+
path: string;
|
|
163
|
+
message: string;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const isRec = (v: unknown): v is Record<string, unknown> =>
|
|
167
|
+
typeof v === "object" && v !== null && !Array.isArray(v);
|
|
168
|
+
|
|
169
|
+
export function isModelAlias(v: unknown): v is ModelAlias {
|
|
170
|
+
return (
|
|
171
|
+
typeof v === "string" && (MODEL_ALIASES as readonly string[]).includes(v)
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function isEffort(v: unknown): v is Effort {
|
|
176
|
+
return typeof v === "string" && (EFFORTS as readonly string[]).includes(v);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function isGrade(v: unknown): v is Grade {
|
|
180
|
+
return typeof v === "string" && (GRADES as readonly string[]).includes(v);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Is this one of the three external-model slots?
|
|
185
|
+
*
|
|
186
|
+
* Exact match only, and no prefix rule: `mate1` is a slot, `mate10` is a typo, and near-
|
|
187
|
+
* matching one into the other would route an agent somewhere its author never named.
|
|
188
|
+
*/
|
|
189
|
+
export function isMate(v: unknown): v is Mate {
|
|
190
|
+
return typeof v === "string" && (MATES as readonly string[]).includes(v);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* The values that own no `modelSettings` KEY: `inherit`, and every mate.
|
|
195
|
+
*
|
|
196
|
+
* Effort reaches a subagent through `modelSettings`, which is keyed BY MODEL (AMR-1 rule 5).
|
|
197
|
+
* `inherit` names none by construction; a mate names one Claude Code has never heard of, so
|
|
198
|
+
* a key under it could not select anything.
|
|
199
|
+
*
|
|
200
|
+
* This is now used by exactly ONE rule — the one-model-one-effort conflict pass — and it
|
|
201
|
+
* deliberately no longer gates VALIDATION. Those two questions looked identical and are not:
|
|
202
|
+
*
|
|
203
|
+
* - "may this carry an effort at all?" `inherit` no, a mate YES. A mate's effort is a
|
|
204
|
+
* per-call value for claudish, not a settings key, so it is a legitimate thing to write.
|
|
205
|
+
* - "can this collide with another effort over a shared key?" neither, because neither
|
|
206
|
+
* has a key to collide over. Two mates bound to the SAME external model may still carry
|
|
207
|
+
* different efforts, and that is not a conflict for the same reason.
|
|
208
|
+
*
|
|
209
|
+
* Collapsing them back into one predicate is what made an effort on a mate a validation
|
|
210
|
+
* error in the first place.
|
|
211
|
+
*/
|
|
212
|
+
function namesNoKeyableModel(model: unknown): boolean {
|
|
213
|
+
return model === "inherit" || isMate(model);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Why a bare catalog id, and not the two forms that also identify a model.
|
|
218
|
+
*
|
|
219
|
+
* Both rejected forms name a ROUTE rather than an identity, and a committed file that pins a
|
|
220
|
+
* route makes every reader of it use that route — including a reader whose subscription
|
|
221
|
+
* would have served the same model directly. Routing is claudish's to decide; this file
|
|
222
|
+
* records WHICH MODEL, and nothing about how to reach it.
|
|
223
|
+
*/
|
|
224
|
+
const ROUTE_PINNED =
|
|
225
|
+
"pins the provider and bypasses claudish's subscription-aware routing — store the model's identity, not a route to it";
|
|
226
|
+
|
|
227
|
+
/** Shape-check one `mates` entry. Returns the errors, which may be empty. */
|
|
228
|
+
function validateMateBinding(slot: string, value: unknown): ValidationError[] {
|
|
229
|
+
const out: ValidationError[] = [];
|
|
230
|
+
const at = `mates.${slot}`;
|
|
231
|
+
if (!isRec(value)) {
|
|
232
|
+
return [{ path: at, message: "must be an object with a `model`" }];
|
|
233
|
+
}
|
|
234
|
+
const model = value.model;
|
|
235
|
+
if (typeof model !== "string" || model.length === 0) {
|
|
236
|
+
out.push({
|
|
237
|
+
path: `${at}.model`,
|
|
238
|
+
message:
|
|
239
|
+
"must be a non-empty string naming a model in claudish's catalogue",
|
|
240
|
+
});
|
|
241
|
+
} else if (model.includes("/")) {
|
|
242
|
+
out.push({
|
|
243
|
+
path: `${at}.model`,
|
|
244
|
+
message: `${JSON.stringify(model)} looks like an openrouterId. A provider-prefixed id ${ROUTE_PINNED}. Use the bare catalogue id — \`${model.slice(model.lastIndexOf("/") + 1)}\``,
|
|
245
|
+
});
|
|
246
|
+
} else if (model.includes("@")) {
|
|
247
|
+
out.push({
|
|
248
|
+
path: `${at}.model`,
|
|
249
|
+
message: `${JSON.stringify(model)} looks like an Access route. A \`provider@model\` selector ${ROUTE_PINNED}. Use the bare catalogue id — \`${model.slice(model.indexOf("@") + 1)}\``,
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
if (value.effort !== undefined && !isEffort(value.effort)) {
|
|
253
|
+
out.push({
|
|
254
|
+
path: `${at}.effort`,
|
|
255
|
+
message: `must be one of ${EFFORTS.join(", ")}`,
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
return out;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Is this a value the Agent tool will accept as `model`?
|
|
263
|
+
*
|
|
264
|
+
* Measured (AMR-1 `bogus-model`, 5/5): a non-alias refuses the spawn, the session reports a
|
|
265
|
+
* tool error, and the agent cannot work around it. So this is checked in THREE places on
|
|
266
|
+
* purpose — at write time, at install time, and again in the hook — because the cost of a bad
|
|
267
|
+
* value is not a bad model, it is every subagent spawn failing for everyone who pulled the
|
|
268
|
+
* config.
|
|
269
|
+
*
|
|
270
|
+
* Mates are excluded DELIBERATELY, and widening this to admit them would be the one change
|
|
271
|
+
* that turns the whole feature into an outage: a mate is a name claudish understands and the
|
|
272
|
+
* Agent tool does not, so injecting one is exactly the `bogus-model` case measured above.
|
|
273
|
+
*/
|
|
274
|
+
export function isRoutableModel(v: unknown): v is ModelAlias {
|
|
275
|
+
return isModelAlias(v);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Strip a `[1m]` (or any bracketed) suffix to get the underlying alias. */
|
|
279
|
+
export function baseAlias(model: string): string {
|
|
280
|
+
const i = model.indexOf("[");
|
|
281
|
+
return i === -1 ? model : model.slice(0, i);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Validate a parsed `models.json`. Returns [] when `raw` is a usable ModelsConfig.
|
|
286
|
+
*
|
|
287
|
+
* Hand-rolled rather than schema-library-driven, matching `manifest.ts validateManifest`:
|
|
288
|
+
* magus ships no validation dependency, and the errors here are meant to be read by a
|
|
289
|
+
* person fixing a committed file.
|
|
290
|
+
*
|
|
291
|
+
* A MATE IS VALID WHEREVER AN AGENT MODEL IS, whether or not the multimodel plugin is
|
|
292
|
+
* installed on this machine. Validity is a property of the file, not of the machine reading
|
|
293
|
+
* it: a `models.json` is committed and shared, so gating `mate1` on a local install would
|
|
294
|
+
* make the same file valid on one teammate's laptop and invalid on the next one's — and the
|
|
295
|
+
* failure would land on whoever had not installed the plugin yet, over a routing decision
|
|
296
|
+
* they did not make. Whether the slots are DRAWN is a UI question, answered in the renderers.
|
|
297
|
+
*/
|
|
298
|
+
export function validateModelsConfig(raw: unknown): ValidationError[] {
|
|
299
|
+
const errors: ValidationError[] = [];
|
|
300
|
+
const err = (path: string, message: string) => errors.push({ path, message });
|
|
301
|
+
|
|
302
|
+
if (!isRec(raw)) return [{ path: "", message: "must be a JSON object" }];
|
|
303
|
+
|
|
304
|
+
if (raw.version !== 1) {
|
|
305
|
+
err("version", `must be 1, got ${JSON.stringify(raw.version)}`);
|
|
306
|
+
// Everything below assumes v1's shape; reading on would produce noise, not findings.
|
|
307
|
+
return errors;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
if (typeof raw.preset !== "string" || raw.preset.length === 0) {
|
|
311
|
+
err("preset", 'must be a non-empty string (a built-in name, or "custom")');
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// ── main ────────────────────────────────────────────────────────────────────────────
|
|
315
|
+
if (!isRec(raw.main)) {
|
|
316
|
+
err("main", "must be an object with a `model`");
|
|
317
|
+
} else {
|
|
318
|
+
const m = raw.main.model;
|
|
319
|
+
if (typeof m !== "string") {
|
|
320
|
+
err("main.model", "must be a string");
|
|
321
|
+
} else if (m === "inherit") {
|
|
322
|
+
err(
|
|
323
|
+
"main.model",
|
|
324
|
+
"`inherit` is for agents only — the main thread needs a real model",
|
|
325
|
+
);
|
|
326
|
+
} else if (isMate(m)) {
|
|
327
|
+
// The main thread is the one seat a mate can never take. Its model is written to
|
|
328
|
+
// `settings.model`, which Claude Code reads to start the session itself — a name
|
|
329
|
+
// only claudish understands would leave the session with no model at all, and
|
|
330
|
+
// unlike a subagent there is nothing left to fall back to.
|
|
331
|
+
err(
|
|
332
|
+
"main.model",
|
|
333
|
+
`\`${m}\` is for subagents only — magus writes main.model into settings.model, so it has to name a model Claude Code runs (${MODEL_ALIASES.join(", ")}). A mate names an external model reached through claudish, which the main thread cannot use`,
|
|
334
|
+
);
|
|
335
|
+
} else if (!isModelAlias(baseAlias(m))) {
|
|
336
|
+
err(
|
|
337
|
+
"main.model",
|
|
338
|
+
`must be one of ${MODEL_ALIASES.join(", ")} (optionally with a [1m] suffix), got ${JSON.stringify(m)}. Full model ids are rejected: they go stale, and an alias always names the current model`,
|
|
339
|
+
);
|
|
340
|
+
} else if (m !== baseAlias(m) && !/^[a-z]+\[1m\]$/.test(m)) {
|
|
341
|
+
err(
|
|
342
|
+
"main.model",
|
|
343
|
+
`only a [1m] suffix is understood, got ${JSON.stringify(m)}`,
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
if (raw.main.effort !== undefined && !isEffort(raw.main.effort)) {
|
|
347
|
+
err("main.effort", `must be one of ${EFFORTS.join(", ")}`);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// ── grades ──────────────────────────────────────────────────────────────────────────
|
|
352
|
+
if (!isRec(raw.grades)) {
|
|
353
|
+
err("grades", `must be an object with the keys ${GRADES.join(", ")}`);
|
|
354
|
+
} else {
|
|
355
|
+
for (const g of GRADES) {
|
|
356
|
+
const spec = raw.grades[g];
|
|
357
|
+
if (!isRec(spec)) {
|
|
358
|
+
err(`grades.${g}`, "must be an object with a `model`");
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
361
|
+
if (
|
|
362
|
+
spec.model !== "inherit" &&
|
|
363
|
+
!isMate(spec.model) &&
|
|
364
|
+
!isModelAlias(spec.model)
|
|
365
|
+
) {
|
|
366
|
+
err(
|
|
367
|
+
`grades.${g}.model`,
|
|
368
|
+
`must be one of ${MODEL_ALIASES.join(", ")}, "inherit", or a mate (${MATES.join(", ")}), got ${JSON.stringify(spec.model)}`,
|
|
369
|
+
);
|
|
370
|
+
}
|
|
371
|
+
// `inherit` ONLY. An effort on a MATE is valid — declared for claudish rather
|
|
372
|
+
// than written to modelSettings — so `namesNoKeyableModel` is deliberately not
|
|
373
|
+
// used here; its comment explains why the two questions are not the same one.
|
|
374
|
+
if (spec.model === "inherit" && spec.effort !== undefined) {
|
|
375
|
+
err(
|
|
376
|
+
`grades.${g}.effort`,
|
|
377
|
+
"cannot set effort on `inherit` — effort is applied per model through modelSettings, and inherit names no model",
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
if (spec.effort !== undefined && !isEffort(spec.effort)) {
|
|
381
|
+
err(`grades.${g}.effort`, `must be one of ${EFFORTS.join(", ")}`);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
for (const key of Object.keys(raw.grades)) {
|
|
385
|
+
if (!isGrade(key))
|
|
386
|
+
err(
|
|
387
|
+
`grades.${key}`,
|
|
388
|
+
`unknown grade — expected one of ${GRADES.join(", ")}`,
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// ── fallback ────────────────────────────────────────────────────────────────────────
|
|
394
|
+
// Named `fallback`, not `default`: it answers a different question from the tiers —
|
|
395
|
+
// not "how much model", but "what an agent gets when nothing names it at all".
|
|
396
|
+
if (!isGrade(raw.fallback)) {
|
|
397
|
+
err("fallback", `must name a tier: ${GRADES.join(", ")}`);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// ── agents ──────────────────────────────────────────────────────────────────────────
|
|
401
|
+
if (raw.agents !== undefined && !isRec(raw.agents)) {
|
|
402
|
+
err(
|
|
403
|
+
"agents",
|
|
404
|
+
"must be an object mapping subagent_type to a grade name or {model}",
|
|
405
|
+
);
|
|
406
|
+
} else if (isRec(raw.agents)) {
|
|
407
|
+
for (const [name, value] of Object.entries(raw.agents)) {
|
|
408
|
+
if (typeof value === "string") {
|
|
409
|
+
if (!isGrade(value)) {
|
|
410
|
+
err(
|
|
411
|
+
`agents.${name}`,
|
|
412
|
+
`must name a grade (${GRADES.join(", ")}) or be an object with a model`,
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
continue;
|
|
416
|
+
}
|
|
417
|
+
if (!isRec(value)) {
|
|
418
|
+
err(
|
|
419
|
+
`agents.${name}`,
|
|
420
|
+
"must be a grade name or an object with a `model`",
|
|
421
|
+
);
|
|
422
|
+
continue;
|
|
423
|
+
}
|
|
424
|
+
if (
|
|
425
|
+
value.model !== "inherit" &&
|
|
426
|
+
!isMate(value.model) &&
|
|
427
|
+
!isModelAlias(value.model)
|
|
428
|
+
) {
|
|
429
|
+
err(
|
|
430
|
+
`agents.${name}.model`,
|
|
431
|
+
`must be one of ${MODEL_ALIASES.join(", ")}, "inherit", or a mate (${MATES.join(", ")}), got ${JSON.stringify(value.model)}`,
|
|
432
|
+
);
|
|
433
|
+
}
|
|
434
|
+
if (value.effort !== undefined && !isEffort(value.effort)) {
|
|
435
|
+
err(`agents.${name}.effort`, `must be one of ${EFFORTS.join(", ")}`);
|
|
436
|
+
}
|
|
437
|
+
// `inherit` ONLY — see the identical rule on grades above.
|
|
438
|
+
if (value.model === "inherit" && value.effort !== undefined) {
|
|
439
|
+
err(
|
|
440
|
+
`agents.${name}.effort`,
|
|
441
|
+
"cannot set effort on `inherit` — effort is applied per model, and inherit names no model",
|
|
442
|
+
);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// ── mates ───────────────────────────────────────────────────────────────────────────
|
|
448
|
+
//
|
|
449
|
+
// VALIDATION IS OFFLINE AND CONSULTS NO CATALOGUE. Shape only — that `grok-4.6` is a
|
|
450
|
+
// plausible bare id, never that it exists.
|
|
451
|
+
//
|
|
452
|
+
// A `models.json` is committed and shared, so its validity has to be a property of the
|
|
453
|
+
// FILE. A validator that reached the network would make the same file valid on a
|
|
454
|
+
// connected laptop and invalid on a plane, and the failure would land on whoever happened
|
|
455
|
+
// to open it offline, over a routing decision they did not make. Whether a bound id is
|
|
456
|
+
// still in the live catalogue is a real question, and it is answered separately, as an
|
|
457
|
+
// ADVISORY note — `services/mate-catalog.ts`, which never blocks anything.
|
|
458
|
+
if (raw.mates !== undefined && !isRec(raw.mates)) {
|
|
459
|
+
err("mates", `must be an object keyed by ${MATES.join(", ")}`);
|
|
460
|
+
} else if (isRec(raw.mates)) {
|
|
461
|
+
for (const [slot, value] of Object.entries(raw.mates)) {
|
|
462
|
+
if (!isMate(slot)) {
|
|
463
|
+
// No near-matching: `mate10` is a typo, not `mate1`, and quietly binding one
|
|
464
|
+
// to the other would send an agent to a model its author never named.
|
|
465
|
+
err(
|
|
466
|
+
`mates.${slot}`,
|
|
467
|
+
`unknown slot — expected one of ${MATES.join(", ")}`,
|
|
468
|
+
);
|
|
469
|
+
continue;
|
|
470
|
+
}
|
|
471
|
+
for (const e of validateMateBinding(slot, value)) errors.push(e);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// ── one model, one effort ───────────────────────────────────────────────────────────
|
|
476
|
+
// `modelSettings` is keyed by MODEL, so two entries naming the same model cannot carry
|
|
477
|
+
// different efforts — one would silently lose. The main thread counts: `main.effort` IS
|
|
478
|
+
// the effort of `main.model`. Reported as a conflict rather than resolved, because there
|
|
479
|
+
// is no defensible way to pick a winner on the author's behalf.
|
|
480
|
+
if (errors.length === 0) {
|
|
481
|
+
const cfg = raw as unknown as ModelsConfig;
|
|
482
|
+
const claims = new Map<string, { effort: Effort; at: string }>();
|
|
483
|
+
const claim = (model: string, effort: Effort | undefined, at: string) => {
|
|
484
|
+
// Mates are skipped with `inherit`, and by the same predicate: neither owns a
|
|
485
|
+
// `modelSettings` key, so neither can collide with anything over one. Two seats
|
|
486
|
+
// on `mate1` are not a conflict — they are two agents sent to claudish, which is
|
|
487
|
+
// the normal way to use a slot.
|
|
488
|
+
//
|
|
489
|
+
// This exemption now carries real weight. Mates DO carry effort (the rule was
|
|
490
|
+
// reversed), so without it two agents on `mate1` at different efforts would be
|
|
491
|
+
// reported as a conflict — and it is not one. Effort on a mate is a per-call
|
|
492
|
+
// value handed to claudish, not a settings key, so two mates bound to the very
|
|
493
|
+
// same external model may still legitimately declare different efforts.
|
|
494
|
+
if (effort === undefined || namesNoKeyableModel(model)) return;
|
|
495
|
+
const key = baseAlias(model);
|
|
496
|
+
const prior = claims.get(key);
|
|
497
|
+
if (prior && prior.effort !== effort) {
|
|
498
|
+
err(
|
|
499
|
+
at,
|
|
500
|
+
`${key} is already set to effort "${prior.effort}" by ${prior.at}. Effort is applied per model, so one model cannot carry two efforts — give them different models, or use the same effort`,
|
|
501
|
+
);
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
if (!prior) claims.set(key, { effort, at });
|
|
505
|
+
};
|
|
506
|
+
claim(cfg.main.model, cfg.main.effort, "main");
|
|
507
|
+
for (const g of GRADES)
|
|
508
|
+
claim(cfg.grades[g].model, cfg.grades[g].effort, `grades.${g}`);
|
|
509
|
+
for (const [name, value] of Object.entries(cfg.agents ?? {})) {
|
|
510
|
+
if (typeof value !== "string")
|
|
511
|
+
claim(value.model, value.effort, `agents.${name}`);
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
return errors;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// ─── Reading a binding ────────────────────────────────────────────────────────────────────
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* What the Model column shows for an unbound slot.
|
|
522
|
+
*
|
|
523
|
+
* NOT the slot name repeated, and not a blank. The slot name in a Model column is what this
|
|
524
|
+
* whole change exists to remove — it said `mate1 mate1 —` and neither word named a model. A
|
|
525
|
+
* blank would read as the `—` that means "nothing was decided", which is the opposite: the
|
|
526
|
+
* author decided to route here and has not yet said where.
|
|
527
|
+
*/
|
|
528
|
+
export const UNBOUND_MATE = "unset";
|
|
529
|
+
|
|
530
|
+
/** The catalogue id a slot is bound to, or null when it is unbound. */
|
|
531
|
+
export function boundModel(
|
|
532
|
+
config: Pick<ModelsConfig, "mates">,
|
|
533
|
+
slot: Mate,
|
|
534
|
+
): string | null {
|
|
535
|
+
return config.mates?.[slot]?.model ?? null;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* The effort a slot runs at: its own if it names one, otherwise the preset's.
|
|
540
|
+
*
|
|
541
|
+
* Two rungs and no third. The slot is the specific answer and wins; the preset is the
|
|
542
|
+
* general one and covers every slot that did not bother. Resolved here rather than at each
|
|
543
|
+
* call site, so the tier table, the agent table and the CLI cannot disagree about what a
|
|
544
|
+
* slot with no effort of its own is actually running at.
|
|
545
|
+
*/
|
|
546
|
+
export function mateEffort(
|
|
547
|
+
config: Pick<ModelsConfig, "mates" | "mateEffort">,
|
|
548
|
+
slot: Mate,
|
|
549
|
+
): Effort | undefined {
|
|
550
|
+
return config.mates?.[slot]?.effort ?? config.mateEffort;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* The text that names a routing value: a bound slot resolves to its model, everything else
|
|
555
|
+
* is already a model name.
|
|
556
|
+
*
|
|
557
|
+
* One function, because the Model column, its width maths and the CLI all have to print the
|
|
558
|
+
* SAME string. The renderers measure a column from this and then draw from it; the defect
|
|
559
|
+
* `modelWidth` exists to prevent — a column measured from a different string than the one
|
|
560
|
+
* rendered — is exactly what two copies of this rule would reintroduce.
|
|
561
|
+
*/
|
|
562
|
+
export function modelLabel(
|
|
563
|
+
config: Pick<ModelsConfig, "mates">,
|
|
564
|
+
model: string,
|
|
565
|
+
): string {
|
|
566
|
+
if (!isMate(model)) return model;
|
|
567
|
+
return boundModel(config, model) ?? UNBOUND_MATE;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* A config as a canonical string, for comparing two of them.
|
|
572
|
+
*
|
|
573
|
+
* Key order is normalised because the two sides come from different places: a built-in preset
|
|
574
|
+
* is a TypeScript literal and a project's config is parsed from a file, so a plain
|
|
575
|
+
* `JSON.stringify` comparison would call two identical configs different whenever the file
|
|
576
|
+
* happened to list `fallback` before `agents`.
|
|
577
|
+
*/
|
|
578
|
+
function canonical(value: unknown): string {
|
|
579
|
+
if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
|
|
580
|
+
if (value && typeof value === "object") {
|
|
581
|
+
const entries = Object.entries(value as Record<string, unknown>)
|
|
582
|
+
// `undefined` members are ABSENT, not present-and-empty: `{effort: undefined}` and
|
|
583
|
+
// `{}` describe the same routing, and a JSON round-trip turns the first into the
|
|
584
|
+
// second — so a literal compared against a parsed file would differ over nothing.
|
|
585
|
+
.filter(([, v]) => v !== undefined)
|
|
586
|
+
.sort(([a], [b]) => a.localeCompare(b));
|
|
587
|
+
return `{${entries.map(([k, v]) => `${k}:${canonical(v)}`).join(",")}}`;
|
|
588
|
+
}
|
|
589
|
+
return JSON.stringify(value) ?? "null";
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* Do these two configs describe the same routing?
|
|
594
|
+
*
|
|
595
|
+
* Exists because "is the project running a built-in" cannot be answered by NAME. `magus
|
|
596
|
+
* models use opus-lead` writes `preset: "opus-lead"`, and every hand edit afterwards — a
|
|
597
|
+
* `mates` block, a rebound agent — leaves that name untouched. A name check therefore reports
|
|
598
|
+
* a diverged config as a built-in, which is how the one config on the machine that can name a
|
|
599
|
+
* mate became invisible to the TUI.
|
|
600
|
+
*/
|
|
601
|
+
export function sameRouting(a: ModelsConfig, b: ModelsConfig): boolean {
|
|
602
|
+
return canonical(a) === canonical(b);
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/** Every slot a config actually uses — assigned to a grade or to an agent. */
|
|
606
|
+
export function matesInUse(config: ModelsConfig): Mate[] {
|
|
607
|
+
const seen = new Set<string>();
|
|
608
|
+
for (const grade of GRADES) seen.add(config.grades[grade].model);
|
|
609
|
+
for (const assignment of Object.values(config.agents ?? {})) {
|
|
610
|
+
if (typeof assignment !== "string") seen.add(assignment.model);
|
|
611
|
+
}
|
|
612
|
+
return MATES.filter((mate) => seen.has(mate));
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
export interface Routing {
|
|
616
|
+
model: AgentModel;
|
|
617
|
+
effort?: Effort;
|
|
618
|
+
/** Where the answer came from — surfaced by `models status` and the hook's log. */
|
|
619
|
+
source: "agent-override" | "agent-grade" | "default-grade";
|
|
620
|
+
grade?: Grade;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/**
|
|
624
|
+
* Which model and effort should serve `subagentType`?
|
|
625
|
+
*
|
|
626
|
+
* An unknown agent falls to `fallback`, which is why a new plugin agent routes sensibly on the
|
|
627
|
+
* day it appears rather than silently inheriting the orchestrator's model.
|
|
628
|
+
*/
|
|
629
|
+
export function resolveAgentRouting(
|
|
630
|
+
config: ModelsConfig,
|
|
631
|
+
subagentType: string | undefined,
|
|
632
|
+
): Routing {
|
|
633
|
+
const assignment =
|
|
634
|
+
subagentType === undefined ? undefined : config.agents?.[subagentType];
|
|
635
|
+
|
|
636
|
+
if (assignment !== undefined && typeof assignment !== "string") {
|
|
637
|
+
const out: Routing = { model: assignment.model, source: "agent-override" };
|
|
638
|
+
if (assignment.effort !== undefined) out.effort = assignment.effort;
|
|
639
|
+
return out;
|
|
640
|
+
}
|
|
641
|
+
const grade: Grade =
|
|
642
|
+
typeof assignment === "string" ? assignment : config.fallback;
|
|
643
|
+
const spec = config.grades[grade];
|
|
644
|
+
const out: Routing = {
|
|
645
|
+
model: spec.model,
|
|
646
|
+
source: typeof assignment === "string" ? "agent-grade" : "default-grade",
|
|
647
|
+
grade,
|
|
648
|
+
};
|
|
649
|
+
if (spec.effort !== undefined) out.effort = spec.effort;
|
|
650
|
+
return out;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
export interface AgentHookPayload {
|
|
654
|
+
hook_event_name?: string;
|
|
655
|
+
tool_name?: string;
|
|
656
|
+
tool_input?: Record<string, unknown>;
|
|
657
|
+
cwd?: string;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
export type AgentHookDecision =
|
|
661
|
+
| {
|
|
662
|
+
kind: "passthrough";
|
|
663
|
+
reason:
|
|
664
|
+
| "not-agent"
|
|
665
|
+
| "no-config"
|
|
666
|
+
| "invalid-config"
|
|
667
|
+
| "explicit-model"
|
|
668
|
+
| "inherit"
|
|
669
|
+
| "mate"
|
|
670
|
+
| "unroutable-model";
|
|
671
|
+
}
|
|
672
|
+
| {
|
|
673
|
+
kind: "update";
|
|
674
|
+
model: ModelAlias;
|
|
675
|
+
output: {
|
|
676
|
+
hookSpecificOutput: {
|
|
677
|
+
hookEventName: "PreToolUse";
|
|
678
|
+
updatedInput: Record<string, unknown>;
|
|
679
|
+
};
|
|
680
|
+
};
|
|
681
|
+
};
|
|
682
|
+
|
|
683
|
+
/**
|
|
684
|
+
* Decide what the hook should print. Pure — `cli/hook.ts` supplies the payload and the config.
|
|
685
|
+
*
|
|
686
|
+
* FAIL OPEN, ALWAYS. Every branch that is not a confident rewrite is a passthrough, because
|
|
687
|
+
* this runs on every Agent call in every session on the machine. A hook that blocks or errors
|
|
688
|
+
* takes the user's subagents with it, and the worst outcome available is a config typo that
|
|
689
|
+
* makes delegation stop working everywhere with no clue why.
|
|
690
|
+
*
|
|
691
|
+
* `hookEventName` is emitted because AMR-1 measured it as required (the payload without it
|
|
692
|
+
* fired and was ignored, 5/5). `permissionDecision` is NOT emitted: it was measured as
|
|
693
|
+
* neither required nor harmful, so emitting it would widen permission for nothing.
|
|
694
|
+
*/
|
|
695
|
+
export function evaluateAgentHook(
|
|
696
|
+
payload: AgentHookPayload,
|
|
697
|
+
config: ModelsConfig | null,
|
|
698
|
+
): AgentHookDecision {
|
|
699
|
+
if (payload.tool_name !== "Agent")
|
|
700
|
+
return { kind: "passthrough", reason: "not-agent" };
|
|
701
|
+
if (config === null) return { kind: "passthrough", reason: "no-config" };
|
|
702
|
+
|
|
703
|
+
const toolInput = payload.tool_input ?? {};
|
|
704
|
+
|
|
705
|
+
// A model the caller set wins by Claude Code's own precedence (per-invocation > frontmatter
|
|
706
|
+
// > env > session). Overriding it would silently discard a deliberate choice.
|
|
707
|
+
if (typeof toolInput.model === "string" && toolInput.model.length > 0) {
|
|
708
|
+
return { kind: "passthrough", reason: "explicit-model" };
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
const subagentType =
|
|
712
|
+
typeof toolInput.subagent_type === "string"
|
|
713
|
+
? toolInput.subagent_type
|
|
714
|
+
: undefined;
|
|
715
|
+
const routing = resolveAgentRouting(config, subagentType);
|
|
716
|
+
|
|
717
|
+
// `inherit` is the opt-out: leave the call exactly as it was, so the agent's own
|
|
718
|
+
// frontmatter (or the session model) decides.
|
|
719
|
+
if (routing.model === "inherit")
|
|
720
|
+
return { kind: "passthrough", reason: "inherit" };
|
|
721
|
+
|
|
722
|
+
// A mate's model is claudish's to choose, not ours. Same passthrough as `inherit`, kept as
|
|
723
|
+
// its own reason so the hook log distinguishes "the author opted out" from "the author sent
|
|
724
|
+
// this one outside".
|
|
725
|
+
//
|
|
726
|
+
// BEFORE the guard below, not after: a mate would fail `isRoutableModel` too, and reporting
|
|
727
|
+
// it as `unroutable-model` would read as a broken config — the one state the log exists to
|
|
728
|
+
// make obvious — when it is the config working exactly as written.
|
|
729
|
+
if (isMate(routing.model)) return { kind: "passthrough", reason: "mate" };
|
|
730
|
+
|
|
731
|
+
// Belt and braces over the writer's validation. A hand-edited profile or an older magus
|
|
732
|
+
// can put an unroutable value here, and MEASURED (AMR-1 `bogus-model`) that does not
|
|
733
|
+
// degrade — it refuses the spawn outright. Passing through costs the routing; injecting
|
|
734
|
+
// costs the user every subagent.
|
|
735
|
+
if (!isRoutableModel(routing.model))
|
|
736
|
+
return { kind: "passthrough", reason: "unroutable-model" };
|
|
737
|
+
|
|
738
|
+
return {
|
|
739
|
+
kind: "update",
|
|
740
|
+
model: routing.model,
|
|
741
|
+
output: {
|
|
742
|
+
hookSpecificOutput: {
|
|
743
|
+
hookEventName: "PreToolUse",
|
|
744
|
+
// updatedInput REPLACES the tool input wholesale, so it must carry everything
|
|
745
|
+
// the caller sent, not just the field being changed.
|
|
746
|
+
updatedInput: { ...toolInput, model: routing.model },
|
|
747
|
+
},
|
|
748
|
+
},
|
|
749
|
+
};
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
export interface SettingsPatch {
|
|
753
|
+
/** Keys to set on the settings object. */
|
|
754
|
+
set: Record<string, unknown>;
|
|
755
|
+
/** Top-level keys to delete (used by `models off`). */
|
|
756
|
+
unset: string[];
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
/**
|
|
760
|
+
* The settings a config implies: the main thread's model and effort, plus per-model effort for
|
|
761
|
+
* every distinct model any grade or agent names.
|
|
762
|
+
*
|
|
763
|
+
* `resolveFullId` maps an alias to the full model id this machine currently runs (resolved
|
|
764
|
+
* LIVE by the caller — never a table in this repo, which would go stale). BOTH key forms are
|
|
765
|
+
* written with the same value, which is the direct consequence of AMR-1's `effort-conflict`
|
|
766
|
+
* run: when both forms are present and disagree, the full id wins. Writing both means they
|
|
767
|
+
* cannot disagree, so it does not matter which the runtime prefers, and a full-id entry a user
|
|
768
|
+
* left behind with `/effort` cannot quietly override the project's intent.
|
|
769
|
+
*/
|
|
770
|
+
export function buildSettingsPatch(
|
|
771
|
+
config: ModelsConfig,
|
|
772
|
+
resolveFullId: (alias: ModelAlias) => string | null,
|
|
773
|
+
): SettingsPatch {
|
|
774
|
+
const set: Record<string, unknown> = {};
|
|
775
|
+
set.model = config.main.model;
|
|
776
|
+
if (config.main.effort !== undefined) set.effortLevel = config.main.effort;
|
|
777
|
+
|
|
778
|
+
const modelSettings: Record<string, { effortLevel: Effort }> = {};
|
|
779
|
+
const add = (model: string, effort: Effort | undefined) => {
|
|
780
|
+
if (effort === undefined || model === "inherit") return;
|
|
781
|
+
const alias = baseAlias(model);
|
|
782
|
+
// The `isModelAlias` gate is what keeps a MATE out of `modelSettings`, and it is the
|
|
783
|
+
// only thing that does. It is now LOAD-BEARING rather than belt-and-braces: an effort
|
|
784
|
+
// on a mate used to be a validation error, so nothing could reach here carrying one,
|
|
785
|
+
// and today it is valid and ordinary. `modelSettings` is keyed by models Claude Code
|
|
786
|
+
// RUNS — a `mate1` key selects nothing, and a `grok-4.6` key selects nothing either.
|
|
787
|
+
//
|
|
788
|
+
// A mate's effort is written to the `mates` block and read by claudish. It never
|
|
789
|
+
// reaches settings, from any path: `add()` is the only writer and this line is the
|
|
790
|
+
// only gate, so the test that pins it asserts on the whole map (no slot, no external
|
|
791
|
+
// id, no `undefined` key) rather than on this branch.
|
|
792
|
+
if (!isModelAlias(alias)) return;
|
|
793
|
+
modelSettings[alias] = { effortLevel: effort };
|
|
794
|
+
const full = resolveFullId(alias);
|
|
795
|
+
// A resolver that cannot answer leaves the alias key alone rather than inventing an id.
|
|
796
|
+
if (full) modelSettings[full] = { effortLevel: effort };
|
|
797
|
+
};
|
|
798
|
+
add(config.main.model, config.main.effort);
|
|
799
|
+
for (const g of GRADES) add(config.grades[g].model, config.grades[g].effort);
|
|
800
|
+
for (const value of Object.values(config.agents ?? {})) {
|
|
801
|
+
if (typeof value !== "string") add(value.model, value.effort);
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
if (Object.keys(modelSettings).length > 0) set.modelSettings = modelSettings;
|
|
805
|
+
return { set, unset: [] };
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
/**
|
|
809
|
+
* The four states routing can be in. Each is a fact the reader can act on.
|
|
810
|
+
*
|
|
811
|
+
* There is no state for "the settings differ from the config". Differing is what a CUSTOM
|
|
812
|
+
* config is, and the list says so with a `Custom` row rather than the header saying it with
|
|
813
|
+
* a warning.
|
|
814
|
+
*/
|
|
815
|
+
export type ModelsState = "off" | "on" | "invalid" | "unhooked";
|
|
816
|
+
|
|
817
|
+
export interface ModelsStatus {
|
|
818
|
+
state: ModelsState;
|
|
819
|
+
preset: string | null;
|
|
820
|
+
/** Human-readable differences between what the config asks for and what settings hold. */
|
|
821
|
+
drift: string[];
|
|
822
|
+
warnings: string[];
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
/**
|
|
826
|
+
* What `models status` and `doctor` report.
|
|
827
|
+
*
|
|
828
|
+
* `unhooked` outranks `stale`, deliberately: a config with no hook registered routes NOTHING,
|
|
829
|
+
* so reporting a drifted effort value first would bury the reason nothing is happening.
|
|
830
|
+
*/
|
|
831
|
+
export function computeModelsStatus(args: {
|
|
832
|
+
config: ModelsConfig | null;
|
|
833
|
+
errors: ValidationError[];
|
|
834
|
+
hookRegistered: boolean;
|
|
835
|
+
settings: Record<string, unknown>;
|
|
836
|
+
resolveFullId: (alias: ModelAlias) => string | null;
|
|
837
|
+
subagentModelEnv?: string | null;
|
|
838
|
+
}): ModelsStatus {
|
|
839
|
+
const {
|
|
840
|
+
config,
|
|
841
|
+
errors,
|
|
842
|
+
hookRegistered,
|
|
843
|
+
settings,
|
|
844
|
+
resolveFullId,
|
|
845
|
+
subagentModelEnv,
|
|
846
|
+
} = args;
|
|
847
|
+
const warnings: string[] = [];
|
|
848
|
+
|
|
849
|
+
if (subagentModelEnv) {
|
|
850
|
+
warnings.push(
|
|
851
|
+
`CLAUDE_CODE_SUBAGENT_MODEL is set to "${subagentModelEnv}". Per-call routing wins over it, so it now has no effect on routed agents — consider unsetting it.`,
|
|
852
|
+
);
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
if (errors.length > 0) {
|
|
856
|
+
return {
|
|
857
|
+
state: "invalid",
|
|
858
|
+
preset: null,
|
|
859
|
+
drift: errors.map((e) => `${e.path || "(root)"}: ${e.message}`),
|
|
860
|
+
warnings,
|
|
861
|
+
};
|
|
862
|
+
}
|
|
863
|
+
if (config === null)
|
|
864
|
+
return { state: "off", preset: null, drift: [], warnings };
|
|
865
|
+
if (!hookRegistered) {
|
|
866
|
+
return {
|
|
867
|
+
state: "unhooked",
|
|
868
|
+
preset: config.preset,
|
|
869
|
+
drift: ["the agent-model hook is not registered, so nothing is routed"],
|
|
870
|
+
warnings,
|
|
871
|
+
};
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
// A config whose settings differ from a built-in's is CUSTOM, and custom is not a fault.
|
|
875
|
+
//
|
|
876
|
+
// There is no comparison here any more, and no state for the result of one. A project may
|
|
877
|
+
// legitimately run models and efforts no shipped preset names; saying so as a state called
|
|
878
|
+
// `stale`, with a count of the differences, described a thing you have as a thing that is
|
|
879
|
+
// wrong with you — and the count was of MESSAGES rather than of differences, so `1 drift`
|
|
880
|
+
// could mean every setting differed.
|
|
881
|
+
//
|
|
882
|
+
// The list carries the fact instead: a config matching no built-in gets its own `Custom`
|
|
883
|
+
// row holding those settings, with its own tier table and chart. `drift` survives for the
|
|
884
|
+
// two states that ARE faults — a config that does not validate, and one with no hook to
|
|
885
|
+
// run it — where the lines name something the reader has to fix.
|
|
886
|
+
return {
|
|
887
|
+
state: "on",
|
|
888
|
+
preset: config.preset,
|
|
889
|
+
drift: [],
|
|
890
|
+
warnings,
|
|
891
|
+
};
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
/**
|
|
895
|
+
* One human-readable drift line.
|
|
896
|
+
*
|
|
897
|
+
* Three things this deliberately does NOT do, each a defect it replaced:
|
|
898
|
+
*
|
|
899
|
+
* - `JSON.stringify(undefined)` returns `undefined` — the string, not a value — so an
|
|
900
|
+
* absent key printed as `settings has undefined`, leaking a JavaScript value into text
|
|
901
|
+
* a person reads. Absent is now said in words.
|
|
902
|
+
* - `modelSettings` printed as raw JSON was a 200-character wall nobody parses.
|
|
903
|
+
* - Quoting every scalar (`config wants "opus"`) adds noise; a model name is a word.
|
|
904
|
+
*/
|
|
905
|
+
function describeDrift(key: string, have: unknown, want: unknown): string {
|
|
906
|
+
// `modelSettings` is DIFFED rather than summarised on both sides.
|
|
907
|
+
//
|
|
908
|
+
// Summarising it was the wrong shape, and hiding the full-id keys to keep the summary
|
|
909
|
+
// short made it actively wrong: the patch writes every model twice (alias + live-resolved
|
|
910
|
+
// full id), and the side that differs is almost always the full id — which is also the
|
|
911
|
+
// side that WINS at runtime (AMR-1 rule 6 above). So the line could print
|
|
912
|
+
// `settings has opus xhigh, config wants opus xhigh` — X differs from X — while the key
|
|
913
|
+
// actually serving subagents at another effort was the one suppressed. That happens with
|
|
914
|
+
// no hand-editing at all: apply a preset, wait for the next model release, and the stored
|
|
915
|
+
// full id no longer matches the resolved one.
|
|
916
|
+
//
|
|
917
|
+
// Diffing cannot produce that line, because a key only appears when its two sides differ.
|
|
918
|
+
if (key === "modelSettings" && isRec(have) && isRec(want)) {
|
|
919
|
+
const changed = modelSettingsDiff(have, want);
|
|
920
|
+
if (changed.length > 0) return `modelSettings: ${changed.join(", ")}`;
|
|
921
|
+
}
|
|
922
|
+
return `${key}: settings ${have === undefined ? "has no value" : `has ${describeValue(have)}`}, config wants ${describeValue(want)}`;
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
/** A value as a person would say it, never as JSON. */
|
|
926
|
+
function describeValue(v: unknown): string {
|
|
927
|
+
if (v === undefined) return "nothing";
|
|
928
|
+
if (typeof v === "string") return v;
|
|
929
|
+
if (v && typeof v === "object") return describeModelSettings(v);
|
|
930
|
+
return JSON.stringify(v);
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
/** The `effortLevel` inside one `modelSettings` entry, or null when there is not one. */
|
|
934
|
+
function effortOf(spec: unknown): string | null {
|
|
935
|
+
if (!spec || typeof spec !== "object") return null;
|
|
936
|
+
const level = (spec as { effortLevel?: unknown }).effortLevel;
|
|
937
|
+
return typeof level === "string" ? level : null;
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
/**
|
|
941
|
+
* Every model whose effort differs between the two maps, as `opus xhigh → high`.
|
|
942
|
+
*
|
|
943
|
+
* Keys are taken from BOTH sides and sorted, so a model present in only one of them still
|
|
944
|
+
* appears — an entry `/effort` left behind in settings is exactly the case worth seeing, and
|
|
945
|
+
* a summary of one side alone can never show it.
|
|
946
|
+
*/
|
|
947
|
+
function modelSettingsDiff(
|
|
948
|
+
have: Record<string, unknown>,
|
|
949
|
+
want: Record<string, unknown>,
|
|
950
|
+
): string[] {
|
|
951
|
+
const models = new Set([...Object.keys(have), ...Object.keys(want)]);
|
|
952
|
+
const out: string[] = [];
|
|
953
|
+
for (const model of [...models].sort()) {
|
|
954
|
+
const h = effortOf(have[model]);
|
|
955
|
+
const w = effortOf(want[model]);
|
|
956
|
+
if (h === w) continue;
|
|
957
|
+
out.push(`${model} ${h ?? "unset"} → ${w ?? "unset"}`);
|
|
958
|
+
}
|
|
959
|
+
return out;
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
/** `modelSettings` as `opus xhigh, fable medium`, for the one-sided case. */
|
|
963
|
+
function describeModelSettings(v: object): string {
|
|
964
|
+
const parts: string[] = [];
|
|
965
|
+
for (const [model, spec] of Object.entries(v as Record<string, unknown>)) {
|
|
966
|
+
parts.push(`${model} ${effortOf(spec) ?? "unset"}`);
|
|
967
|
+
}
|
|
968
|
+
return parts.length ? parts.join(", ") : "nothing";
|
|
969
|
+
}
|