tumwater 0.1.2 → 0.2.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 +74 -108
- package/dist/build-info.json +2 -2
- package/dist/src/agent/agent-args.js +24 -0
- package/dist/src/agent/agent-backend.js +2 -2
- package/dist/src/agent/backend-traits.js +60 -0
- package/dist/src/agent/claude/claude-account.js +61 -8
- package/dist/src/agent/claude/claude-args.js +12 -15
- package/dist/src/agent/claude/claude-backend.js +51 -41
- package/dist/src/agent/claude/claude-env.js +7 -3
- package/dist/src/agent/claude/claude-stream.js +7 -16
- package/dist/src/agent/image-input.js +107 -0
- package/dist/src/agent/notes-shim.js +41 -0
- package/dist/src/agent/opencode/opencode-args.js +61 -0
- package/dist/src/agent/opencode/opencode-backend.js +255 -0
- package/dist/src/agent/opencode/opencode-catalog.js +184 -0
- package/dist/src/agent/opencode/opencode-config.js +188 -0
- package/dist/src/agent/opencode/opencode-env.js +36 -0
- package/dist/src/agent/opencode/opencode-session-id.js +37 -0
- package/dist/src/agent/opencode/opencode-stream.js +278 -0
- package/dist/src/agent/role-notes-write.js +16 -32
- package/dist/src/agent/run-agent.js +95 -18
- package/dist/src/agent/session-ledger.js +57 -1
- package/dist/src/backlog/backlog-archive.js +296 -0
- package/dist/src/backlog/backlog-cli.js +573 -0
- package/dist/src/backlog/backlog-commit.js +31 -0
- package/dist/src/backlog/backlog-eligibility.js +357 -33
- package/dist/src/backlog/backlog-md.js +98 -18
- package/dist/src/backlog/backlog-render.js +62 -11
- package/dist/src/backlog/backlog-structure.js +261 -58
- package/dist/src/backlog/backlog-write.js +197 -319
- package/dist/src/backlog/backlog.js +113 -16
- package/dist/src/backlog/question-answer.js +339 -0
- package/dist/src/baseline/main-baseline.js +75 -88
- package/dist/src/baseline/main-red.js +38 -30
- package/dist/src/brief/brief-edit.js +43 -0
- package/dist/src/{brief.js → brief/brief.js} +66 -10
- package/dist/src/budget/budget.js +32 -15
- package/dist/src/budget/fallback-breaker.js +8 -7
- package/dist/src/build/build-check-detect.js +8 -7
- package/dist/src/build/build-check-events.js +68 -22
- package/dist/src/build/build-check-report.js +22 -23
- package/dist/src/build/build-check-scoped.js +51 -62
- package/dist/src/build/build-check.js +15 -14
- package/dist/src/build/build-info.js +2 -1
- package/dist/src/build/build-stage.js +23 -22
- package/dist/src/build/dep-install.js +22 -21
- package/dist/src/build/host-sleep.js +1 -1
- package/dist/src/change/change-data.js +25 -14
- package/dist/src/change/change-render.js +11 -10
- package/dist/src/cli/brief-command.js +58 -0
- package/dist/src/cli/cli-args.js +25 -5
- package/dist/src/cli/cli-command-args.js +46 -14
- package/dist/src/cli/cli-editor.js +64 -0
- package/dist/src/cli/cli-flag-specs.js +54 -16
- package/dist/src/cli/cli-marker-commands.js +13 -10
- package/dist/src/cli/cli-output.js +9 -0
- package/dist/src/cli/cli-query-commands.js +458 -49
- package/dist/src/cli/cli-repo-gate.js +1 -1
- package/dist/src/cli/cli-run.js +208 -59
- package/dist/src/cli/completion.js +207 -0
- package/dist/src/cli/config-commands.js +14 -11
- package/dist/src/cli/help.js +305 -110
- package/dist/src/cli/log-commands.js +127 -21
- package/dist/src/cli/notify-command.js +33 -18
- package/dist/src/cli/question-commands.js +29 -224
- package/dist/src/cli.js +121 -40
- package/dist/src/collections.js +66 -16
- package/dist/src/concurrency/check-permit.js +16 -17
- package/dist/src/concurrency/lock.js +6 -5
- package/dist/src/concurrency/provider-lease.js +69 -0
- package/dist/src/concurrency/semaphore.js +258 -36
- package/dist/src/config/config-aliases.js +106 -0
- package/dist/src/config/config-editable-keys.js +3 -3
- package/dist/src/config/config-fallbacks.js +379 -0
- package/dist/src/config/config-field-checks.js +19 -10
- package/dist/src/config/config-keys.js +160 -0
- package/dist/src/config/config-live.js +64 -12
- package/dist/src/config/config-schema.js +34 -22
- package/dist/src/config/config-validation.js +132 -48
- package/dist/src/config/config-views.js +119 -337
- package/dist/src/config/config-write.js +138 -177
- package/dist/src/config/config.js +60 -49
- package/dist/src/config/model-selector.js +28 -2
- package/dist/src/doctor/doctor-backlog.js +546 -49
- package/dist/src/doctor/doctor-checks.js +534 -56
- package/dist/src/doctor/doctor-holds.js +193 -53
- package/dist/src/doctor/doctor-install.js +30 -0
- package/dist/src/doctor/doctor-launch-services.js +2 -2
- package/dist/src/doctor/doctor-model-checks.js +549 -157
- package/dist/src/doctor/doctor-notify.js +27 -0
- package/dist/src/doctor/doctor-orphans.js +10 -9
- package/dist/src/doctor/doctor-render.js +36 -9
- package/dist/src/doctor/doctor.js +80 -22
- package/dist/src/doctor/image-probe.js +244 -0
- package/dist/src/doctor/probe.js +64 -0
- package/dist/src/events/event-format.js +226 -102
- package/dist/src/events/event-labels.js +116 -0
- package/dist/src/events/event-legacy.js +140 -0
- package/dist/src/events/event-read.js +58 -22
- package/dist/src/events/event-window.js +27 -7
- package/dist/src/events/events.js +18 -14
- package/dist/src/events/notify.js +137 -38
- package/dist/src/failure/failure-cluster.js +12 -9
- package/dist/src/failure/failure-data.js +96 -48
- package/dist/src/failure/failure-render.js +69 -58
- package/dist/src/failure/failure-state-change.js +33 -26
- package/dist/src/failure/time-spend.js +79 -59
- package/dist/src/files/file-queue.js +16 -4
- package/dist/src/files/files.js +18 -6
- package/dist/src/files/json-object.js +18 -3
- package/dist/src/files/tail.js +1 -1
- package/dist/src/gates/bootstrap-gates.js +5 -4
- package/dist/src/gates/budget-gates.js +250 -34
- package/dist/src/gates/disk-gate.js +18 -18
- package/dist/src/gates/gate-polls.js +117 -69
- package/dist/src/gates/maintenance-quota.js +29 -29
- package/dist/src/gates/pause-gates.js +11 -11
- package/dist/src/gates/readiness.js +2 -1
- package/dist/src/gates/role-cap-gates.js +16 -21
- package/dist/src/gates/startup-gate.js +75 -31
- package/dist/src/gates/streak-gate.js +18 -16
- package/dist/src/git/commit-message.js +12 -5
- package/dist/src/git/git-diff.js +16 -16
- package/dist/src/git/git-run.js +8 -8
- package/dist/src/git/git.js +86 -55
- package/dist/src/git/slots-state.js +14 -5
- package/dist/src/git/worktree-pool.js +25 -18
- package/dist/src/git/worktree-use.js +15 -14
- package/dist/src/git/worktree.js +18 -18
- package/dist/src/gui/gui-args.js +10 -14
- package/dist/src/gui/gui-backlog-commands.js +273 -0
- package/dist/src/gui/gui-command.js +1 -1
- package/dist/src/gui/gui-config-commands.js +440 -0
- package/dist/src/gui/gui-endpoint-commands.js +16 -669
- package/dist/src/gui/gui-endpoint-common.js +49 -0
- package/dist/src/gui/gui-endpoints.js +116 -56
- package/dist/src/gui/gui-operator-commands.js +145 -0
- package/dist/src/gui/gui-prompt-commands.js +234 -0
- package/dist/src/gui/gui-server.js +28 -8
- package/dist/src/gui/gui-team-commands.js +164 -0
- package/dist/src/gui/http-body.js +17 -1
- package/dist/src/history/history-data.js +194 -98
- package/dist/src/history/history-render.js +76 -0
- package/dist/src/history/history.js +37 -58
- package/dist/src/inbox/inbox-attachments.js +75 -14
- package/dist/src/inbox/inbox-cancel.js +7 -5
- package/dist/src/inbox/inbox-edit.js +1 -1
- package/dist/src/inbox/inbox-submit.js +44 -4
- package/dist/src/inbox/inbox-top.js +83 -0
- package/dist/src/inbox/inbox.js +8 -2
- package/dist/src/inbox/pending-prompt.js +1 -1
- package/dist/src/inbox/prompt-commands.js +170 -80
- package/dist/src/init/init-templates.js +16 -14
- package/dist/src/init/init.js +12 -11
- package/dist/src/loop/leftover.js +28 -27
- package/dist/src/loop/loop-fallback.js +2 -2
- package/dist/src/loop/loop-pi.js +106 -52
- package/dist/src/loop/loop-recovery.js +15 -15
- package/dist/src/loop/loop-state.js +139 -13
- package/dist/src/loop/loop-tick.js +194 -86
- package/dist/src/loop/loop.js +72 -36
- package/dist/src/loop/model-fallback.js +3 -2
- package/dist/src/loop/revision.js +16 -7
- package/dist/src/merge/backlog-conflicts.js +155 -0
- package/dist/src/{landing/landing-batch.js → merge/merge-batch.js} +90 -89
- package/dist/src/{landing/landing-check-failures.js → merge/merge-check-failures.js} +87 -41
- package/dist/src/{landing/landing-core.js → merge/merge-core.js} +119 -95
- package/dist/src/{landing/landing-diff.js → merge/merge-diff.js} +1 -1
- package/dist/src/merge/merge-drain.js +212 -0
- package/dist/src/{landing/landing-git.js → merge/merge-git.js} +94 -21
- package/dist/src/merge/merge-pipeline.js +99 -0
- package/dist/src/{landing/landing-questions.js → merge/merge-questions.js} +9 -6
- package/dist/src/merge/merge-queue-render.js +75 -0
- package/dist/src/{landing/landing-queue.js → merge/merge-queue.js} +95 -36
- package/dist/src/merge/merge-slot.js +340 -0
- package/dist/src/{landing/landing-stack.js → merge/merge-stack.js} +66 -62
- package/dist/src/{landing/landing-merge.js → merge/merge-to-main.js} +72 -62
- package/dist/src/merge/merge-verify.js +212 -0
- package/dist/src/notes-cli.js +97 -0
- package/dist/src/operator/operator-commands.js +154 -47
- package/dist/src/operator/operator-intent.js +87 -49
- package/dist/src/operator/operator-requests.js +52 -52
- package/dist/src/operator/retire.js +62 -27
- package/dist/src/operator/revert.js +71 -0
- package/dist/src/orchestrator/orchestrator-launch.js +74 -14
- package/dist/src/orchestrator/orchestrator-scheduling.js +129 -44
- package/dist/src/orchestrator/orchestrator.js +158 -100
- package/dist/src/orchestrator/retention.js +29 -12
- package/dist/src/orchestrator/startup-migration-steps.js +51 -0
- package/dist/src/orchestrator/startup-migrations.js +40 -0
- package/dist/src/paths.js +128 -37
- package/dist/src/pi/pi-args.js +26 -3
- package/dist/src/pi/pi-backend.js +15 -23
- package/dist/src/pi/pi-bin.js +18 -12
- package/dist/src/pi/pi-event-line.js +17 -1
- package/dist/src/pi/pi-failure.js +87 -0
- package/dist/src/pi/pi-models.js +126 -38
- package/dist/src/pi/pi-run-result.js +1 -1
- package/dist/src/pi/pi-settings.js +86 -0
- package/dist/src/pi/pi-stream.js +131 -107
- package/dist/src/pi/pi-watchdogs.js +47 -43
- package/dist/src/pi-extension/bounded-output.js +20 -5
- package/dist/src/pi-extension/context-budget.js +3 -1
- package/dist/src/pi-extension/context-shake.js +54 -21
- package/dist/src/pi-extension/context-window.js +37 -0
- package/dist/src/pi-extension/full-output.js +2 -2
- package/dist/src/pi-extension/strip-thinking.js +80 -0
- package/dist/src/pi-extension/trim-system-prompt.js +38 -0
- package/dist/src/process/launch-services.js +11 -10
- package/dist/src/process/process-table.js +1 -1
- package/dist/src/process/process.js +7 -7
- package/dist/src/process/run-marker.js +29 -6
- package/dist/src/process/supervisor.js +47 -25
- package/dist/src/prompt/dialect.js +30 -10
- package/dist/src/prompt/gate-prompts.js +170 -67
- package/dist/src/prompt/principles.js +1 -0
- package/dist/src/prompt/prompt-followup.js +35 -20
- package/dist/src/prompt/prompt.js +249 -91
- package/dist/src/redeploy/redeploy-policy.js +10 -10
- package/dist/src/redeploy/redeploy.js +6 -4
- package/dist/src/redeploy/redeployer.js +63 -55
- package/dist/src/redeploy/self-reload.js +1 -1
- package/dist/src/report/report-data.js +264 -255
- package/dist/src/report/report-fold.js +353 -0
- package/dist/src/report/report-render.js +270 -33
- package/dist/src/request-timeouts.js +1 -1
- package/dist/src/review/known-flakes.js +34 -14
- package/dist/src/review/protected-edits.js +33 -0
- package/dist/src/review/review-followup.js +16 -4
- package/dist/src/review/review-precheck.js +14 -13
- package/dist/src/review/review-verdict.js +14 -0
- package/dist/src/review/review.js +81 -39
- package/dist/src/review/secret-leaks.js +127 -0
- package/dist/src/review/suite-rerun.js +4 -3
- package/dist/src/roles/legacy-role-ids.js +21 -0
- package/dist/src/roles/legacy-role-migration.js +62 -0
- package/dist/src/roles/loop-ids.js +19 -2
- package/dist/src/roles/role-catalog.js +125 -135
- package/dist/src/roles/role-guidance.js +64 -31
- package/dist/src/roles/role-labels.js +10 -0
- package/dist/src/roles/role-render.js +194 -18
- package/dist/src/roles/role-view.js +216 -17
- package/dist/src/roles/roles.js +18 -15
- package/dist/src/scheduling/backoff.js +29 -19
- package/dist/src/scheduling/claims.js +25 -9
- package/dist/src/scheduling/once-round.js +11 -11
- package/dist/src/scheduling/quiet-hours.js +25 -32
- package/dist/src/scheduling/scheduling.js +43 -24
- package/dist/src/scheduling/{work-landed-cache.js → work-merged-cache.js} +14 -14
- package/dist/src/status/status-budget.js +115 -0
- package/dist/src/status/status-data.js +77 -184
- package/dist/src/status/status-inbox.js +47 -0
- package/dist/src/status/status-install.js +27 -0
- package/dist/src/status/status-polls.js +68 -33
- package/dist/src/status/status-roles.js +105 -0
- package/dist/src/{fleet → team}/error-storm.js +10 -10
- package/dist/src/{fleet → team}/failure-spread.js +8 -8
- package/dist/src/{fleet → team}/orchestrator-info.js +86 -18
- package/dist/src/{fleet → team}/reclaim.js +5 -4
- package/dist/src/{fleet/fleet-hold.js → team/team-hold.js} +55 -21
- package/dist/src/{fleet/fleet-polls.js → team/team-polls.js} +116 -65
- package/dist/src/{fleet/fleet-state.js → team/team-state.js} +62 -37
- package/dist/src/text/datetime.js +42 -2
- package/dist/src/text/format.js +10 -1
- package/dist/src/text/markdown.js +1 -1
- package/dist/src/text/phrases.js +35 -34
- package/dist/src/text/suggest.js +3 -2
- package/dist/src/text/text.js +2 -2
- package/dist/src/tick/qa-coverage.js +2 -1
- package/dist/src/tick/stage-check.js +147 -26
- package/dist/src/tick/telemetry-digest.js +10 -9
- package/dist/src/tick/tick-apply.js +101 -72
- package/dist/src/tick/tick-detail-data.js +32 -18
- package/dist/src/tick/tick-detail.js +62 -21
- package/dist/src/tick/tick-finalize.js +29 -22
- package/dist/src/tick/tick-outcome.js +2 -1
- package/dist/src/tick/tick-prompt.js +94 -26
- package/dist/src/tick/tick-resume.js +29 -13
- package/dist/src/tick/tick-stage.js +91 -47
- package/dist/src/tick/tick-timing.js +60 -59
- package/dist/src/tick/tick-usage.js +53 -23
- package/dist/src/tick/tick-verdict.js +76 -10
- package/dist/src/ui/badges.js +118 -67
- package/dist/src/ui/gui/gui-client-boot.js +209 -30
- package/dist/src/ui/gui/gui-client-composer.js +102 -23
- package/dist/src/ui/gui/gui-client-drawer.js +330 -75
- package/dist/src/ui/gui/gui-client-history.js +96 -38
- package/dist/src/ui/gui/gui-client-labels.js +24 -0
- package/dist/src/ui/gui/gui-client-loops.js +94 -52
- package/dist/src/ui/gui/gui-client-markdown.js +1 -0
- package/dist/src/ui/gui/gui-client-model.js +63 -43
- package/dist/src/ui/gui/gui-client-operator.js +129 -39
- package/dist/src/ui/gui/gui-client-pending.js +36 -32
- package/dist/src/ui/gui/gui-client-report.js +339 -46
- package/dist/src/ui/gui/gui-client-settings.js +148 -14
- package/dist/src/ui/gui/gui-client-sound.js +2 -1
- package/dist/src/ui/gui/{gui-client-fleet.js → gui-client-team.js} +196 -72
- package/dist/src/ui/gui/gui-client.js +26 -20
- package/dist/src/ui/gui/gui-page.js +19 -19
- package/dist/src/ui/gui/gui-styles.js +12 -4
- package/dist/src/ui/progress-data.js +5 -3
- package/dist/src/ui/status-model.js +184 -122
- package/dist/src/ui/status-payload.js +119 -32
- package/dist/src/ui/status-render.js +122 -52
- package/dist/src/ui/team-alerts.js +341 -0
- package/dist/src/ui/tick-progress-model.js +14 -14
- package/dist/src/ui/tone.js +11 -8
- package/dist/src/ui/transcript-tail.js +1 -0
- package/dist/src/ui/transcript.js +23 -16
- package/dist/src/ui/tui/tui-app.js +1 -0
- package/dist/src/ui/tui/tui-backlog.js +85 -11
- package/dist/src/ui/tui/tui-editor-answer.js +97 -0
- package/dist/src/ui/tui/tui-editor-attach.js +107 -0
- package/dist/src/ui/tui/tui-editor-brief.js +107 -0
- package/dist/src/ui/tui/tui-editor-budget.js +155 -0
- package/dist/src/ui/tui/tui-editor-ctx.js +1 -0
- package/dist/src/ui/tui/tui-editor-entry.js +230 -0
- package/dist/src/ui/tui/tui-editor-interval.js +122 -0
- package/dist/src/ui/tui/tui-editor-notes.js +114 -0
- package/dist/src/ui/tui/tui-editor-pause.js +266 -0
- package/dist/src/ui/tui/tui-editor-prompt.js +95 -0
- package/dist/src/ui/tui/tui-editor-queued.js +98 -0
- package/dist/src/ui/tui/tui-editor-quiet.js +106 -0
- package/dist/src/ui/tui/tui-editor-retire.js +83 -0
- package/dist/src/ui/tui/tui-editor-search.js +73 -0
- package/dist/src/ui/tui/tui-editor-session.js +97 -0
- package/dist/src/ui/tui/tui-editors.js +152 -0
- package/dist/src/ui/tui/tui-frame.js +67 -55
- package/dist/src/ui/tui/tui-history.js +12 -7
- package/dist/src/ui/tui/tui-input.js +49 -44
- package/dist/src/ui/tui/tui-keymap.js +1 -0
- package/dist/src/ui/tui/tui-keys.js +91 -1704
- package/dist/src/ui/tui/tui-pane-activity.js +99 -0
- package/dist/src/ui/tui/tui-pane-backlog.js +224 -0
- package/dist/src/ui/tui/tui-pane-brief.js +30 -0
- package/dist/src/ui/tui/tui-pane-ctx.js +11 -0
- package/dist/src/ui/tui/tui-pane-health.js +124 -0
- package/dist/src/ui/tui/tui-pane-history.js +316 -0
- package/dist/src/ui/tui/tui-pane-pending.js +74 -0
- package/dist/src/ui/tui/tui-pane-queued.js +143 -0
- package/dist/src/ui/tui/tui-pane-report.js +122 -0
- package/dist/src/ui/tui/tui-pane-session.js +101 -0
- package/dist/src/ui/tui/tui-pane-transcript.js +150 -0
- package/dist/src/ui/tui/tui-pane.js +66 -28
- package/dist/src/ui/tui/tui-panes.js +294 -0
- package/dist/src/ui/tui/tui.js +45 -25
- package/dist/src/verdict/fix-claim.js +141 -38
- package/dist/src/verdict/no-change.js +19 -0
- package/dist/src/verdict/refusal.js +92 -8
- package/dist/src/verdict/reply-contract.js +17 -5
- package/dist/src/version.js +3 -2
- package/package.json +4 -2
- package/dist/src/agent/mcp/role-notes-server.js +0 -160
- package/dist/src/landing/backlog-conflicts.js +0 -116
- package/dist/src/landing/landing-drain.js +0 -204
- package/dist/src/landing/landing-pipeline.js +0 -96
- package/dist/src/landing/landing-queue-render.js +0 -31
- package/dist/src/landing/landing-slot.js +0 -246
- package/dist/src/landing/landing-vetting.js +0 -196
- package/dist/src/pi-extension/role-notes.js +0 -40
- package/dist/src/ui/fleet-alerts.js +0 -174
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
// vocabulary: plain
|
|
2
|
+
/** The budget/tier FALLBACK side of the config views, split out of config-views.ts: how the
|
|
3
|
+
* team falls back at the daily spend cap — which free pair each tier runs on
|
|
4
|
+
* (resolveTierFallbacks and its BORROW_ORDER), the per-role fallback config a model-failure
|
|
5
|
+
* or cap episode hands a loop (fallbackRoleConfig, applyFallbackModel), and the surfaces
|
|
6
|
+
* that name those pairs (seamsInUse, modelFallbackView, tierFallbackLabels). The per-seam
|
|
7
|
+
* resolution these build on — how one role/reviewer/resolver provider/model/thinking resolves
|
|
8
|
+
* (configForRole, reviewConfig, withModelOverrides, …) — stays in config-views.ts, so the
|
|
9
|
+
* one-way dependency runs fallback → seam and never back. Pure, like its sibling: no I/O and
|
|
10
|
+
* no caching. */
|
|
11
|
+
import { MODEL_TIERS, } from "./config-schema.js";
|
|
12
|
+
import { formatModelSelector, isAgentId, parseModelSelector } from "./model-selector.js";
|
|
13
|
+
import { modelPairName } from "../budget/budget.js";
|
|
14
|
+
import { DIRECTOR_ROLE } from "../roles/roles.js";
|
|
15
|
+
import { baseRoleOf } from "../roles/loop-ids.js";
|
|
16
|
+
import { enabledRoleIds } from "./config.js";
|
|
17
|
+
import { modelFallbackActive } from "../loop/model-fallback.js";
|
|
18
|
+
import { FALLBACK_REVIEW_TIMEOUT_S, REVIEW_TIMEOUT_S, configForRole, isTierName, resolvedModelFields, resolverConfig, reviewConfig, reviewRunConfig, roleSeamTier, topTierSelector, withModelOverrides, } from "./config-views.js";
|
|
19
|
+
/** The resolved model fields of a pure `fallback` selector string: parsed as a pure selector
|
|
20
|
+
* (no legacy provider in scope), with the ambient top-level thinking when the selector
|
|
21
|
+
* carries none. fallbackPair's map and string branches and tierOwnChain share this
|
|
22
|
+
* assembly, so all three read one rule for how a `fallback` selector resolves. */
|
|
23
|
+
function fallbackSelectorFields(selector, config) {
|
|
24
|
+
const parsed = parseModelSelector(selector);
|
|
25
|
+
return resolvedModelFields(seamKeyedFallbackProvider(config, parsed.provider), parsed.model, parsed.thinking ?? config.thinking);
|
|
26
|
+
}
|
|
27
|
+
/** A fallback pair's provider keyed by the seam it routes to (PLANS.md "Mixed claude + pi
|
|
28
|
+
* fleets, part 2/4"): a pair that routes to a non-pi backend carries that backend's name as
|
|
29
|
+
* its provider — `claude/claude-sonnet-5-5` and a bare `claude-sonnet-5-5` on a claude
|
|
30
|
+
* team both read `claude` — so the budget gate's handback (gate-polls.ts's tickOnPair
|
|
31
|
+
* against the tick's seamHoldKey capture, loop-tick.ts's setTickPair) matches a tick on
|
|
32
|
+
* either selector form. The same routing rule as routeModel: a provider naming an agent is
|
|
33
|
+
* that agent, no provider runs on the config's `agent`, anything else is pi, which keeps
|
|
34
|
+
* its own provider (a bare pi selector keeps undefined, exactly as before). */
|
|
35
|
+
function seamKeyedFallbackProvider(config, provider) {
|
|
36
|
+
const agent = provider === undefined ? config.backend : isAgentId(provider) ? provider : "pi";
|
|
37
|
+
return agent !== "pi" ? agent : provider;
|
|
38
|
+
}
|
|
39
|
+
/** The team-level fallback pair: the `default` tier's resolved pair under
|
|
40
|
+
* resolveTierFallbacks' borrow order, or null when the tier resolves to pause. The
|
|
41
|
+
* selector-string `fallback` (plans/model-tiers.md), the legacy `fallbackModel` object, and
|
|
42
|
+
* the map form all feed one resolution — a map with no `default` entry (or a `"pause"` one)
|
|
43
|
+
* borrows another tier's pair exactly as the budget gate does, so this view and the gate
|
|
44
|
+
* cannot disagree about which model the team would engage. `usable` filters the pairs that
|
|
45
|
+
* may supply the answer: the default predicate admits every declared pair (the config-only
|
|
46
|
+
* view), while a pricing predicate (src/pi/pi-models.ts's fallbackModelPair) yields the pair
|
|
47
|
+
* the gate would actually engage when a declared `default` pair is priced and a free pair
|
|
48
|
+
* exists at another tier. One definition so the freeness check, the dashboards' badge, the
|
|
49
|
+
* doctor, and applyFallbackModel below cannot disagree. */
|
|
50
|
+
export function fallbackPair(config, usable = () => true) {
|
|
51
|
+
return resolveTierFallbacks(config, usable).default.pair;
|
|
52
|
+
}
|
|
53
|
+
/** The legacy `fallbackModel` object resolved to a pair: its own fields over the top-level
|
|
54
|
+
* values, the same precedence every other override section uses. Private — reached only
|
|
55
|
+
* through tierOwnChain (the default tier's own fallback) and so fallbackPair's resolution,
|
|
56
|
+
* which keeps the object and the string/map forms on one rule without recursing. */
|
|
57
|
+
function legacyFallbackPair(config) {
|
|
58
|
+
const fbo = config.fallbackModel;
|
|
59
|
+
if (!fbo)
|
|
60
|
+
return null;
|
|
61
|
+
// The top-level model is a selector too (parsed the same way withModelOverrides does), so a
|
|
62
|
+
// fallback field that omits model borrows the parsed id, never the raw selector string.
|
|
63
|
+
const topSel = topTierSelector(config, "default");
|
|
64
|
+
const provider = seamKeyedFallbackProvider(config, fbo.provider ?? topSel?.provider ?? config.provider);
|
|
65
|
+
const model = fbo.model ?? topSel?.model;
|
|
66
|
+
const thinking = fbo.thinking ?? topSel?.thinking ?? config.thinking;
|
|
67
|
+
return resolvedModelFields(provider, model, thinking);
|
|
68
|
+
}
|
|
69
|
+
/** The order a tier borrows other tiers' own fallbacks in, after trying its own: small
|
|
70
|
+
* climbs (small → default → strong), default keeps near itself (default → strong → small),
|
|
71
|
+
* and strong never drops to a small model (strong → default → pause) — a weak reviewer or
|
|
72
|
+
* planner does more harm than a paused one. */
|
|
73
|
+
const BORROW_ORDER = {
|
|
74
|
+
small: ["small", "default", "strong"],
|
|
75
|
+
default: ["default", "strong", "small"],
|
|
76
|
+
strong: ["strong", "default"],
|
|
77
|
+
};
|
|
78
|
+
/** Exported for imageRerouteTarget (src/agent/image-input.ts), which walks a rerouting role's
|
|
79
|
+
* tier through the same borrow order the budget fallback uses (skipping the tier itself), and
|
|
80
|
+
* for the doctor's `image input` check naming the tier a text-only loop reroutes to. */
|
|
81
|
+
export { BORROW_ORDER };
|
|
82
|
+
/** A tier's OWN fallback chain, first step first — what the `fallback` key declares at that
|
|
83
|
+
* tier's map entry (a selector or a list of them), or (default only) the string/list form of
|
|
84
|
+
* `fallback` or the legacy `fallbackModel` object (a one-element chain). Empty when the tier
|
|
85
|
+
* declares nothing or declares `"pause"` (an explicit opt-out, which also makes it
|
|
86
|
+
* unborrowable: a tier with no own fallback offers nothing to borrow). Each step is parsed
|
|
87
|
+
* like fallbackPair: a pure selector, with the ambient top-level thinking when the selector
|
|
88
|
+
* carries none. */
|
|
89
|
+
function tierOwnChain(config, tier) {
|
|
90
|
+
const fb = config.fallback;
|
|
91
|
+
let raw;
|
|
92
|
+
if (fb !== undefined) {
|
|
93
|
+
// The top-level string/list form names the default tier's own chain; `"pause"` is rejected
|
|
94
|
+
// by validation but defensively names no model (matching the map entries below).
|
|
95
|
+
if (typeof fb === "string" || Array.isArray(fb))
|
|
96
|
+
raw = tier === "default" ? fb : undefined;
|
|
97
|
+
else
|
|
98
|
+
raw = fb[tier];
|
|
99
|
+
}
|
|
100
|
+
else if (tier === "default") {
|
|
101
|
+
// The legacy `fallbackModel` object is the default tier's own fallback, with its
|
|
102
|
+
// provider/thinking fields and the top-level borrow precedence.
|
|
103
|
+
const legacy = legacyFallbackPair(config);
|
|
104
|
+
if (legacy === null)
|
|
105
|
+
return [];
|
|
106
|
+
const primaryClaude = withModelOverrides(config, tier, {}).agent === "claude";
|
|
107
|
+
return [legacy.provider === "claude" && primaryClaude ? { ...legacy, extraUsage: true } : legacy];
|
|
108
|
+
}
|
|
109
|
+
if (raw === undefined || raw === "pause")
|
|
110
|
+
return [];
|
|
111
|
+
// A Claude step after an earlier Claude model — the tier's primary counts — may spend extra
|
|
112
|
+
// usage; the first Claude model stays on the subscription. Position alone decides.
|
|
113
|
+
let claudeSeen = withModelOverrides(config, tier, {}).agent === "claude";
|
|
114
|
+
return (Array.isArray(raw) ? raw : [raw])
|
|
115
|
+
.filter((s) => s !== "pause")
|
|
116
|
+
.map((s) => {
|
|
117
|
+
const step = typeof s === "string" ? { model: s } : s;
|
|
118
|
+
const fields = fallbackSelectorFields(step.model, config);
|
|
119
|
+
const extraUsage = fields.provider === "claude" && claudeSeen;
|
|
120
|
+
if (fields.provider === "claude")
|
|
121
|
+
claudeSeen = true;
|
|
122
|
+
return {
|
|
123
|
+
...fields,
|
|
124
|
+
...(step.maxDailyCostUsd !== undefined ? { maxDailyCostUsd: step.maxDailyCostUsd } : {}),
|
|
125
|
+
...(extraUsage ? { extraUsage } : {}),
|
|
126
|
+
};
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
/** Every step of every tier's own chain, as the pair and its 0-based index in that chain — the
|
|
130
|
+
* enumeration the budget gate keys its breakers by and seamsInUse checks binaries over. */
|
|
131
|
+
export function tierChainSteps(config) {
|
|
132
|
+
return MODEL_TIERS.flatMap((tier) => tierOwnChain(config, tier).map((pair, step) => ({ tier, step, pair })));
|
|
133
|
+
}
|
|
134
|
+
const stepField = (step) => (step > 0 ? { step } : {});
|
|
135
|
+
/** Which free pair each tier runs on at the daily cap (plans/model-tiers.md "Budget fallback
|
|
136
|
+
* by tier", part 5a/8). A tier runs the first `usable(pair)` step of its own declared chain;
|
|
137
|
+
* otherwise it borrows the first usable step of another tier's OWN chain — never one that
|
|
138
|
+
* tier itself borrowed — in BORROW_ORDER's order; otherwise it pauses (`pair: null`). The
|
|
139
|
+
* engine is pure: the caller supplies `usable` (part 5b/8 wires it to pair pricing and the
|
|
140
|
+
* fallback breaker), so the same rules are testable with a stub and the gates cannot disagree
|
|
141
|
+
* with each other. A config with no fallback at all resolves every tier to pause, and a single
|
|
142
|
+
* `fallback: F` reads as default's own pair, which small and strong then borrow — today's
|
|
143
|
+
* behavior. */
|
|
144
|
+
export function resolveTierFallbacks(config, usable) {
|
|
145
|
+
const firstUsable = (tier) => {
|
|
146
|
+
const chain = tierOwnChain(config, tier);
|
|
147
|
+
const step = chain.findIndex((p) => usable(p));
|
|
148
|
+
return step < 0 ? undefined : { pair: chain[step], step };
|
|
149
|
+
};
|
|
150
|
+
const resolve = (tier) => {
|
|
151
|
+
const mine = firstUsable(tier);
|
|
152
|
+
if (mine)
|
|
153
|
+
return { pair: mine.pair, from: null, ...stepField(mine.step) };
|
|
154
|
+
for (const t of BORROW_ORDER[tier]) {
|
|
155
|
+
if (t === tier)
|
|
156
|
+
continue;
|
|
157
|
+
const theirs = firstUsable(t);
|
|
158
|
+
if (theirs)
|
|
159
|
+
return { pair: theirs.pair, from: t, ...stepField(theirs.step) };
|
|
160
|
+
}
|
|
161
|
+
return { pair: null, from: null };
|
|
162
|
+
};
|
|
163
|
+
return { small: resolve("small"), default: resolve("default"), strong: resolve("strong") };
|
|
164
|
+
}
|
|
165
|
+
/** The config a role's ticks run on while a model-fallback episode is active (PLANS.md "Model
|
|
166
|
+
* failure fallback, part 1/2"): the role's tier pair from resolveTierFallbacks (the tier's own
|
|
167
|
+
* pair, else BORROW_ORDER's borrow), shaped like configForRole — provider/model/thinking over
|
|
168
|
+
* the role's own and top-level values, plus the role's minRunIntervalSeconds. Null when the
|
|
169
|
+
* tier resolves to pause (no fallback configured), so the feature stays off. Pure. */
|
|
170
|
+
export function fallbackRoleConfig(config, role) {
|
|
171
|
+
const tier = roleSeamTier(config, role);
|
|
172
|
+
const pair = resolveTierFallbacks(config, () => true)[tier].pair;
|
|
173
|
+
if (pair === null)
|
|
174
|
+
return null;
|
|
175
|
+
const rc = config.roles[baseRoleOf(role)];
|
|
176
|
+
return {
|
|
177
|
+
...withModelOverrides(config, tier, {
|
|
178
|
+
provider: pair.provider,
|
|
179
|
+
model: pair.model,
|
|
180
|
+
thinking: pair.thinking,
|
|
181
|
+
}),
|
|
182
|
+
minRunIntervalSeconds: rc?.minRunIntervalSeconds ?? config.minRunIntervalSeconds,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
/** Every seam the team can spawn, each with its label: every enabled role's resolved config
|
|
186
|
+
* (configForRole), the review gate when it is on (reviewRunConfig), the conflict resolver
|
|
187
|
+
* (resolverConfig), the director (a catalog role, named explicitly so it is never missed), and
|
|
188
|
+
* each tier's budget fallback (resolveTierFallbacks) — the model-failure fallback
|
|
189
|
+
* (fallbackRoleConfig) runs the same tier pair, so one enumeration covers it. Deduplicated by
|
|
190
|
+
* label + routed pair so a seam reached through several surfaces is listed once. Read by the
|
|
191
|
+
* startup gate's agent list, doctor's pair sweep (and so its binary/credential/version checks
|
|
192
|
+
* and routing table), and the live-edit reload's newly-routed-backend check — no surface can
|
|
193
|
+
* miss a seam that exists only as a fallback. */
|
|
194
|
+
export function seamsInUse(config) {
|
|
195
|
+
const seams = new Map();
|
|
196
|
+
const add = (label, cfg) => {
|
|
197
|
+
const key = `${label}\0${cfg.agent ?? ""}\0${cfg.provider ?? ""}\0${cfg.model ?? ""}\0${cfg.thinking ?? ""}`;
|
|
198
|
+
seams.set(key, {
|
|
199
|
+
label,
|
|
200
|
+
agent: cfg.agent,
|
|
201
|
+
provider: cfg.provider,
|
|
202
|
+
model: cfg.model,
|
|
203
|
+
thinking: cfg.thinking,
|
|
204
|
+
});
|
|
205
|
+
};
|
|
206
|
+
for (const role of enabledRoleIds(config))
|
|
207
|
+
add(role, configForRole(config, role));
|
|
208
|
+
if (config.review.enabled)
|
|
209
|
+
add("review", reviewRunConfig(config));
|
|
210
|
+
add("resolver", resolverConfig(config));
|
|
211
|
+
add("director", configForRole(config, DIRECTOR_ROLE));
|
|
212
|
+
for (const { tier, step, pair } of tierChainSteps(config)) {
|
|
213
|
+
add(step === 0 ? `fallback:${tier}` : `fallback:${tier}#${step}`, withModelOverrides(config, tier, { provider: pair.provider, model: pair.model, thinking: pair.thinking }));
|
|
214
|
+
}
|
|
215
|
+
return [...seams.values()];
|
|
216
|
+
}
|
|
217
|
+
/** Build a ModelFallbackView for an episode, or null while the episode is not running the
|
|
218
|
+
* fallback (no episode, or a due probe on the primary). role-view.ts's
|
|
219
|
+
* RoleViewPayload.modelFallback and status/status-data.ts's per-loop row both build this
|
|
220
|
+
* shape, so one function keeps the two surfaces from disagreeing about the pair, its start,
|
|
221
|
+
* and its reason. `now` is the caller's clock (the status snapshot passes its own poll
|
|
222
|
+
* clock), so a row and its poll agree on what "active" means. */
|
|
223
|
+
export function modelFallbackView(episode, config, role, now = Date.now()) {
|
|
224
|
+
if (episode === undefined || !modelFallbackActive(episode, now))
|
|
225
|
+
return null;
|
|
226
|
+
const episodeConfig = fallbackRoleConfig(config, role);
|
|
227
|
+
if (episodeConfig === null)
|
|
228
|
+
return null;
|
|
229
|
+
return {
|
|
230
|
+
...resolvedModelFields(episodeConfig.provider, episodeConfig.model, episodeConfig.thinking),
|
|
231
|
+
...(episodeConfig.agent !== undefined && episodeConfig.agent !== "pi" ? { backend: episodeConfig.agent } : {}),
|
|
232
|
+
since: episode.since,
|
|
233
|
+
reason: episode.reason,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
/** The per-tier fallback labels the observability surfaces render (part 7b/8): tier →
|
|
237
|
+
* `provider/model[ (from <tier>)]` for every tier the resolution gives a pair, in tier order.
|
|
238
|
+
* The `(from …)` suffix marks a borrowed pair — a strong-tier borrow is the fact an operator
|
|
239
|
+
* reading the feed or the badge can act on. Tiers resolved to pause are left out: the badge
|
|
240
|
+
* and the event name the pairs that RUN, the pause story lives on the pause events and the
|
|
241
|
+
* per-role rows. */
|
|
242
|
+
export function tierFallbackLabels(resolved) {
|
|
243
|
+
const out = {};
|
|
244
|
+
for (const tier of MODEL_TIERS) {
|
|
245
|
+
const r = resolved[tier];
|
|
246
|
+
if (r.pair !== null)
|
|
247
|
+
out[tier] = modelPairName(r.pair) + (r.from !== null ? ` (from ${r.from})` : "");
|
|
248
|
+
}
|
|
249
|
+
return out;
|
|
250
|
+
}
|
|
251
|
+
/** True when the resolution hands out two or more DISTINCT pairs — the condition under which
|
|
252
|
+
* the tiered surfaces (the budget_fallback event, the header badge) switch from today's
|
|
253
|
+
* single-pair text to the tier list: a single fallback whose pair the other tiers borrow
|
|
254
|
+
* resolves every tier to the same pair, and that stays on the single-pair text, byte
|
|
255
|
+
* identical to the pre-tier surfaces. */
|
|
256
|
+
export function tiersResolveDistinctPairs(resolved) {
|
|
257
|
+
const pairs = MODEL_TIERS.map((t) => resolved[t].pair)
|
|
258
|
+
.filter((p) => p !== null)
|
|
259
|
+
.map((p) => modelPairName(p));
|
|
260
|
+
return new Set(pairs).size >= 2;
|
|
261
|
+
}
|
|
262
|
+
/** Drop a model-override seam's provider/model/thinking fields — the one rule the fallback
|
|
263
|
+
* view's role and reviewer strips share, so a field on that triple cannot be dropped from one
|
|
264
|
+
* and left on the other (where it would reach a priced model after the cap). */
|
|
265
|
+
function stripModel(o) {
|
|
266
|
+
const { provider: _p, model: _m, thinking: _t, ...rest } = o;
|
|
267
|
+
return rest;
|
|
268
|
+
}
|
|
269
|
+
/** The reviewer config as the fallback view sees it: model seam stripped, and its time budget
|
|
270
|
+
* raised to at least FALLBACK_REVIEW_TIMEOUT_S (a budget sized for the budgeted model's turns
|
|
271
|
+
* times out every review on a slower free one; a configured value above the floor stands). The
|
|
272
|
+
* one home of that rule, shared by both fallback branches. */
|
|
273
|
+
function fallbackReview(review) {
|
|
274
|
+
return {
|
|
275
|
+
...stripModel(review),
|
|
276
|
+
timeoutSeconds: Math.max(review.timeoutSeconds ?? REVIEW_TIMEOUT_S, FALLBACK_REVIEW_TIMEOUT_S),
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
/** Every `roles.<id>` entry with its model seam stripped. With `keepTierNames`, an entry whose
|
|
280
|
+
* model is a tier name is left untouched: the resolved tier map now serves that tier itself, so
|
|
281
|
+
* the reference stays valid (stripping it would lose the role's tier). */
|
|
282
|
+
function stripRoleModels(roles, keepTierNames) {
|
|
283
|
+
return Object.fromEntries(Object.entries(roles).map(([id, rc]) => [
|
|
284
|
+
id,
|
|
285
|
+
keepTierNames && isTierName(rc?.model) ? rc : stripModel(rc),
|
|
286
|
+
]));
|
|
287
|
+
}
|
|
288
|
+
/** Every `roles.<id>` entry with its model seam stripped, except a seam `free` calls free — a
|
|
289
|
+
* subscription claude role keeps its own model through the cap rather than moving to the
|
|
290
|
+
* fallback (plans/agent-backends.md "Free-ness"). With `keepTierNames`, an entry whose model is
|
|
291
|
+
* a tier name is left untouched: the resolved tier map now serves that tier itself, so the
|
|
292
|
+
* reference stays valid (stripping it would lose the role's tier). */
|
|
293
|
+
function freeRoleModels(config, free) {
|
|
294
|
+
return Object.fromEntries(Object.entries(config.roles).map(([id, rc]) => {
|
|
295
|
+
if (rc && free?.(withModelOverrides(config, roleSeamTier(config, id), rc)))
|
|
296
|
+
return [id, rc];
|
|
297
|
+
return [id, isTierName(rc?.model) ? rc : stripModel(rc)];
|
|
298
|
+
}));
|
|
299
|
+
}
|
|
300
|
+
/** The config as seen by a role loop running on the free fallback model
|
|
301
|
+
* (plans/fallback-model.md): the fallback's provider/model/thinking installed as the top-level
|
|
302
|
+
* values AND every per-role and reviewer model override dropped, so that EVERY seam that could
|
|
303
|
+
* otherwise reach a priced model — an author run, its reviewer, a conflict resolver — resolves
|
|
304
|
+
* to the one free pair. Dropping the overrides is the point: a role pinned to a paid model in
|
|
305
|
+
* tumwater.json must not keep spending after the cap is reached. The one other change is the
|
|
306
|
+
* reviewer's time budget, raised to at least FALLBACK_REVIEW_TIMEOUT_S: a budget sized for the
|
|
307
|
+
* budgeted model's turns times out every review on a slower free one (a configured
|
|
308
|
+
* `review.timeoutSeconds` above the floor stands, and reviewRunConfig still caps it at
|
|
309
|
+
* runTimeoutSeconds). Everything else (intervals, thresholds, exempt paths, the cap itself) is
|
|
310
|
+
* unchanged when no fallback is configured. With a per-tier `resolved` map (resolveTierFallbacks,
|
|
311
|
+
* part 5a/8) the model map is rewritten to the resolved pairs instead — each seam keeps its
|
|
312
|
+
* tier: `model[tier]` becomes the tier's pair as a selector string, a tier resolved to pause
|
|
313
|
+
* is left out (it inherits default's entry, harmless because the gate holds its roles), raw
|
|
314
|
+
* per-role selector overrides are dropped so a paid pin cannot keep spending, and tier-name
|
|
315
|
+
* role overrides (`roles.<id>.model: "strong"`) are kept since the map now serves the tier
|
|
316
|
+
* itself. The director's exemption lives in the gates (roleForConfig), not here. A
|
|
317
|
+
* subscription claude seam is never moved (`free`): the gate passes one predicate over the
|
|
318
|
+
* resolved seam, so a free tier keeps its own primary in the map — with the ambient thinking
|
|
319
|
+
* level it resolved with — a free role keeps its own override, and a free reviewer keeps its own
|
|
320
|
+
* model, while every priced seam still lands on its tier's fallback pair. */
|
|
321
|
+
export function applyFallbackModel(config, resolved, free) {
|
|
322
|
+
if (resolved) {
|
|
323
|
+
const selectorOf = (f) => f.pair
|
|
324
|
+
? formatModelSelector({
|
|
325
|
+
provider: f.pair.provider,
|
|
326
|
+
model: f.pair.model ?? "",
|
|
327
|
+
thinking: f.pair.thinking,
|
|
328
|
+
})
|
|
329
|
+
: undefined;
|
|
330
|
+
const model = {};
|
|
331
|
+
const stepCaps = {};
|
|
332
|
+
for (const tier of MODEL_TIERS) {
|
|
333
|
+
// A free seam keeps the tier's own primary selector — the backend namespace a resolved
|
|
334
|
+
// seam has cleared, plus the ambient thinking level `topTierSelector` alone omits. A moved
|
|
335
|
+
// seam gets its pair selector, which already carries that ambient level.
|
|
336
|
+
const seam = withModelOverrides(config, tier, {});
|
|
337
|
+
if (free?.(seam)) {
|
|
338
|
+
const own = topTierSelector(config, tier);
|
|
339
|
+
if (own !== undefined)
|
|
340
|
+
model[tier] = formatModelSelector({ ...own, thinking: own.thinking ?? config.thinking });
|
|
341
|
+
// A tier with no top-level selector — `agent: "claude"` riding its agent default —
|
|
342
|
+
// has no own selector to carry the level, so name the routed default plus it.
|
|
343
|
+
else if (seam.model !== undefined)
|
|
344
|
+
model[tier] = formatModelSelector({
|
|
345
|
+
provider: seam.provider,
|
|
346
|
+
model: seam.model,
|
|
347
|
+
thinking: seam.thinking,
|
|
348
|
+
});
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
const sel = selectorOf(resolved[tier]);
|
|
352
|
+
if (sel !== undefined)
|
|
353
|
+
model[tier] = sel;
|
|
354
|
+
const cap = resolved[tier].pair?.maxDailyCostUsd;
|
|
355
|
+
if (cap !== undefined)
|
|
356
|
+
stepCaps[modelPairName(resolved[tier].pair)] = cap;
|
|
357
|
+
}
|
|
358
|
+
return {
|
|
359
|
+
...config,
|
|
360
|
+
...(Object.keys(stepCaps).length > 0 ? { fallbackStepCaps: stepCaps } : {}),
|
|
361
|
+
provider: undefined,
|
|
362
|
+
model,
|
|
363
|
+
thinking: undefined,
|
|
364
|
+
review: free?.(reviewConfig(config)) ? config.review : fallbackReview(config.review),
|
|
365
|
+
roles: freeRoleModels(config, free),
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
const pair = fallbackPair(config);
|
|
369
|
+
if (!pair)
|
|
370
|
+
return config;
|
|
371
|
+
return {
|
|
372
|
+
...config,
|
|
373
|
+
provider: pair.provider,
|
|
374
|
+
model: pair.model,
|
|
375
|
+
thinking: pair.thinking,
|
|
376
|
+
review: fallbackReview(config.review),
|
|
377
|
+
roles: stripRoleModels(config.roles, false),
|
|
378
|
+
};
|
|
379
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
// vocabulary: plain
|
|
1
2
|
import { MODEL_TRIPLE_KEYS, MODEL_TIERS, TIER_MAP_KEYS, THINKING_LEVELS, } from "./config-schema.js";
|
|
2
3
|
import { allRoleIds } from "../roles/roles.js";
|
|
3
4
|
import { isJsonObject } from "../files/json-object.js";
|
|
@@ -88,8 +89,16 @@ export const POSITIVE_INTEGER = {
|
|
|
88
89
|
what: "an integer of at least 1",
|
|
89
90
|
};
|
|
90
91
|
export const AT_LEAST_ONE = { ok: (n) => n >= 1, what: "a number of at least 1" };
|
|
92
|
+
/** The upper bound every seconds field that feeds a ×1000 duration shares: 2147483 s
|
|
93
|
+
* × 1000 stays inside node's signed-32-bit setTimeout/setInterval range (2^31−1 ms,
|
|
94
|
+
* about 24.8 days). Above it node clamps the delay to 1ms — a one-zero typo like
|
|
95
|
+
* `"runTimeoutSeconds": 1e300` became Infinity ms, and every pi run died the moment it
|
|
96
|
+
* started (BUGS.md 2026-10-03). One home for the bound, shared by the config schema's
|
|
97
|
+
* duration rules (config-validation.ts) and checkMinRunInterval (config-keys.ts), so the
|
|
98
|
+
* dashboard's interval editor cannot drift from the schema's bound. */
|
|
99
|
+
export const MAX_DURATION_SECONDS = 2147483;
|
|
91
100
|
/** Shared known-role guard for the per-role config sections (`roles.<id>` entries and
|
|
92
|
-
* `maxDailyCostUsdPerRole.<id>` keys): an id outside the role catalog and
|
|
101
|
+
* `maxDailyCostUsdPerRole.<id>` keys): an id outside the role catalog and customRoles cannot
|
|
93
102
|
* work — a `roles` typo spawns a phantom loop that errors every tick forever; a caps typo
|
|
94
103
|
* silently no-ops the cap — so both reject it here with the valid ids, the same message shape
|
|
95
104
|
* `tumwater logs --role` uses for a bad flag value. Returns true when the id is known. */
|
|
@@ -102,7 +111,7 @@ export function checkKnownRoleId(section, id, customNames, problems) {
|
|
|
102
111
|
}
|
|
103
112
|
/** Validate one string field when present. `allowEmpty` defaults true; the model-triple fields
|
|
104
113
|
* pass false — pi-args.ts skips an empty value when it builds its flags, so `"provider": ""`
|
|
105
|
-
* would be silently ignored and the
|
|
114
|
+
* would be silently ignored and the team would quietly fall back to pi's default — and an
|
|
106
115
|
* empty `fallbackModel` field makes fallbackPair drop it, so the fallback never engages. */
|
|
107
116
|
export function checkStringField(problems, obj, prefix, key, allowEmpty = true) {
|
|
108
117
|
whenPresent(obj, key, (v) => {
|
|
@@ -133,24 +142,24 @@ export function checkModelTripleField(problems, obj, prefix, modelShape = "selec
|
|
|
133
142
|
checkStringField(problems, obj, prefix, key, false);
|
|
134
143
|
const t = obj.thinking;
|
|
135
144
|
if (isNonBlankString(t) && !THINKING_LEVELS.has(t))
|
|
136
|
-
problems.push(`${prefix}thinking must be one of ${[...THINKING_LEVELS].join(", ")} (got ${show(t)})`);
|
|
145
|
+
problems.push(`${prefix}thinking must be one of ${[...THINKING_LEVELS].join(", ")} (got ${show(t)})${typoSuffix(t, [...THINKING_LEVELS])}`);
|
|
137
146
|
const m = obj.model;
|
|
138
147
|
if (m === undefined)
|
|
139
148
|
return;
|
|
140
149
|
if (modelShape === "tier-map" && isJsonObject(m)) {
|
|
141
150
|
checkKnownKeys(m, TIER_MAP_KEYS, `${prefix}model`, problems);
|
|
142
151
|
if (obj.provider !== undefined)
|
|
143
|
-
problems.push(`${prefix}model as a map by tier cannot coexist with ${prefix ? prefix : "the legacy top-level "}provider — move the provider into each tier's
|
|
152
|
+
problems.push(`${prefix}model as a map by tier cannot coexist with ${prefix ? prefix : "the legacy top-level "}provider — move the provider into each tier's model id (got provider ${show(obj.provider)})`);
|
|
144
153
|
for (const [tier, v] of Object.entries(m))
|
|
145
154
|
if (!isNonBlankString(v))
|
|
146
|
-
problems.push(`${prefix}model.${tier} must be a non-empty
|
|
155
|
+
problems.push(`${prefix}model.${tier} must be a non-empty model id (got ${show(v)})`);
|
|
147
156
|
return;
|
|
148
157
|
}
|
|
149
158
|
if (typeof m !== "string") {
|
|
150
159
|
const what = modelShape === "tier-map"
|
|
151
|
-
? "a
|
|
160
|
+
? "a model id or a map by tier (small, default, strong)"
|
|
152
161
|
: modelShape === "tier-name"
|
|
153
|
-
? "a
|
|
162
|
+
? "a model id or a tier name (small, default, strong)"
|
|
154
163
|
: "a string";
|
|
155
164
|
problems.push(`${prefix}model must be ${what} (got ${show(m)})`);
|
|
156
165
|
return;
|
|
@@ -162,7 +171,7 @@ export function checkModelTripleField(problems, obj, prefix, modelShape = "selec
|
|
|
162
171
|
if (MODEL_TIERS.includes(m)) {
|
|
163
172
|
if (modelShape === "tier-name")
|
|
164
173
|
return; // A valid tier reference.
|
|
165
|
-
problems.push(`${prefix}model must name a
|
|
174
|
+
problems.push(`${prefix}model must name a model id — tier names are valid only as roles.<id>.model or review.model values (got ${show(m)})`);
|
|
166
175
|
}
|
|
167
176
|
}
|
|
168
177
|
/** Validate one numeric field against a NumberRule when present. */
|
|
@@ -199,7 +208,7 @@ export function checkStringArrayField(problems, obj, prefix, key) {
|
|
|
199
208
|
}
|
|
200
209
|
/** Report every passthrough arg in `args` whose flag the harness sets itself — the one screen
|
|
201
210
|
* behind both of validateConfig's reserved-flag checks (top-level `piArgs` and each
|
|
202
|
-
* `
|
|
211
|
+
* `backends.<id>.args`). `argsPath` names the array in the message (`piArgs`, `backends.pi.args`),
|
|
203
212
|
* and `whyFor` resolves the flag's reason against the reserved set it is screened against, or
|
|
204
213
|
* undefined when the flag is not reserved. The flag is matched even when the arg carries its
|
|
205
214
|
* value as `--flag=value` (split on the first `=`), so that joined spelling cannot slip past
|
|
@@ -211,6 +220,6 @@ export function checkReservedArgs(problems, args, argsPath, whyFor) {
|
|
|
211
220
|
return;
|
|
212
221
|
const why = whyFor(arg.split("=", 1)[0]);
|
|
213
222
|
if (why)
|
|
214
|
-
problems.push(`${argsPath}[${i}] ${show(arg)} duplicates a flag
|
|
223
|
+
problems.push(`${argsPath}[${i}] ${show(arg)} duplicates a flag tumwater sets — ${why}`);
|
|
215
224
|
});
|
|
216
225
|
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// vocabulary: plain
|
|
2
|
+
/** The config KEY language — how `tumwater config get/set/unset` and the dashboard editors
|
|
3
|
+
* name a config value and what makes one legitimate, apart from the writes that change it.
|
|
4
|
+
* One family, in its own module because it serves both verbs AND the read side: `config get`,
|
|
5
|
+
* shell completion, doctor's holds and the dashboard controllers all address keys and screen
|
|
6
|
+
* per-key values without performing a write, so burying this in the config-write module made
|
|
7
|
+
* read-side callers import from the "config WRITES" layer. The mutators (the load →
|
|
8
|
+
* validate → atomic-write idiom, the cross-process lock) live in src/config/config-write.ts;
|
|
9
|
+
* the per-key validators and key-shape parsing here are the one home of the wording both the
|
|
10
|
+
* writers and the read side show. */
|
|
11
|
+
import { AGENT_KEYS, CLAUDE_AGENT_KEYS, ROLE_ENTRY_KEYS, TIER_MAP_KEYS, TOP_LEVEL_KEYS } from "./config-schema.js";
|
|
12
|
+
import { typoSuffix } from "../text/suggest.js";
|
|
13
|
+
import { MAX_DURATION_SECONDS, POSITIVE_INTEGER, show } from "./config-field-checks.js";
|
|
14
|
+
import { parseQuietHours } from "../scheduling/quiet-hours.js";
|
|
15
|
+
/** One definition of "a valid daily budget cap" (the TUI's Ctrl+B editor and the GUI's
|
|
16
|
+
* /api/budget endpoint both run their input through it): a finite number of 0 or more —
|
|
17
|
+
* 0 disables the gate, fractional dollars allowed (the badge renders cents). Returns an
|
|
18
|
+
* actionable error message for anything else so both surfaces can flash it without
|
|
19
|
+
* try/catch plumbing. A value past Number.MAX_SAFE_INTEGER is rejected too: it stays
|
|
20
|
+
* finite ("9"×25 → 1e24), so only this screen stands between a one-zero typo in the
|
|
21
|
+
* browser's budget editor and an effectively uncapped budget (BUGS.md 2026-10-02, the
|
|
22
|
+
* same rule the TUI's parseBudgetInput applies). `key` names the config key in the error,
|
|
23
|
+
* defaulting to the team-wide `maxDailyCostUsd`; the per-role map passes
|
|
24
|
+
* `maxDailyCostUsdPerRole.<id>` so a rejected entry names the key actually set. */
|
|
25
|
+
export function checkDailyBudgetUsd(value, key = "maxDailyCostUsd") {
|
|
26
|
+
if (typeof value !== "number" ||
|
|
27
|
+
!Number.isFinite(value) ||
|
|
28
|
+
value < 0 ||
|
|
29
|
+
value > Number.MAX_SAFE_INTEGER)
|
|
30
|
+
return `${key} must be a number of 0 or more, at most ${Number.MAX_SAFE_INTEGER}, 0 disables (got ${show(value)})`;
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
/** One definition of "a valid per-provider concurrency cap" (plans/provider-concurrency.md
|
|
34
|
+
* §1): a positive integer, accepted for any (possibly unknown) provider name — a fallback's
|
|
35
|
+
* provider may only come into use later, so the key is not checked against a catalog. The one
|
|
36
|
+
* validation both `config set maxConcurrentPerProvider.<provider>` and validateConfig show,
|
|
37
|
+
* so they cannot drift apart on what a valid cap is. `key` names the config key in the error,
|
|
38
|
+
* defaulting to the map name; the dotted map passes `maxConcurrentPerProvider.<provider>`
|
|
39
|
+
* so a rejected entry names the key actually set. */
|
|
40
|
+
export function checkProviderConcurrencyCap(value, key = "maxConcurrentPerProvider") {
|
|
41
|
+
if (typeof value !== "number" || !Number.isFinite(value) || !Number.isInteger(value) || value < 1)
|
|
42
|
+
return `${key} must be ${POSITIVE_INTEGER.what} (got ${show(value)})`;
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
/** One definition of "a valid quietHours window" (src/scheduling/quiet-hours.ts owns the format):
|
|
46
|
+
* "HH:MM-HH:MM" in local time, wrapping permitted, empty means off. parseQuietHours's
|
|
47
|
+
* message is the one wording every surface (validateConfig, `config set`, the TUI/GUI
|
|
48
|
+
* editors) shows, so they cannot drift apart on what a valid window is. `key` names the
|
|
49
|
+
* config key in the error, defaulting to the team-wide `quietHours`; the per-role map passes
|
|
50
|
+
* `quietHoursPerRole.<id>` so a rejected entry names the key actually set. */
|
|
51
|
+
export function checkQuietHours(value, key = "quietHours") {
|
|
52
|
+
const parsed = parseQuietHours(value, key);
|
|
53
|
+
return parsed.ok ? null : parsed.error;
|
|
54
|
+
}
|
|
55
|
+
/** One definition of "a valid min run interval" — the seconds value the team-wide
|
|
56
|
+
* `minRunIntervalSeconds` and a per-role `roles.<id>.minRunIntervalSeconds` override hold:
|
|
57
|
+
* a finite number of 0 or more, at most MAX_DURATION_SECONDS (the ×1000 setTimeout bound the
|
|
58
|
+
* config schema's duration rules share). The dashboard's interval editor runs this before
|
|
59
|
+
* writing; `config set roles.<id>.minRunIntervalSeconds` leans on validateConfig's
|
|
60
|
+
* DURATION_NON_NEGATIVE for the CLI path instead, but the two cannot drift on the upper bound
|
|
61
|
+
* because both read the same MAX_DURATION_SECONDS constant. `key` names the config key in the
|
|
62
|
+
* error, defaulting to the team-wide `minRunIntervalSeconds`; the per-role path passes
|
|
63
|
+
* `roles.<id>.minRunIntervalSeconds` so a rejected entry names the key actually set. */
|
|
64
|
+
export function checkMinRunInterval(value, key = "minRunIntervalSeconds") {
|
|
65
|
+
if (typeof value !== "number" ||
|
|
66
|
+
!Number.isFinite(value) ||
|
|
67
|
+
value < 0 ||
|
|
68
|
+
value > MAX_DURATION_SECONDS)
|
|
69
|
+
return `${key} must be a number of 0 or more, at most ${MAX_DURATION_SECONDS} (got ${show(value)})`;
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
/** Per-key value validators `config set` (and the dashboard editors that call it) run
|
|
73
|
+
* BEFORE the write, so a malformed value fails with its own actionable message instead of
|
|
74
|
+
* waiting for validateConfig's whole-file report. */
|
|
75
|
+
export const PER_KEY_VALIDATORS = {
|
|
76
|
+
quietHours: checkQuietHours,
|
|
77
|
+
};
|
|
78
|
+
/** The per-role maps `config get/set` reaches with dotted keys: each names ONE entry
|
|
79
|
+
* (`maxDailyCostUsdPerRole.<role>`, `quietHoursPerRole.<role>`) that merges into the
|
|
80
|
+
* existing map, so raising one role's cap never requires re-typing another's. The
|
|
81
|
+
* `maxConcurrentPerProvider` map is dotted the same way (`maxConcurrentPerProvider.<provider>`);
|
|
82
|
+
* its keys are provider names, not role ids, so its per-key validation skips the role-catalog
|
|
83
|
+
* check the per-role maps apply. The per-key value validator runs before the write, the same
|
|
84
|
+
* screening a bare top-level key gets. */
|
|
85
|
+
export const DOTTED_MAP_KEYS = ["maxDailyCostUsdPerRole", "quietHoursPerRole", "maxConcurrentPerProvider"];
|
|
86
|
+
/** The valid first segments of a dotted config key — the per-role maps plus the `roles.` and
|
|
87
|
+
* `model.` sections. The generic dotted-shape error suggests against these so a typo'd head
|
|
88
|
+
* (`modle.small`, `maxDailyCostUsdPerRoll.feature`) gets the same did-you-mean the sibling
|
|
89
|
+
* unknown-key errors attach. */
|
|
90
|
+
const DOTTED_KEY_HEADS = [...DOTTED_MAP_KEYS, "roles", "model", "backends"];
|
|
91
|
+
/** Split a config key into its write/get shape: `foo` is top-level,
|
|
92
|
+
* `maxDailyCostUsdPerRole.feature` / `quietHoursPerRole.qa` name one map entry,
|
|
93
|
+
* `roles.qa.model` names one role-entry field, and anything else dotted is an error
|
|
94
|
+
* naming the supported dotted forms. */
|
|
95
|
+
export function parseConfigKey(key) {
|
|
96
|
+
if (!key.includes("."))
|
|
97
|
+
return { kind: "top", key };
|
|
98
|
+
const parts = key.split(".");
|
|
99
|
+
const first = parts[0] ?? "";
|
|
100
|
+
const second = parts.length > 1 ? parts[1] ?? "" : undefined;
|
|
101
|
+
const third = parts.length > 2 ? parts[2] ?? "" : undefined;
|
|
102
|
+
if (third === undefined && second !== undefined && second !== "" && DOTTED_MAP_KEYS.includes(first))
|
|
103
|
+
return { kind: "map", map: first, role: second };
|
|
104
|
+
if (parts.length === 3 && first === "roles" && second !== undefined && second !== "" && third !== undefined && third !== "")
|
|
105
|
+
return { kind: "role", id: second, field: third };
|
|
106
|
+
// `backends.<id>.<field>` names one field of one agent backend's entry (the way
|
|
107
|
+
// `roles.<id>.<field>` names one role field). The id is shape-checked only — routing owns
|
|
108
|
+
// which agent ids exist (part 4b); a typo'd field is refused here with the valid ones named.
|
|
109
|
+
if (parts.length === 3 && first === "backends" && second !== undefined && second !== "" && third !== undefined) {
|
|
110
|
+
// configDir is claude-only (CLAUDE_AGENT_KEYS): a config set backends.pi.configDir would
|
|
111
|
+
// write a key nothing reads, so it is refused everywhere but claude.
|
|
112
|
+
const validKeys = second === "claude" ? CLAUDE_AGENT_KEYS : AGENT_KEYS;
|
|
113
|
+
if (validKeys.includes(third))
|
|
114
|
+
return { kind: "agent", id: second, field: third };
|
|
115
|
+
return {
|
|
116
|
+
kind: "error",
|
|
117
|
+
error: `unknown backend field "${third}" for backends.${second} (valid fields: ${validKeys.join(", ")})${typoSuffix(third, validKeys)}`,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
// `model.<tier>` names one entry of the tier model map — the way `roles.<id>.model`
|
|
121
|
+
// names one role field, `maxDailyCostUsdPerRole.<role>` names one map entry. The tier
|
|
122
|
+
// must be a real tier: `config set model.turbo x` would otherwise write a dead map key
|
|
123
|
+
// the runtime (and doctor's checkKnownKeys) would silently never resolve.
|
|
124
|
+
if (parts.length === 2 && first === "model") {
|
|
125
|
+
if ((TIER_MAP_KEYS).includes(second ?? ""))
|
|
126
|
+
return { kind: "tier", map: "model", tier: second };
|
|
127
|
+
return {
|
|
128
|
+
kind: "error",
|
|
129
|
+
error: `unknown model tier "${second ?? ""}" — a dotted \`model.<tier>\` names one of ${TIER_MAP_KEYS.join(", ")} (e.g. \`config set model.strong huggingface/zai-org/GLM-5.3-Flash:together:low\`)${typoSuffix(second ?? "", TIER_MAP_KEYS)}`,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
// A typo'd first segment is the common miss (`maxDailyCostUsdPerRoll.feature`,
|
|
133
|
+
// `modle.small`); when the head is one of the valid ones the shape itself is wrong, so
|
|
134
|
+
// suggesting that head back would mislead — the generic wording is the whole story there.
|
|
135
|
+
const headHint = DOTTED_KEY_HEADS.includes(first)
|
|
136
|
+
? ""
|
|
137
|
+
: typoSuffix(first, DOTTED_KEY_HEADS);
|
|
138
|
+
return {
|
|
139
|
+
kind: "error",
|
|
140
|
+
error: `unknown config key "${key}" — dotted keys name one entry of a per-role map or one field of a role entry (e.g. \`maxDailyCostUsdPerRole.feature 1.5\`, \`roles.qa.model x\`); a bare key names a whole top-level value${headHint}`,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
/** The error `tumwater config get <key>` and `config set <key> <value>` both surface for a
|
|
144
|
+
* key outside TOP_LEVEL_KEYS — the valid-keys list plus text.ts's did-you-mean suggestion —
|
|
145
|
+
* or null when the key is known. One home for the phrasing so whichever verb misspells a
|
|
146
|
+
* key, the typo reads the same. */
|
|
147
|
+
export function unknownConfigKeyError(key) {
|
|
148
|
+
if (TOP_LEVEL_KEYS.includes(key))
|
|
149
|
+
return null;
|
|
150
|
+
return `unknown config key "${key}" (valid top-level keys: ${TOP_LEVEL_KEYS.join(", ")})${typoSuffix(key, TOP_LEVEL_KEYS)}`;
|
|
151
|
+
}
|
|
152
|
+
/** The error for a dotted role key naming a field ROLE_ENTRY_KEYS does not hold — the one
|
|
153
|
+
* home of the wording `config set` and `config get` both surface, so a typo'd `roles.qa.colour`
|
|
154
|
+
* reads the same whichever verb names it (both refuse it; a silent `null` from get would look
|
|
155
|
+
* like a real unset value). Null when the field is a known role-entry key. */
|
|
156
|
+
export function unknownRoleFieldError(id, field) {
|
|
157
|
+
if (ROLE_ENTRY_KEYS.includes(field))
|
|
158
|
+
return null;
|
|
159
|
+
return `unknown role field "${field}" for roles.${id} (valid fields: ${ROLE_ENTRY_KEYS.join(", ")})${typoSuffix(field, ROLE_ENTRY_KEYS)}`;
|
|
160
|
+
}
|