tumwater 0.4.0 → 0.4.2
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 +4 -6
- package/dist/build-info.json +2 -2
- package/dist/src/agent/agent-backend.js +5 -2
- package/dist/src/agent/backend-traits.js +21 -0
- package/dist/src/agent/claude/claude-backend.js +1 -1
- package/dist/src/agent/claude/claude-stream.js +181 -182
- package/dist/src/agent/claude/{write-guard-hook.js → claude-write-guard-hook.js} +1 -1
- package/dist/src/agent/claude/claude-write-guard.js +5 -4
- package/dist/src/agent/codex/codex-backend.js +1 -1
- package/dist/src/agent/codex/codex-rollout.js +48 -45
- package/dist/src/agent/codex/codex-stream.js +269 -215
- package/dist/src/agent/codex/{write-guard-hook.js → codex-write-guard-hook.js} +2 -1
- package/dist/src/agent/codex/codex-write-guard.js +3 -3
- package/dist/src/agent/image-input.js +18 -87
- package/dist/src/agent/notes-shim.js +2 -2
- package/dist/src/agent/opencode/opencode-backend.js +2 -2
- package/dist/src/agent/opencode/opencode-catalog.js +2 -2
- package/dist/src/agent/opencode/opencode-config.js +2 -2
- package/dist/src/agent/opencode/opencode-stream.js +101 -92
- package/dist/src/agent/run-agent.js +76 -382
- package/dist/src/agent/run-settle.js +25 -15
- package/dist/src/agent/run-spawn.js +226 -0
- package/dist/src/agent/run-stream.js +185 -0
- package/dist/src/agent/session-ledger.js +16 -0
- package/dist/src/agent/write-guard.js +5 -3
- package/dist/src/backlog/backlog-archive.js +86 -170
- package/dist/src/backlog/backlog-cli-view.js +89 -70
- package/dist/src/backlog/backlog-eligibility.js +51 -59
- package/dist/src/backlog/backlog-file-bugs.js +133 -0
- package/dist/src/backlog/backlog-index-data.js +159 -0
- package/dist/src/backlog/backlog-index.js +7 -5
- package/dist/src/backlog/backlog-md.js +39 -28
- package/dist/src/backlog/backlog-release.js +21 -36
- package/dist/src/backlog/backlog-render.js +1 -1
- package/dist/src/backlog/backlog-structure.js +14 -145
- package/dist/src/backlog/backlog-write.js +31 -18
- package/dist/src/backlog/backlog.js +14 -4
- package/dist/src/backlog/question-answer.js +33 -15
- package/dist/src/baseline/main-baseline.js +71 -26
- package/dist/src/baseline/main-guardrails.js +76 -15
- package/dist/src/baseline/main-red.js +45 -94
- package/dist/src/baseline/main-watch.js +9 -3
- package/dist/src/brief/brief.js +40 -0
- package/dist/src/budget/budget-fallback-view.js +50 -8
- package/dist/src/budget/budget-wiring.js +15 -11
- package/dist/src/budget/budget.js +3 -2
- package/dist/src/build/build-check-events.js +4 -2
- package/dist/src/build/build-check-report.js +2 -1
- package/dist/src/build/build-check-scoped.js +70 -41
- package/dist/src/change/change-data.js +4 -2
- package/dist/src/cli/cli-args.js +9 -0
- package/dist/src/cli/cli-command-args.js +199 -98
- package/dist/src/cli/cli-flag-specs.js +6 -4
- package/dist/src/cli/cli-marker-commands.js +2 -2
- package/dist/src/cli/cli-query-commands.js +41 -6
- package/dist/src/cli/cli-review-commands.js +2 -1
- package/dist/src/cli/cli-role-command.js +46 -92
- package/dist/src/cli/cli-start.js +123 -47
- package/dist/src/cli/follow-view.js +3 -1
- package/dist/src/cli/help.js +19 -12
- package/dist/src/cli/log-commands.js +128 -107
- package/dist/src/cli/question-commands.js +82 -63
- package/dist/src/cli.js +396 -343
- package/dist/src/collections.js +46 -9
- package/dist/src/config/config-fallbacks.js +0 -4
- package/dist/src/config/config-live.js +179 -147
- package/dist/src/config/config-request.js +176 -51
- package/dist/src/config/config-validation-roles.js +271 -0
- package/dist/src/config/config-validation.js +10 -205
- package/dist/src/config/config-views.js +2 -7
- package/dist/src/config/config-write.js +205 -175
- package/dist/src/config/config.js +2 -1
- package/dist/src/doctor/doctor-backlog-bugs.js +24 -122
- package/dist/src/doctor/doctor-backlog-plans.js +24 -25
- package/dist/src/doctor/doctor-backlog.js +34 -42
- package/dist/src/doctor/doctor-check-names.js +4 -2
- package/dist/src/doctor/doctor-checks.js +16 -4
- package/dist/src/doctor/doctor-claude-checks.js +2 -2
- package/dist/src/doctor/doctor-codex-checks.js +9 -20
- package/dist/src/doctor/doctor-contention-checks.js +52 -19
- package/dist/src/doctor/doctor-holds.js +22 -20
- package/dist/src/doctor/doctor-model-checks.js +8 -184
- package/dist/src/doctor/doctor-notify.js +2 -5
- package/dist/src/doctor/doctor-opencode-checks.js +90 -41
- package/dist/src/doctor/doctor-principles.js +26 -0
- package/dist/src/doctor/doctor-project-checks.js +69 -19
- package/dist/src/doctor/doctor-role-notes.js +33 -0
- package/dist/src/doctor/doctor.js +13 -6
- package/dist/src/doctor/probe.js +1 -1
- package/dist/src/events/event-format.js +307 -162
- package/dist/src/events/event-labels.js +0 -1
- package/dist/src/events/event-legacy.js +1 -1
- package/dist/src/events/event-window.js +1 -1
- package/dist/src/events/events.js +15 -2
- package/dist/src/events/notify.js +1 -1
- package/dist/src/failure/failure-cluster.js +4 -3
- package/dist/src/failure/failure-data.js +126 -94
- package/dist/src/failure/failure-render.js +58 -17
- package/dist/src/failure/failure-state-change.js +308 -27
- package/dist/src/failure/time-spend.js +139 -106
- package/dist/src/files/files.js +7 -0
- package/dist/src/files/json-object.js +15 -2
- package/dist/src/gates/budget-gates.js +90 -68
- package/dist/src/gates/gate-polls.js +92 -61
- package/dist/src/gates/role-cap-gates.js +1 -1
- package/dist/src/gates/startup-gate.js +28 -28
- package/dist/src/gates/streak-gate.js +1 -1
- package/dist/src/git/git.js +1 -1
- package/dist/src/git/slot-salvage.js +9 -9
- package/dist/src/git/slots-state.js +12 -3
- package/dist/src/git/worktree-pool-startup.js +127 -0
- package/dist/src/git/worktree-pool.js +1 -120
- package/dist/src/gui/gui-admin-endpoints.js +3 -4
- package/dist/src/gui/gui-args.js +1 -1
- package/dist/src/gui/gui-backlog-commands.js +20 -11
- package/dist/src/gui/gui-backlog-endpoints.js +2 -8
- package/dist/src/gui/gui-config-commands.js +51 -76
- package/dist/src/gui/gui-endpoint-commands.js +4 -2
- package/dist/src/gui/gui-endpoint-common.js +4 -3
- package/dist/src/gui/gui-history-endpoints.js +36 -27
- package/dist/src/gui/gui-operator-commands.js +43 -17
- package/dist/src/gui/gui-prompt-commands.js +27 -21
- package/dist/src/gui/gui-quarantine-commands.js +30 -0
- package/dist/src/gui/gui-report-endpoints.js +13 -9
- package/dist/src/gui/gui-server.js +5 -3
- package/dist/src/gui/gui-team-commands.js +1 -1
- package/dist/src/history/history-data.js +7 -1
- package/dist/src/history/history-render.js +15 -10
- package/dist/src/history/history.js +103 -68
- package/dist/src/inbox/inbox-submit.js +1 -5
- package/dist/src/inbox/pending-prompt.js +3 -2
- package/dist/src/inbox/prompt-commands.js +194 -161
- package/dist/src/init/init.js +104 -25
- package/dist/src/merge/merge-core.js +1 -1
- package/dist/src/merge/merge-drain.js +1 -1
- package/dist/src/merge/merge-git.js +5 -5
- package/dist/src/merge/{merge-post-review.js → merge-post-review-drain.js} +1 -1
- package/dist/src/merge/merge-queue.js +2 -1
- package/dist/src/merge/merge-to-main.js +1 -1
- package/dist/src/merge/merge-verify.js +3 -2
- package/dist/src/operator/operator-commands.js +84 -52
- package/dist/src/operator/operator-intent.js +26 -3
- package/dist/src/operator/retire.js +2 -1
- package/dist/src/operator/revert.js +18 -12
- package/dist/src/orchestrator/orchestrator-poll.js +2 -2
- package/dist/src/orchestrator/orchestrator.js +1 -1
- package/dist/src/orchestrator/retention.js +6 -5
- package/dist/src/paths.js +10 -10
- package/dist/src/pi/pi-bin.js +2 -2
- package/dist/src/pi/pi-models.js +9 -0
- package/dist/src/pi/pi-stream.js +63 -41
- package/dist/src/pi/pi-watchdogs.js +84 -49
- package/dist/src/pi-extension/context-shake.js +55 -31
- package/dist/src/pi-extension/full-output.js +2 -3
- package/dist/src/process/process.js +24 -1
- package/dist/src/process/run-marker.js +2 -2
- package/dist/src/process/supervisor.js +24 -5
- package/dist/src/prompt/gate-notes.js +41 -16
- package/dist/src/prompt/notes-backlog.js +22 -29
- package/dist/src/prompt/principles.js +11 -0
- package/dist/src/prompt/prompt-followup.js +4 -12
- package/dist/src/prompt/prompt.js +15 -12
- package/dist/src/redeploy/redeploy-probes.js +11 -1
- package/dist/src/redeploy/redeployer.js +54 -7
- package/dist/src/redeploy/self-reload.js +7 -2
- package/dist/src/report/report-data.js +224 -320
- package/dist/src/report/report-fold.js +23 -231
- package/dist/src/report/report-main-red.js +70 -3
- package/dist/src/report/report-render.js +181 -48
- package/dist/src/report/report-since-data.js +218 -0
- package/dist/src/report/report-usage-fold.js +288 -0
- package/dist/src/report/report.js +2 -1
- package/dist/src/review/known-flakes.js +22 -3
- package/dist/src/review/post-merge-review-accept.js +3 -2
- package/dist/src/review/post-merge-review.js +16 -14
- package/dist/src/review/quarantine-render.js +29 -11
- package/dist/src/review/review-precheck.js +2 -1
- package/dist/src/roles/role-catalog.js +30 -29
- package/dist/src/roles/role-guidance.js +26 -43
- package/dist/src/roles/role-render.js +19 -0
- package/dist/src/roles/role-view.js +19 -5
- package/dist/src/roles/roles.js +2 -22
- package/dist/src/run/ci-parity.js +5 -6
- package/dist/src/run/feedback-filings.js +10 -1
- package/dist/src/run/qa-coverage.js +4 -4
- package/dist/src/run/run-apply.js +14 -2
- package/dist/src/run/run-detail-data.js +50 -10
- package/dist/src/run/run-detail-render.js +32 -1
- package/dist/src/run/run-detail.js +44 -22
- package/dist/src/run/run-dismiss.js +43 -0
- package/dist/src/run/run-finalize.js +75 -46
- package/dist/src/run/run-prompt.js +271 -187
- package/dist/src/run/run-resume.js +32 -31
- package/dist/src/run/run-stage.js +116 -64
- package/dist/src/run/run-timing.js +18 -4
- package/dist/src/run/run-verdict.js +119 -83
- package/dist/src/run/stage-backlog-checks.js +4 -31
- package/dist/src/run/stage-check.js +4 -12
- package/dist/src/run/stage-checks-shared.js +15 -0
- package/dist/src/run/stage-name-checks.js +3 -13
- package/dist/src/run/stage-path-checks.js +26 -29
- package/dist/src/runner/leftover.js +13 -4
- package/dist/src/runner/revision.js +2 -2
- package/dist/src/runner/runner-fallback.js +1 -1
- package/dist/src/runner/runner-outcome.js +0 -5
- package/dist/src/runner/runner-pi.js +72 -49
- package/dist/src/runner/runner-run.js +19 -102
- package/dist/src/runner/runner-state-load.js +0 -10
- package/dist/src/runner/runner.js +1 -32
- package/dist/src/scheduling/claims.js +4 -11
- package/dist/src/status/status-data.js +140 -91
- package/dist/src/status/status-polls.js +41 -5
- package/dist/src/status/status-role-notes.js +53 -0
- package/dist/src/status/status-roles.js +10 -37
- package/dist/src/text/datetime.js +8 -1
- package/dist/src/text/format.js +13 -1
- package/dist/src/text/phrases.js +16 -2
- package/dist/src/text/text.js +12 -0
- package/dist/src/ui/alert-feeds.js +115 -0
- package/dist/src/ui/badges.js +3 -4
- package/dist/src/ui/gui/gui-client-boot.js +78 -57
- package/dist/src/ui/gui/gui-client-drawer-config.js +8 -3
- package/dist/src/ui/gui/gui-client-drawer.js +18 -2
- package/dist/src/ui/gui/gui-client-feed.js +3 -8
- package/dist/src/ui/gui/gui-client-history.js +16 -16
- package/dist/src/ui/gui/gui-client-model.js +9 -9
- package/dist/src/ui/gui/gui-client-operator.js +6 -10
- package/dist/src/ui/gui/gui-client-pending.js +4 -4
- package/dist/src/ui/gui/gui-client-report-chart.js +111 -10
- package/dist/src/ui/gui/gui-client-report.js +70 -11
- package/dist/src/ui/gui/gui-client-roles.js +52 -12
- package/dist/src/ui/gui/gui-client-settings.js +31 -26
- package/dist/src/ui/gui/gui-client-team.js +28 -2
- package/dist/src/ui/gui/gui-client.js +33 -4
- package/dist/src/ui/gui/gui-icons.js +7 -1
- package/dist/src/ui/progress-data.js +2 -2
- package/dist/src/ui/run-progress-model.js +2 -2
- package/dist/src/ui/status-model.js +26 -16
- package/dist/src/ui/status-payload.js +149 -326
- package/dist/src/ui/status-render.js +131 -80
- package/dist/src/ui/team-alert-backlog.js +236 -0
- package/dist/src/ui/team-alert-shared.js +92 -0
- package/dist/src/ui/team-alert-status.js +326 -0
- package/dist/src/ui/team-alert-tail.js +238 -0
- package/dist/src/ui/team-alerts.js +16 -984
- package/dist/src/ui/transcript-tail.js +137 -94
- package/dist/src/ui/tui/tui-backlog.js +1 -1
- package/dist/src/ui/tui/tui-editor-answer.js +54 -44
- package/dist/src/ui/tui/tui-editor-attach.js +74 -63
- package/dist/src/ui/tui/tui-editor-backends.js +62 -56
- package/dist/src/ui/tui/tui-editor-brief.js +64 -54
- package/dist/src/ui/tui/tui-editor-budget.js +106 -89
- package/dist/src/ui/tui/tui-editor-config.js +2 -4
- package/dist/src/ui/tui/tui-editor-ctx.js +12 -1
- package/dist/src/ui/tui/tui-editor-entry.js +171 -153
- package/dist/src/ui/tui/tui-editor-instances.js +2 -1
- package/dist/src/ui/tui/tui-editor-instructions.js +50 -107
- package/dist/src/ui/tui/tui-editor-interval.js +39 -112
- package/dist/src/ui/tui/tui-editor-notes.js +28 -84
- package/dist/src/ui/tui/tui-editor-pause.js +210 -183
- package/dist/src/ui/tui/tui-editor-prompt.js +46 -35
- package/dist/src/ui/tui/tui-editor-queued.js +74 -64
- package/dist/src/ui/tui/tui-editor-quiet.js +122 -108
- package/dist/src/ui/tui/tui-editor-release.js +85 -0
- package/dist/src/ui/tui/tui-editor-retire.js +57 -46
- package/dist/src/ui/tui/tui-editor-role-enabled.js +90 -80
- package/dist/src/ui/tui/tui-editor-role-file.js +87 -0
- package/dist/src/ui/tui/tui-editor-role-key.js +50 -42
- package/dist/src/ui/tui/tui-editor-search.js +70 -58
- package/dist/src/ui/tui/tui-editor-session.js +3 -0
- package/dist/src/ui/tui/tui-editors.js +4 -1
- package/dist/src/ui/tui/tui-frame.js +7 -3
- package/dist/src/ui/tui/tui-history.js +12 -10
- package/dist/src/ui/tui/tui-keys.js +6 -1
- package/dist/src/ui/tui/tui-pane-activity.js +62 -50
- package/dist/src/ui/tui/tui-pane-backlog.js +4 -4
- package/dist/src/ui/tui/tui-pane-ctx.js +1 -1
- package/dist/src/ui/tui/tui-pane-health.js +45 -26
- package/dist/src/ui/tui/tui-pane-history.js +296 -264
- package/dist/src/ui/tui/tui-pane-report.js +74 -57
- package/dist/src/ui/tui/tui-pane-session.js +6 -2
- package/dist/src/ui/tui/tui-pane-transcript.js +158 -131
- package/dist/src/ui/tui/tui-pane.js +201 -174
- package/dist/src/ui/tui/tui-panes.js +211 -148
- package/dist/src/ui/tui/tui.js +4 -4
- package/dist/src/verdict/fix-claim.js +18 -6
- package/dist/src/verdict/refusal.js +32 -7
- package/package.json +1 -1
- package/dist/src/doctor/image-probe.js +0 -257
- /package/dist/src/gui/{http-body.js → gui-http-body.js} +0 -0
- /package/dist/src/merge/{backlog-conflicts.js → merge-backlog-conflicts.js} +0 -0
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
/** The
|
|
1
|
+
/** The role's staged config-write request — one verb split out of config-write.ts (the
|
|
2
2
|
* operator-editor mutators stay there, addressed by key through config-keys.ts). The director
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
3
|
+
* (and, for a declared project check, the feature role) stages a JSON request file in its
|
|
4
|
+
* worktree; this module consumes it: parse the request, keep only its `customRoles` array and
|
|
5
|
+
* a declared `check.command`, apply it as a whole-config write, and delete the file on every
|
|
6
|
+
* path. A request is shaped and lives differently from the interactive `config set` /
|
|
6
7
|
* `unset` verbs (a staged file that must never reach a diff, not direct CLI text), so the
|
|
7
8
|
* runtime (run-verdict.ts) imports this module rather than the whole editor. It
|
|
8
9
|
* still shares the write machinery with the editor — withConfigLock and rawConfigObject from
|
|
@@ -10,9 +11,10 @@
|
|
|
10
11
|
* config-validation.ts, writeJsonAtomic from json-files.js — because a request is a
|
|
11
12
|
* whole-config write too. */
|
|
12
13
|
import fs from "node:fs";
|
|
14
|
+
import path from "node:path";
|
|
13
15
|
import { isDeepStrictEqual } from "node:util";
|
|
14
16
|
import { configPath, configRequestPath } from "../paths.js";
|
|
15
|
-
import { errorMessage } from "../text/text.js";
|
|
17
|
+
import { errorMessage, isNonBlankString } from "../text/text.js";
|
|
16
18
|
import { readTextOrNull } from "../files/files.js";
|
|
17
19
|
import { writeJsonAtomic } from "../files/json-files.js";
|
|
18
20
|
import { isJsonObject } from "../files/json-object.js";
|
|
@@ -21,21 +23,124 @@ import { show } from "./config-field-checks.js";
|
|
|
21
23
|
import { validateConfig } from "./config-validation.js";
|
|
22
24
|
import { normalizeConfigKeys } from "./config-aliases.js";
|
|
23
25
|
import { collapseBackendAliases, loadConfig } from "./config.js";
|
|
24
|
-
|
|
26
|
+
import { BUILD_CHECK_TIMEOUT_MS, gateCommandOf, } from "../build/build-check-detect.js";
|
|
27
|
+
import { runBuildCheck } from "../build/build-check.js";
|
|
28
|
+
import { CHECK_TIER, withCheckPermit } from "../concurrency/check-permit.js";
|
|
29
|
+
async function defaultCheckGuard(wt, check, config) {
|
|
30
|
+
// Take the same process-wide check permit every full-suite runner takes (runScopedBuildCheck's
|
|
31
|
+
// gate/merge/batch scopes and checkMainBaseline), so a declaration burst cannot stack suites
|
|
32
|
+
// on the host beside the authors' own runs. runBuildCheck's own arguments stand: the same
|
|
33
|
+
// install-first step and upward PATH resolution the gate's run uses; the candidate BuildCheck
|
|
34
|
+
// carries the config-resolved timeout, so checkTimeoutMs enforces it here exactly as the
|
|
35
|
+
// pre-check would.
|
|
36
|
+
return withCheckPermit(config, CHECK_TIER.other, () => runBuildCheck(wt, check));
|
|
37
|
+
}
|
|
38
|
+
/** The candidates the guard verifies before a declared `check.command` is written: the
|
|
39
|
+
* declared command itself (the feature's fail-closed guarantee — a request whose own command
|
|
40
|
+
* fails must not be written), plus, when a `check.gateCommand` is set and differs, that gate
|
|
41
|
+
* command too (the gate scope's pre-check — runScopedBuildCheck — swaps in `check.gateCommand`
|
|
42
|
+
* at scope "gate", so the guard must not read green on a command the gate never runs). Each is
|
|
43
|
+
* run at `check.cwd` resolved against the worktree (default `.`), under the config-resolved
|
|
44
|
+
* timeout. A preserved cwd must be honored or the guard runs in the wrong tree — a false green
|
|
45
|
+
* or false red that defeats the fail-closed purpose. */
|
|
46
|
+
function guardChecks(current, wt, declared) {
|
|
47
|
+
const c = current.check;
|
|
48
|
+
const configuredMs = typeof c?.timeoutSeconds === "number" && c.timeoutSeconds > 0 ? c.timeoutSeconds * 1000 : undefined;
|
|
49
|
+
const base = {
|
|
50
|
+
kind: "command",
|
|
51
|
+
cwd: path.resolve(wt, isNonBlankString(c?.cwd) ? c.cwd : "."),
|
|
52
|
+
timeoutMs: configuredMs ?? BUILD_CHECK_TIMEOUT_MS,
|
|
53
|
+
};
|
|
54
|
+
const gate = gateCommandOf(current);
|
|
55
|
+
if (gate !== undefined && gate !== declared) {
|
|
56
|
+
return [{ ...base, command: gate }, { ...base, command: declared }];
|
|
57
|
+
}
|
|
58
|
+
return [{ ...base, command: declared }];
|
|
59
|
+
}
|
|
60
|
+
/** How a non-passed guard outcome is named in the rejection error. */
|
|
61
|
+
function describeGuardFailure(o) {
|
|
62
|
+
if (o.status === "failed")
|
|
63
|
+
return "failed";
|
|
64
|
+
if (o.skipReason === "timeout")
|
|
65
|
+
return "timed out";
|
|
66
|
+
if (o.skipReason === "install")
|
|
67
|
+
return "could not install dependencies";
|
|
68
|
+
return "could not run";
|
|
69
|
+
}
|
|
70
|
+
/** Build the whole-config write candidate a request would produce from a given `current`
|
|
71
|
+
* config — the request's customRoles array (or the live one when absent) and, when a
|
|
72
|
+
* check.command is declared, the check section with only `command` replaced — and return
|
|
73
|
+
* whether that candidate differs from `current` at all. Shared by the pre-write validation
|
|
74
|
+
* (which must reject an invalid request BEFORE the declared check runs, so a request pairing a
|
|
75
|
+
* valid check.command with an invalid customRoles entry never spawns the check) and the locked
|
|
76
|
+
* write, so the two can never build different candidates. Roles stripping is folded in: the
|
|
77
|
+
* standalone validation has to reject what the write would reject, and the write has to strip
|
|
78
|
+
* what the validation sees. */
|
|
79
|
+
function buildCandidate(root, current, entries, checkCommand) {
|
|
80
|
+
const customRoles = entries === undefined ? current.customRoles : entries;
|
|
81
|
+
const checkChanged = checkCommand !== undefined && (current.check?.command ?? "") !== checkCommand;
|
|
82
|
+
const unchanged = !checkChanged && isDeepStrictEqual(customRoles, current.customRoles);
|
|
83
|
+
// Strip roles.<id> entries for custom roles this request removes (see applyConfigRequest's
|
|
84
|
+
// docstring): structurally valid names only; anything else stays for validateConfig to
|
|
85
|
+
// reject — never dereference a request entry before validation.
|
|
86
|
+
const requestedNames = new Set();
|
|
87
|
+
for (const entry of customRoles) {
|
|
88
|
+
if (isJsonObject(entry) && typeof entry.name === "string")
|
|
89
|
+
requestedNames.add(entry.name);
|
|
90
|
+
}
|
|
91
|
+
// Base the roles rewrite on the FILE's own section, not the loaded (catalog-merged) map:
|
|
92
|
+
// this whole-config write must not re-expand a partial section to every built-in
|
|
93
|
+
// (preserveFileRoles' rule). Only the removed custom roles' entries are stripped, and only
|
|
94
|
+
// from what the file actually holds.
|
|
95
|
+
const rawRolesCandidate = rawConfigObject(root).roles;
|
|
96
|
+
const roles = isJsonObject(rawRolesCandidate) ? { ...(rawRolesCandidate) } : {};
|
|
97
|
+
for (const c of current.customRoles) {
|
|
98
|
+
if (!requestedNames.has(c.name))
|
|
99
|
+
delete roles[c.name];
|
|
100
|
+
}
|
|
101
|
+
const candidate = { ...current, customRoles };
|
|
102
|
+
if (checkChanged && checkCommand !== undefined) {
|
|
103
|
+
// Preserve the rest of the check section (gateCommand/mergeCommand/timeoutSeconds);
|
|
104
|
+
// only `command` is declared by the request.
|
|
105
|
+
candidate.check = { ...current.check, command: checkCommand };
|
|
106
|
+
}
|
|
107
|
+
if (rawRolesCandidate === undefined)
|
|
108
|
+
delete candidate.roles;
|
|
109
|
+
else
|
|
110
|
+
candidate.roles = roles;
|
|
111
|
+
return { candidate: candidate, changed: !unchanged };
|
|
112
|
+
}
|
|
113
|
+
/** Consume the role's config-write request file (plans/portability.md §3/7). After its pi
|
|
25
114
|
* run the director may leave `.tumwater-config-request.json` at its worktree root:
|
|
26
|
-
* `{ "customRoles": [ { "name", "task" }, … ] }` — the whole array, replacing the current one
|
|
115
|
+
* `{ "customRoles": [ { "name", "task" }, … ] }` — the whole array, replacing the current one;
|
|
116
|
+
* the feature role (only) may leave the same file carrying a `check.command` to declare the
|
|
117
|
+
* project's verification command, which is observed green in the worktree before it is written.
|
|
27
118
|
* Applied atomically to the live config, sharing the editor's trap (fresh loadConfig that
|
|
28
119
|
* bypasses the stat cache, validateConfig, then writeJsonAtomic — readers poll every ~2 s), so
|
|
29
120
|
* the orchestrator's live reload starts the new role with no commit, no review
|
|
30
121
|
* gate, and no merge, and the request file never enters a diff.
|
|
31
122
|
*
|
|
32
|
-
* The permitted key set is enforced here, not in prose: `customRoles`
|
|
33
|
-
* other top-level key is collected into `ignored` and dropped (the caller
|
|
34
|
-
* names them).
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
123
|
+
* The permitted key set is enforced here, not in prose: `customRoles` and a `check` object are
|
|
124
|
+
* accepted and every other top-level key is collected into `ignored` and dropped (the caller
|
|
125
|
+
* logs the warning that names them). Inside `check` only `command` is read — a non-blank
|
|
126
|
+
* string that declares the project's check; every other sub-key (and a blank `command`) is
|
|
127
|
+
* collected into `ignored` and dropped, so a role can declare a check but never turn the
|
|
128
|
+
* safety gates off (`gateCommand: ""`, `mergeCommand: ""`, `timeoutSeconds`). Validation runs
|
|
129
|
+
* BEFORE any write and nothing here dereferences a request entry first — a structurally
|
|
130
|
+
* malformed entry (`[null]`, a non-string name) is left for validateConfig to reject, so on
|
|
131
|
+
* failure nothing is written and the previous config stays live. The request file is deleted
|
|
132
|
+
* on EVERY path (including every failure), so a malformed request cannot retry forever and the
|
|
133
|
+
* file cannot survive into `git add -A`.
|
|
134
|
+
*
|
|
135
|
+
* A declared `check.command` is verified before it is written: the declared command is run
|
|
136
|
+
* through runBuildCheck in the requesting worktree — and, when a `check.gateCommand` is set
|
|
137
|
+
* and differs, that gate command (which the gate scope runs) is verified first too — each at
|
|
138
|
+
* `check.cwd` resolved against it (default `.`), under the config-resolved timeout, the same
|
|
139
|
+
* install-first step, and the same process-wide check permit (`maxConcurrentChecks`) the gate's
|
|
140
|
+
* pre-check takes — and only a `passed` outcome for every candidate writes. A failed, timed-out,
|
|
141
|
+
* or skipped run writes nothing and returns an error naming what was observed — the role's own
|
|
142
|
+
* claim is not the evidence, because a command that could never pass would park the whole queue
|
|
143
|
+
* behind a red main no role can repair (part 1/2).
|
|
39
144
|
*
|
|
40
145
|
* Roles entries for custom roles the request removes are stripped before validation, from the
|
|
41
146
|
* FILE's own `roles` section rather than the catalog-merged map (the rule every other
|
|
@@ -46,11 +151,11 @@ import { collapseBackendAliases, loadConfig } from "./config.js";
|
|
|
46
151
|
* validateConfig to reject.
|
|
47
152
|
*
|
|
48
153
|
* Returns null when no request file exists. `applied` names the custom roles now live (empty
|
|
49
|
-
* unless the write happened); `ignored` lists the discarded top-level keys
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
* nothing is written. */
|
|
53
|
-
export function applyConfigRequest(root, wt) {
|
|
154
|
+
* unless the write happened); `ignored` lists the discarded keys (top-level keys and, named
|
|
155
|
+
* `check.<key>`, the check sub-keys that were dropped); `error`, when set, names why nothing
|
|
156
|
+
* (or only part of the work) was done — the caller turns it and `ignored` into warning events.
|
|
157
|
+
* `unchanged` is set when the request changes nothing; nothing is written. */
|
|
158
|
+
export async function applyConfigRequest(root, wt, runCheck = defaultCheckGuard) {
|
|
54
159
|
const requestFile = configRequestPath(wt);
|
|
55
160
|
const raw = readTextOrNull(requestFile);
|
|
56
161
|
if (raw === null)
|
|
@@ -69,48 +174,68 @@ export function applyConfigRequest(root, wt) {
|
|
|
69
174
|
throw new Error(normalized.problems.join("; "));
|
|
70
175
|
const parsed = normalized.raw;
|
|
71
176
|
for (const key of Object.keys(parsed)) {
|
|
72
|
-
if (key !== "customRoles")
|
|
177
|
+
if (key !== "customRoles" && key !== "check")
|
|
73
178
|
ignored.push(key);
|
|
74
179
|
}
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
180
|
+
// A check request is read only for its `command` (a non-blank string). Every other
|
|
181
|
+
// sub-key — and a blank `command` — is dropped into `ignored`, so it can never turn the
|
|
182
|
+
// safety gates off; a blank/absent command simply declares nothing.
|
|
183
|
+
let checkCommand;
|
|
184
|
+
if (parsed.check !== undefined) {
|
|
185
|
+
const checkReq = parsed.check;
|
|
186
|
+
if (!isJsonObject(checkReq))
|
|
187
|
+
throw new Error(`config request's check must be an object (got ${show(checkReq)})`);
|
|
188
|
+
for (const key of Object.keys(checkReq)) {
|
|
189
|
+
if (key !== "command")
|
|
190
|
+
ignored.push(`check.${key}`);
|
|
191
|
+
}
|
|
192
|
+
if (isNonBlankString(checkReq.command))
|
|
193
|
+
checkCommand = checkReq.command;
|
|
194
|
+
else if (checkReq.command !== undefined)
|
|
195
|
+
ignored.push("check.command");
|
|
196
|
+
}
|
|
197
|
+
const entriesRaw = parsed.customRoles;
|
|
198
|
+
if (entriesRaw !== undefined && !Array.isArray(entriesRaw))
|
|
199
|
+
throw new Error(`config request's customRoles must be an array (got ${show(entriesRaw)})`);
|
|
200
|
+
const entries = entriesRaw;
|
|
201
|
+
// Verified declaration (part 1/2): a declared check is fail-closed — the gate's pre-check
|
|
202
|
+
// rejects a red run and a failure that reproduces on main parks every queued pin behind a
|
|
203
|
+
// red main no role can repair by code — so the declared command must be observed green in
|
|
204
|
+
// the requesting worktree BEFORE it is written. Only a `passed` outcome writes; failed,
|
|
205
|
+
// timed-out, or skipped writes nothing and returns an error naming what was observed. Each
|
|
206
|
+
// candidate is run at `check.cwd` resolved against the worktree (default `.`) under the
|
|
207
|
+
// config-resolved timeout, and when a `check.gateCommand` is set and differs the gate
|
|
208
|
+
// command is verified first too — the declared command is the fail-closed promise, the
|
|
209
|
+
// gate command is what the gate scope will actually run — so a green here is a green at
|
|
210
|
+
// the gate for every command the write leaves live.
|
|
211
|
+
if (checkCommand !== undefined) {
|
|
212
|
+
// Read the current config only for the guard — the write reloads fresh under the lock.
|
|
213
|
+
const currentForGuard = loadConfig(root);
|
|
214
|
+
// Pre-validate what the request would write BEFORE the declared check runs: a request
|
|
215
|
+
// that pairs a valid check.command with an invalid customRoles entry must be rejected
|
|
216
|
+
// without ever spawning the check (it would fail validation anyway). The locked write
|
|
217
|
+
// re-loads fresh and re-validates, so a config change between here and the write is
|
|
218
|
+
// still caught.
|
|
219
|
+
const { candidate: preCandidate } = buildCandidate(root, currentForGuard, entries, checkCommand);
|
|
220
|
+
validateConfig(collapseBackendAliases(preCandidate));
|
|
221
|
+
for (const check of guardChecks(currentForGuard, wt, checkCommand)) {
|
|
222
|
+
const outcome = await runCheck(wt, check, currentForGuard);
|
|
223
|
+
if (outcome.status !== "passed") {
|
|
224
|
+
throw new Error(`check.command not declared: \`${outcome.script}\` ${describeGuardFailure(outcome)} in the worktree`);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
78
228
|
// The load-to-write sequence is one critical section under withConfigLock: a concurrent
|
|
79
229
|
// `config set`/dashboard write between the load and this write must not be dropped by the
|
|
80
230
|
// whole-file overwrite (see writeConfigMutation).
|
|
81
231
|
withConfigLock(root, () => {
|
|
82
232
|
const current = loadConfig(root); // fresh — bypasses the stat cache: a writer sees the latest file
|
|
83
|
-
|
|
84
|
-
|
|
233
|
+
const { candidate, changed } = buildCandidate(root, current, entries, checkCommand);
|
|
234
|
+
// Nothing the request would change: write nothing, like writeConfigMutation's no-op skip.
|
|
235
|
+
if (!changed) {
|
|
85
236
|
unchanged = true;
|
|
86
237
|
return;
|
|
87
238
|
}
|
|
88
|
-
// Strip roles.<id> entries for custom roles this request removes (see docstring).
|
|
89
|
-
// Structurally valid names only; anything else stays for validateConfig to reject — never
|
|
90
|
-
// dereference a request entry before validation.
|
|
91
|
-
const requestedNames = new Set();
|
|
92
|
-
for (const entry of entries) {
|
|
93
|
-
if (isJsonObject(entry) && typeof entry.name === "string")
|
|
94
|
-
requestedNames.add(entry.name);
|
|
95
|
-
}
|
|
96
|
-
// Base the roles rewrite on the FILE's own section, not the loaded (catalog-merged)
|
|
97
|
-
// map: this whole-config write must not re-expand a partial section to every built-in
|
|
98
|
-
// (preserveFileRoles' rule). Only the removed custom roles' entries are stripped, and
|
|
99
|
-
// only from what the file actually holds.
|
|
100
|
-
const rawRolesCandidate = rawConfigObject(root).roles;
|
|
101
|
-
const roles = isJsonObject(rawRolesCandidate)
|
|
102
|
-
? { ...(rawRolesCandidate) }
|
|
103
|
-
: {};
|
|
104
|
-
for (const c of current.customRoles) {
|
|
105
|
-
if (!requestedNames.has(c.name))
|
|
106
|
-
delete roles[c.name];
|
|
107
|
-
}
|
|
108
|
-
const candidate = { ...current, customRoles: entries };
|
|
109
|
-
const candidateRecord = candidate;
|
|
110
|
-
if (rawRolesCandidate === undefined)
|
|
111
|
-
delete candidateRecord.roles;
|
|
112
|
-
else
|
|
113
|
-
candidateRecord.roles = roles;
|
|
114
239
|
// Collapse legacy/new pi spellings before writing so a merged config (which always
|
|
115
240
|
// carries the default piArgs) never lands both forms in the file — the next load's raw
|
|
116
241
|
// pass rejects a file holding both `piArgs` and `backends.pi.args`.
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
/** The per-role and cross-field section rules of tumwater.json validation: the `customRoles`
|
|
2
|
+
* collect-and-check block, the `roles.<id>` entry block, `review.afterMerge`'s name check,
|
|
3
|
+
* the per-role daily cost caps and quiet hours, `maxConcurrentPerProvider`,
|
|
4
|
+
* `claudeWindowReserve` and its mutually-exclusive spelling pair, and the `idleBackoff`
|
|
5
|
+
* cross-field pair. Extracted from the single validateConfig gate (config-validation.ts) so a
|
|
6
|
+
* per-role or cross-field edit lands as a per-theme diff instead of a whole-gate one; this
|
|
7
|
+
* module runs once, at the gate's end, after every other section has appended its problems,
|
|
8
|
+
* so the overall problem order is unchanged. These rules sit on top of the generic machinery
|
|
9
|
+
* in config-field-checks.ts; the helpers passed in are the closures the gate binds once (the
|
|
10
|
+
* problems-bound checkers and the shared model-triple/window-reserve sequences), and the
|
|
11
|
+
* cross-cutting `customNames` set — collected in the customRoles block, consumed by the
|
|
12
|
+
* roles.<id>, afterMerge, cap and quiet-hour blocks — is owned here, so validateConfig
|
|
13
|
+
* passes no set across the boundary. */
|
|
14
|
+
import { CUSTOM_ROLE_KEYS, CUSTOM_NAME_RE, CUSTOM_TASK_MAX_CHARS, ROLE_ENTRY_KEYS, ROLE_INSTRUCTIONS_MAX_CHARS, } from "./config-schema.js";
|
|
15
|
+
import { DIRECTOR_ROLE, allRoleIds } from "../roles/roles.js";
|
|
16
|
+
import { INSTANCE_ROLES, baseRoleOf } from "../roles/runner-ids.js";
|
|
17
|
+
import { isJsonObject } from "../files/json-object.js";
|
|
18
|
+
import { isNonBlankString } from "../text/text.js";
|
|
19
|
+
import { parseQuietHours } from "../scheduling/quiet-hours.js";
|
|
20
|
+
import { TUMWATER_SOURCE } from "../events/events.js";
|
|
21
|
+
import { DOLLAR_CAP, MAX_DURATION_SECONDS, POSITIVE_INTEGER, checkKnownKeys, checkKnownRoleId, checkNumberField, show, } from "./config-field-checks.js";
|
|
22
|
+
const DURATION_NON_NEGATIVE = {
|
|
23
|
+
ok: (n) => n >= 0 && n <= MAX_DURATION_SECONDS,
|
|
24
|
+
what: `a number of 0 or more, at most ${MAX_DURATION_SECONDS}`,
|
|
25
|
+
};
|
|
26
|
+
/** A per-role instance count (`roles.feature.instances`): an integer from 1 to 8. Bounded so
|
|
27
|
+
* a typo like `instances: 800` cannot spawn hundreds of runners, and integer so
|
|
28
|
+
* `feature-1.5` — which no role id can name — is refused. */
|
|
29
|
+
const INSTANCE_COUNT = {
|
|
30
|
+
ok: (n) => Number.isInteger(n) && n >= 1 && n <= 8,
|
|
31
|
+
what: "an integer from 1 to 8",
|
|
32
|
+
};
|
|
33
|
+
/** Validate one per-role map section (`maxDailyCostUsdPerRole`, `quietHoursPerRole`): the
|
|
34
|
+
* value must be an object mapping role ids to entries, every key must name a known role
|
|
35
|
+
* (checkKnownRoleId's guard — a typo'd id would silently no-op the section), and each entry
|
|
36
|
+
* goes through `checkValue` with its role id. `expectation` is the value description the
|
|
37
|
+
* type-error message quotes. One home for the section shape the two dotted per-role keys
|
|
38
|
+
* share, so a third per-role map reuses it instead of re-copying the loop. */
|
|
39
|
+
function checkPerRoleMap(r, key, expectation, customNames, problems, checkValue) {
|
|
40
|
+
if (!(key in r))
|
|
41
|
+
return;
|
|
42
|
+
const map = r[key];
|
|
43
|
+
if (!isJsonObject(map)) {
|
|
44
|
+
problems.push(`${key} must be an object mapping role ids to ${expectation} (got ${show(map)})`);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
for (const [id, value] of Object.entries(map)) {
|
|
48
|
+
if (!checkKnownRoleId(key, id, customNames, problems))
|
|
49
|
+
continue;
|
|
50
|
+
checkValue(id, value);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/** Validate the per-role and cross-field sections that close the gate: `customRoles`, the
|
|
54
|
+
* `roles.<id>` entries, `review.afterMerge`'s role names, the per-role caps and quiet hours,
|
|
55
|
+
* `maxConcurrentPerProvider`, `claudeWindowReserve` and the `idleBackoff` cross-field pair,
|
|
56
|
+
* each appended to `problems` in the order the gate produced. The single call site in
|
|
57
|
+
* validateConfig is the point where these problems were appended, so the order across the
|
|
58
|
+
* whole pass is unchanged. */
|
|
59
|
+
export function validateRolesAndCrossField(r, problems, helpers) {
|
|
60
|
+
const customNames = validateCustomRoles(r, problems, helpers);
|
|
61
|
+
validateRoleEntries(r, problems, helpers, customNames);
|
|
62
|
+
validateReviewAfterMerge(r, problems, customNames);
|
|
63
|
+
validatePerRoleCostCaps(r, problems, customNames);
|
|
64
|
+
validatePerRoleQuietHours(r, problems, customNames);
|
|
65
|
+
validateMaxConcurrentPerProvider(r, problems);
|
|
66
|
+
validateClaudeWindowReserve(r, problems, helpers);
|
|
67
|
+
validateIdleBackoff(r, problems);
|
|
68
|
+
}
|
|
69
|
+
/** Collect the `customRoles` names and refuse this block's bad shapes, appending to
|
|
70
|
+
* `problems` and returning the set of accepted names for the later per-role sections. Names
|
|
71
|
+
* become worktree dirs and git refs (plans/user-defined-loops.md), so they are validated
|
|
72
|
+
* strictly — a colliding name would silently shadow a built-in's prompt (roleById wins in
|
|
73
|
+
* runPrompt), validation makes that impossible instead of subtle. The collected names feed
|
|
74
|
+
* the roles cross-check: an id under `roles` is valid when it is in the catalog OR listed as
|
|
75
|
+
* a customRoles name. */
|
|
76
|
+
function validateCustomRoles(r, problems, helpers) {
|
|
77
|
+
const { checkPromptLength } = helpers;
|
|
78
|
+
const customNames = new Set();
|
|
79
|
+
if ("customRoles" in r) {
|
|
80
|
+
const cl = r.customRoles;
|
|
81
|
+
if (!Array.isArray(cl)) {
|
|
82
|
+
problems.push(`customRoles must be an array of { name, task } entries (got ${show(cl)})`);
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
cl.forEach((entry, i) => {
|
|
86
|
+
const where = `customRoles[${i}]`;
|
|
87
|
+
if (!isJsonObject(entry)) {
|
|
88
|
+
problems.push(`${where} must be an object with keys name and task (got ${show(entry)})`);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
checkKnownKeys(entry, CUSTOM_ROLE_KEYS, where, problems);
|
|
92
|
+
const name = entry.name;
|
|
93
|
+
if (typeof name !== "string") {
|
|
94
|
+
problems.push(`${where}.name must be a string matching /^[a-z0-9][a-z0-9_-]{0,31}$/ (got ${show(name)})`);
|
|
95
|
+
}
|
|
96
|
+
else if (!CUSTOM_NAME_RE.test(name)) {
|
|
97
|
+
problems.push(`${where}.name "${name}" is not a valid role name: start with a lowercase letter or digit, then at most 31 more of [a-z0-9_-]`);
|
|
98
|
+
}
|
|
99
|
+
else if (name === TUMWATER_SOURCE) {
|
|
100
|
+
problems.push(`${where}.name "${name}" is reserved: events tumwater writes about itself carry that source, so a role's events would be indistinguishable from them — pick another name`);
|
|
101
|
+
}
|
|
102
|
+
else if (allRoleIds().includes(name)) {
|
|
103
|
+
problems.push(`${where}.name "${name}" collides with a built-in role id — pick another name`);
|
|
104
|
+
}
|
|
105
|
+
else if (baseRoleOf(name) !== name) {
|
|
106
|
+
problems.push(`${where}.name "${name}" would shadow the ${baseRoleOf(name)} role's instance ids — pick another name`);
|
|
107
|
+
}
|
|
108
|
+
else if (customNames.has(name)) {
|
|
109
|
+
problems.push(`${where}.name "${name}" is duplicated in customRoles — names must be unique`);
|
|
110
|
+
}
|
|
111
|
+
else {
|
|
112
|
+
customNames.add(name);
|
|
113
|
+
}
|
|
114
|
+
const task = entry.task;
|
|
115
|
+
// A whitespace-only task is as inert as an empty one — it rides into every one of
|
|
116
|
+
// this role's run prefills as blank text, so the role has nothing to do and runs
|
|
117
|
+
// no_change forever. Reject both shapes with the same message; the model-triple
|
|
118
|
+
// checkString rule uses the same trim-based emptiness test.
|
|
119
|
+
if (!isNonBlankString(task)) {
|
|
120
|
+
problems.push(`${where}.task must be a non-empty string (got ${show(task)})`);
|
|
121
|
+
}
|
|
122
|
+
else {
|
|
123
|
+
checkPromptLength(`${where}.task`, task, CUSTOM_TASK_MAX_CHARS);
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return customNames;
|
|
129
|
+
}
|
|
130
|
+
/** Validate the `roles.<id>` entry block: the object shape, `checkKnownRoleId`, the per-entry
|
|
131
|
+
* settings checks, and the INSTANCE_ROLES instances rule. */
|
|
132
|
+
function validateRoleEntries(r, problems, helpers, customNames) {
|
|
133
|
+
const { checkString, checkBoolean, checkNumber, checkPromptLength, checkModelTripleEntry } = helpers;
|
|
134
|
+
if ("roles" in r) {
|
|
135
|
+
const roles = r.roles;
|
|
136
|
+
if (!isJsonObject(roles)) {
|
|
137
|
+
problems.push(`roles must be an object mapping role ids to settings (got ${show(roles)})`);
|
|
138
|
+
}
|
|
139
|
+
else {
|
|
140
|
+
for (const [id, rc] of Object.entries(roles)) {
|
|
141
|
+
// An id outside the catalog and customRoles cannot work: runPrompt has no prompt for
|
|
142
|
+
// it and the role would error every run forever (checkKnownRoleId).
|
|
143
|
+
if (!checkKnownRoleId("roles", id, customNames, problems))
|
|
144
|
+
continue;
|
|
145
|
+
if (!isJsonObject(rc)) {
|
|
146
|
+
problems.push(`roles.${id} must be an object (got ${show(rc)})`);
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
const o = rc;
|
|
150
|
+
checkKnownKeys(o, ROLE_ENTRY_KEYS, `roles.${id}`, problems);
|
|
151
|
+
// Empty is a deliberate "no extra instructions". A non-empty value rides into every
|
|
152
|
+
// one of this role's run prefills, so it is capped like a custom role's task.
|
|
153
|
+
checkString(o, `roles.${id}.`, "instructions");
|
|
154
|
+
checkPromptLength(`roles.${id}.instructions`, o.instructions, ROLE_INSTRUCTIONS_MAX_CHARS);
|
|
155
|
+
checkModelTripleEntry(o, `roles.${id}.`, `roles.${id}`);
|
|
156
|
+
checkBoolean(o, `roles.${id}.`, "enabled");
|
|
157
|
+
checkNumber(o, `roles.${id}.`, "minRunIntervalSeconds", DURATION_NON_NEGATIVE);
|
|
158
|
+
// Only feature and bugfix may split (src/roles/runner-ids.ts INSTANCE_ROLES); an
|
|
159
|
+
// instances count under any other role would never spawn a runner, so reject it
|
|
160
|
+
// rather than let the operator believe it did something.
|
|
161
|
+
if ("instances" in o && !INSTANCE_ROLES.has(id)) {
|
|
162
|
+
problems.push(`roles.${id}.instances is only valid for ${[...INSTANCE_ROLES].join(" and ")} (got ${show(o.instances)})`);
|
|
163
|
+
}
|
|
164
|
+
else {
|
|
165
|
+
checkNumber(o, `roles.${id}.`, "instances", INSTANCE_COUNT);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
/** Validate `review.afterMerge`'s role names: a typo'd id would silently keep that role
|
|
172
|
+
* reviewed before merging; the director is refused because its changes always merge on a
|
|
173
|
+
* review first. */
|
|
174
|
+
function validateReviewAfterMerge(r, problems, customNames) {
|
|
175
|
+
if (isJsonObject(r.review) && Array.isArray(r.review.afterMerge)) {
|
|
176
|
+
r.review.afterMerge.forEach((id, i) => {
|
|
177
|
+
if (typeof id !== "string" || id.trim() === "")
|
|
178
|
+
return; // Already reported by checkStringArray.
|
|
179
|
+
if (id === DIRECTOR_ROLE)
|
|
180
|
+
problems.push(`review.afterMerge[${i}] must not be "${DIRECTOR_ROLE}" — its changes are always reviewed before merging`);
|
|
181
|
+
else
|
|
182
|
+
checkKnownRoleId("review.afterMerge", id, customNames, problems);
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
/** Validate the per-role daily cost caps (src/gates/role-cap-gates.ts): each key must name a
|
|
187
|
+
* known role — built-in or customRoles — because a typo'd id would silently no-op the cap,
|
|
188
|
+
* the exact silent-ignore class the `roles.<id>` check exists to prevent (the customNames set
|
|
189
|
+
* is fully collected by this point, so a custom role can be capped). Each value is a spend
|
|
190
|
+
* threshold with the NON_NEGATIVE_OR_DISABLED semantics: 0 disables that role's cap. */
|
|
191
|
+
function validatePerRoleCostCaps(r, problems, customNames) {
|
|
192
|
+
checkPerRoleMap(r, "maxDailyCostUsdPerRole", "USD caps", customNames, problems, (id) => {
|
|
193
|
+
// Same MAX_SAFE_INTEGER bound as maxDailyCostUsd's DOLLAR_CAP rule (BUGS.md
|
|
194
|
+
// 2026-10-02): a finite-but-unrepresentable per-role cap is an effectively
|
|
195
|
+
// uncapped budget for that role — DOLLAR_CAP is that rule, so the predicate
|
|
196
|
+
// and its wording have one home. checkPerRoleMap narrowed the map, so the cast
|
|
197
|
+
// is sound and checkNumberField sees the entry's value typed as before.
|
|
198
|
+
checkNumberField(problems, r.maxDailyCostUsdPerRole, "maxDailyCostUsdPerRole.", id, DOLLAR_CAP);
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
/** Validate the per-role quiet hours (src/scheduling/quiet-hours.ts): the same known-role gate
|
|
202
|
+
* as the caps — a typo'd role id would silently no-op the window. Each value must parse as a
|
|
203
|
+
* quiet-hours window (an empty string means off for that role, the same disablement the
|
|
204
|
+
* team-wide key takes), and the message names the key and the offending id/value verbatim. */
|
|
205
|
+
function validatePerRoleQuietHours(r, problems, customNames) {
|
|
206
|
+
checkPerRoleMap(r, "quietHoursPerRole", '"HH:MM-HH:MM" windows', customNames, problems, (id, value) => {
|
|
207
|
+
if (typeof value !== "string") {
|
|
208
|
+
problems.push(`quietHoursPerRole.${id} must be a "HH:MM-HH:MM" string like "23:00-07:00" (an empty string means off) (got ${show(value)})`);
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
if (value.trim() === "")
|
|
212
|
+
return; // empty = off for that role
|
|
213
|
+
const parsed = parseQuietHours(value, `quietHoursPerRole.${id}`);
|
|
214
|
+
if (!parsed.ok)
|
|
215
|
+
problems.push(parsed.error);
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
/** Validate per-provider concurrency caps (plans/provider-concurrency.md §1): a map from
|
|
219
|
+
* provider key to a positive integer. Unlike the per-role maps, an unknown provider name is
|
|
220
|
+
* NOT a validation error — providers live in pi's models.json, which validation does not
|
|
221
|
+
* read, and a fallback's provider may only come into use later (doctor warns in part 5/5).
|
|
222
|
+
* Each value must be a positive integer under a non-empty key. */
|
|
223
|
+
function validateMaxConcurrentPerProvider(r, problems) {
|
|
224
|
+
if (r.maxConcurrentPerProvider !== undefined) {
|
|
225
|
+
const map = r.maxConcurrentPerProvider;
|
|
226
|
+
if (!isJsonObject(map)) {
|
|
227
|
+
problems.push(`maxConcurrentPerProvider must be an object mapping provider names to concurrency caps (got ${show(map)})`);
|
|
228
|
+
}
|
|
229
|
+
else {
|
|
230
|
+
for (const [provider, value] of Object.entries(map)) {
|
|
231
|
+
if (provider.trim() === "") {
|
|
232
|
+
problems.push(`maxConcurrentPerProvider.<empty> — a provider key must not be empty`);
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
if (typeof value !== "number" || !Number.isFinite(value) || !POSITIVE_INTEGER.ok(value)) {
|
|
236
|
+
problems.push(`maxConcurrentPerProvider.${provider} must be ${POSITIVE_INTEGER.what} (got ${show(value)})`);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
/** Validate `claudeWindowReserve` (the subscription-window reserve: a map from window type to
|
|
243
|
+
* a fraction, 0 < f <= 1) and its mutually-exclusive spelling pair with
|
|
244
|
+
* `backends.claude.windowReserve`. */
|
|
245
|
+
function validateClaudeWindowReserve(r, problems, helpers) {
|
|
246
|
+
const { checkWindowReserve } = helpers;
|
|
247
|
+
// The subscription-window reserve: a map from window type to a fraction, 0 < f <= 1.
|
|
248
|
+
if (r.claudeWindowReserve !== undefined)
|
|
249
|
+
checkWindowReserve("claudeWindowReserve", r.claudeWindowReserve);
|
|
250
|
+
// The reserve's two spellings are mutually exclusive (plans/codex-backend.md part 2b/9): the
|
|
251
|
+
// nested key is the current one; `claudeWindowReserve` is kept as claude's older spelling. A
|
|
252
|
+
// file holding both is ambiguous about which drives the window, so it is rejected naming both.
|
|
253
|
+
if (r.claudeWindowReserve !== undefined &&
|
|
254
|
+
isJsonObject(r.backends) &&
|
|
255
|
+
isJsonObject(r.backends.claude) &&
|
|
256
|
+
r.backends.claude.windowReserve !== undefined)
|
|
257
|
+
problems.push("claudeWindowReserve and backends.claude.windowReserve set the same value — keep one (backends.claude.windowReserve is the current spelling; claudeWindowReserve is its old name)");
|
|
258
|
+
}
|
|
259
|
+
/** Validate the `idleBackoff` cross-field pair (judged on the merged config, where both sides
|
|
260
|
+
* are always present; on a raw file only when the file itself names both): scheduleBackoff
|
|
261
|
+
* clamps every idle wait — the first included — to min(initialSeconds, maxSeconds), so a
|
|
262
|
+
* smaller max silently discards the configured initial wait. Name both values so one edit
|
|
263
|
+
* fixes the pair. */
|
|
264
|
+
function validateIdleBackoff(r, problems) {
|
|
265
|
+
const backoff = r.idleBackoff;
|
|
266
|
+
if (isJsonObject(backoff) &&
|
|
267
|
+
typeof backoff.initialSeconds === "number" &&
|
|
268
|
+
typeof backoff.maxSeconds === "number" &&
|
|
269
|
+
backoff.maxSeconds < backoff.initialSeconds)
|
|
270
|
+
problems.push(`idleBackoff.maxSeconds (${show(backoff.maxSeconds)}) must be ≥ idleBackoff.initialSeconds (${show(backoff.initialSeconds)}) — every idle wait is clamped to the smaller, so the smaller max would silently shorten the configured first wait`);
|
|
271
|
+
}
|