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
|
@@ -5,1000 +5,32 @@
|
|
|
5
5
|
* as input; `tone` ranks an
|
|
6
6
|
* alert (red and amber ask for action, indigo asks a question, blue and gray inform), and
|
|
7
7
|
* `actions` are the dashboard's buttons (`act` names a page action — open a role, a view,
|
|
8
|
-
* the cap editor…).
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
17
|
-
import { fallbackTierEntries } from "../budget/budget.js";
|
|
18
|
-
import { fallbackDisplayPair } from "../config/config-views.js";
|
|
19
|
-
import { agree, pauseReasonSuffix, plural } from "../text/phrases.js";
|
|
20
|
-
import { missingSymbolNames } from "../verdict/fix-claim.js";
|
|
21
|
-
import { STALL_RE, duration } from "./run-progress-model.js";
|
|
22
|
-
import { formatGB, shortSha, usd, usdCap } from "../text/format.js";
|
|
23
|
-
import { DAY_MS, formatTimestamp, humanAgo, humanUntil } from "../text/datetime.js";
|
|
24
|
-
import { joinWithMore, squash } from "../text/text.js";
|
|
25
|
-
import { roleLabel } from "../roles/role-labels.js";
|
|
26
|
-
/** The alert detail head for a bounded entry list (open questions, refused entries): the
|
|
27
|
-
* first title stripped of its stamp, plus ` — and N more` when others await, so the two
|
|
28
|
-
* alert kinds cannot drift on the separator or the count. The caller appends its own
|
|
29
|
-
* action suffix (the refused alert's " · no role takes it…" clause). */
|
|
30
|
-
function entryListDetail(titles) {
|
|
31
|
-
const first = titles[0] === undefined ? "" : stripEntryStamp(titles[0]);
|
|
32
|
-
return titles.length > 1 ? `${first} — and ${titles.length - 1} more` : first;
|
|
33
|
-
}
|
|
34
|
-
/** Append the folded names of the entries past an alert's first one, after a ` · ` separator,
|
|
35
|
-
* when there are any, and the empty string otherwise — the shared idiom every amber
|
|
36
|
-
* "name the oldest, fold the rest" alert (stale-bugs, waiting-bugs, stale-prompts,
|
|
37
|
-
* stale-merges, stranded-plans, skipped-merges, backlog-headings) uses to compose its
|
|
38
|
-
* `detail`, so the separator, the empty case, and the default fold (entryListDetail;
|
|
39
|
-
* joinWithMore for the merge-queue and heading alerts) cannot drift. Callers pre-map each
|
|
40
|
-
* rest element to the label the first clause already named. */
|
|
41
|
-
function moreDetail(restLabels, fold = entryListDetail) {
|
|
42
|
-
return restLabels.length > 0 ? ` · ${fold(restLabels)}` : "";
|
|
43
|
-
}
|
|
44
|
-
/** The dashboard's "Open <role>" buttons for an alert about a set of roles: one button per
|
|
45
|
-
* role (capped to the first three so the banner does not overflow), each opening that role's
|
|
46
|
-
* pane. Shared by the failing, stuck, and no-image-target alerts, so the button shape and its
|
|
47
|
-
* cap cannot drift between them. */
|
|
48
|
-
function openRoleActions(roles) {
|
|
49
|
-
return roles.slice(0, 3).map((l) => ({ label: `Open ${roleLabel(l.role)}`, act: "role", arg: l.role }));
|
|
50
|
-
}
|
|
51
|
-
/** The dashboard's "See failures" button: opens the failures view. Shared by every alert
|
|
52
|
-
* whose remedy is inspecting the failing runs/checks (main-red, quarantine, flapped checks,
|
|
53
|
-
* post-merge churn and risk, regressed fixes, image-probe), so the button's label
|
|
54
|
-
* and action cannot drift between them. */
|
|
55
|
-
function seeFailures() {
|
|
56
|
-
return [{ label: "See failures", act: "view", arg: "failures" }];
|
|
57
|
-
}
|
|
58
|
-
/** Natural English list of the given names: “a and b” for one or two, “a, b, and c”
|
|
59
|
-
* beyond that. Shared by the role-row and held-id banners so the wording cannot drift. */
|
|
60
|
-
function andJoin(names) {
|
|
61
|
-
return names.length <= 2 ? names.join(" and ") : `${names.slice(0, -1).join(", ")}, and ${names[names.length - 1]}`;
|
|
62
|
-
}
|
|
63
|
-
function listRoles(roles) {
|
|
64
|
-
return andJoin(roles.map((l) => roleLabel(l.role)));
|
|
65
|
-
}
|
|
66
|
-
/** The `listRoles` rendering for a set of held role ids (status-data.ts's `capPaused` and
|
|
67
|
-
* `maintenanceQuota.held`, the scheduler's enabled-but-held sets rather than rendered role
|
|
68
|
-
* rows): names every held id through roleLabel even when a stale id matches no live role, so
|
|
69
|
-
* the role-cap and maintenance banners never drop a held role from their copy. */
|
|
70
|
-
function listRoleIds(ids) {
|
|
71
|
-
return andJoin(ids.map(roleLabel));
|
|
72
|
-
}
|
|
73
|
-
/** The stuck alert's per-role detail: the stall phrase the phase rendered, plus — when that
|
|
74
|
-
* phrase does not already carry one — the honest silence span from the live tail (stuckMs,
|
|
75
|
-
* ms since the role's last pi log write, capped to the run by progressOfRun). A
|
|
76
|
-
* "no agent output for <d>" phrase already carries its duration, so only a tool-call stall gets
|
|
77
|
-
* a " for <duration>" suffix, and only when the caller holds a live tail (stuckMs present). */
|
|
78
|
-
function stuckDetail(l) {
|
|
79
|
-
const cause = (STALL_RE.exec(l.phase)?.[0] ?? "").trim();
|
|
80
|
-
if (cause && !cause.startsWith("no agent output for") && l.stuckMs !== undefined) {
|
|
81
|
-
return `${roleLabel(l.role)}: ${cause} for ${duration(l.stuckMs)}`;
|
|
82
|
-
}
|
|
83
|
-
return `${roleLabel(l.role)}: ${cause}`;
|
|
84
|
-
}
|
|
85
|
-
/** ISO instants in server text (a restart cooldown's deadline) in local time. */
|
|
86
|
-
function localizeInstants(text) {
|
|
87
|
-
return text.replace(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z/g, (iso) => formatTimestamp(Date.parse(iso)));
|
|
88
|
-
}
|
|
89
|
-
/** The status/capacity and role-phase alerts, in emission order: a spent budget
|
|
90
|
-
* (chain/fallback/budget), per-role cap, disk, install, and main-red holds, roles
|
|
91
|
-
* failing run after run or stuck, and a stale running build. Extracted from
|
|
92
|
-
* teamAlerts (part 1/5 of the teamAlerts split) so the shared module stays small;
|
|
93
|
-
* the input names exactly the fields this run reads. The caller pushes the returned
|
|
94
|
-
* alerts at the position this run used to occupy, so the emitted key order is
|
|
95
|
-
* unchanged. */
|
|
96
|
-
function buildStatusAlerts(input) {
|
|
97
|
-
const { snap, roles, staleClaims } = input;
|
|
98
|
-
const out = [];
|
|
99
|
-
const b = snap.budget;
|
|
100
|
-
const onFallback = chainFragments(snap.chain);
|
|
101
|
-
if (onFallback) {
|
|
102
|
-
out.push({
|
|
103
|
-
key: "chain",
|
|
104
|
-
tone: "blue",
|
|
105
|
-
title: "Running on fallback",
|
|
106
|
-
detail: `running on fallback: ${onFallback}`,
|
|
107
|
-
actions: [],
|
|
108
|
-
});
|
|
109
|
-
}
|
|
110
|
-
if (!b.free && b.capUsd > 0 && b.spentUsd >= b.capUsd) {
|
|
111
|
-
// Tiered fallbacks (part 7b/8): the snapshot's tier map stands exactly when the
|
|
112
|
-
// tiers resolve to two or more distinct pairs, so list them and let the borrowed
|
|
113
|
-
// ones' `(from …)` suffixes mark the borrow; a tier-free snapshot keeps the
|
|
114
|
-
// single-pair sentence.
|
|
115
|
-
const fallbackDetail = b.tiers
|
|
116
|
-
? `${fallbackTierEntries(b.tiers).join(", ")} carry them at no cost until midnight.`
|
|
117
|
-
: `${fallbackDisplayPair(b.fallback, "The fallback")} carries them at no cost until midnight.`;
|
|
118
|
-
out.push(b.fallback
|
|
119
|
-
? {
|
|
120
|
-
key: "fallback",
|
|
121
|
-
tone: "blue",
|
|
122
|
-
title: "Today's budget is spent — roles switched to the over-cap model",
|
|
123
|
-
// The roles whose tier has no usable free pair (gate-polls.ts's budgetPausedRoles)
|
|
124
|
-
// don't move: they sit on `budget paused` rows while the banner says everyone
|
|
125
|
-
// switched. One sentence names both halves of the split so the spent-cap banner
|
|
126
|
-
// doesn't read as if every role kept working.
|
|
127
|
-
detail: snap.budgetPausedRoles.length > 0
|
|
128
|
-
? `${fallbackDetail} ${listRoleIds(snap.budgetPausedRoles)} ${agree(snap.budgetPausedRoles.length, "has", "have")} no free pair and ${agree(snap.budgetPausedRoles.length, "stays", "stay")} paused until local midnight.`
|
|
129
|
-
: fallbackDetail,
|
|
130
|
-
actions: [{ label: "Raise the cap", act: "budget" }],
|
|
131
|
-
}
|
|
132
|
-
: {
|
|
133
|
-
key: "budget",
|
|
134
|
-
tone: "red",
|
|
135
|
-
title: "Today's budget is spent, so the roles are paused",
|
|
136
|
-
detail: `Spent ${usd(b.spentUsd)} of the ${usdCap(b.capUsd)} cap. They resume at midnight, or as soon as you raise the cap.`,
|
|
137
|
-
actions: [{ label: "Raise the cap", act: "budget" }],
|
|
138
|
-
});
|
|
139
|
-
}
|
|
140
|
-
// A role over its maxDailyCostUsdPerRole cap (status-model.ts "waiting: daily cap spent") is
|
|
141
|
-
// a per-role hold — one role sits idle while the rest of the team keeps working — so the team
|
|
142
|
-
// banner names it the way the budget, disk, and quiet banners name theirs, instead of leaving
|
|
143
|
-
// an operator to read every row. An amber ask (it needs a raise to resume); clears on its own
|
|
144
|
-
// at local midnight when the spend window rolls over.
|
|
145
|
-
const capPausedRoles = roles.filter((l) => snap.capPaused.includes(l.role));
|
|
146
|
-
if (snap.capPaused.length > 0) {
|
|
147
|
-
out.push({
|
|
148
|
-
key: "role-cap",
|
|
149
|
-
tone: "amber",
|
|
150
|
-
title: `${listRoleIds(snap.capPaused)} ${agree(snap.capPaused.length, "is over its daily cap", "are over their daily caps")}`,
|
|
151
|
-
detail: `The daily cap (maxDailyCostUsdPerRole) holds ${listRoleIds(snap.capPaused)}: no new runs until local midnight. Raise the cap to resume ${agree(snap.capPaused.length, "it", "them")}.`,
|
|
152
|
-
actions: openRoleActions(capPausedRoles),
|
|
153
|
-
});
|
|
154
|
-
}
|
|
155
|
-
// The disk floor holding new work (plans/disk-floor.md, part 4/4): the header badge and the
|
|
156
|
-
// role rows say what is happening, but the banner names the threshold and the way out, so an
|
|
157
|
-
// operator hitting a full volume does not have to read events. An amber ask — the team is
|
|
158
|
-
// stopped and needs room, not merely informed. Only while the hold is on; below the reclaim
|
|
159
|
-
// threshold but not yet held is the badge's quieter business.
|
|
160
|
-
if (snap.disk?.held) {
|
|
161
|
-
out.push({
|
|
162
|
-
key: "disk",
|
|
163
|
-
tone: "amber",
|
|
164
|
-
title: `Waiting: low disk — the team is holding new work at ${formatGB(snap.disk.freeGB)} free`,
|
|
165
|
-
detail: `Free space is below the ${snap.disk.holdGB} GB floor. Build outputs are reclaimed first; new work starts again once space recovers.`,
|
|
166
|
-
actions: [],
|
|
167
|
-
});
|
|
168
|
-
}
|
|
169
|
-
// The root checkout's node_modules is behind package-lock.json (a failed post-merge re-sync):
|
|
170
|
-
// checks install into their own trees until it is fixed. Clears on drift, so a hand fix
|
|
171
|
-
// clears it too.
|
|
172
|
-
const drift = snap.rootInstallDrift ?? [];
|
|
173
|
-
if (drift.length) {
|
|
174
|
-
const names = joinWithMore(drift, 5);
|
|
175
|
-
out.push({
|
|
176
|
-
key: "install",
|
|
177
|
-
tone: "amber",
|
|
178
|
-
title: "The root install is behind package-lock.json",
|
|
179
|
-
detail: `${plural(drift.length, "direct dependency", "direct dependencies")} not installed as pinned (${names}). Run npm install in the project's root checkout; until then each check installs into its own tree, and \`tumwater doctor\` names the last re-sync failure.`,
|
|
180
|
-
actions: [],
|
|
181
|
-
});
|
|
182
|
-
}
|
|
183
|
-
// No deterministic build check is declared (detectBuildCheck returned null): the review
|
|
184
|
-
// gate's build pre-check, the red-main baseline and redeploy's green check have nothing to
|
|
185
|
-
// run, and the header badge renders nothing (mainCheckBadge needs a build_check event).
|
|
186
|
-
// The same state the doctor `check` row warns on, raised as a standing alert on the always-on
|
|
187
|
-
// surfaces. Independent of team state (running or stopped), like the install alert beside it.
|
|
188
|
-
if (snap.noDeclaredCheck) {
|
|
189
|
-
out.push({
|
|
190
|
-
key: "no-check",
|
|
191
|
-
tone: "amber",
|
|
192
|
-
title: "No project check is declared — the safety gates are off",
|
|
193
|
-
detail: "The review gate's build pre-check, the red-main baseline and redeploy's green check have nothing to run. Set \`check.command\` in tumwater.json (or add a test/typecheck/build npm script) so merges are verified.",
|
|
194
|
-
actions: [],
|
|
195
|
-
});
|
|
196
|
-
}
|
|
197
|
-
const red = roles.filter((l) => l.phase === "main's check is failing");
|
|
198
|
-
if (red.length) {
|
|
199
|
-
out.push({
|
|
200
|
-
key: "mainred",
|
|
201
|
-
tone: "red",
|
|
202
|
-
title: `main's check is failing — ${plural(red.length, "role is", "roles are")} blocked`,
|
|
203
|
-
detail: "Code roles stop authoring until main's check passes, and pick up on their own once it does.",
|
|
204
|
-
actions: seeFailures(),
|
|
205
|
-
});
|
|
206
|
-
}
|
|
207
|
-
// A role whose own failure streak is at or past the alarm threshold (onErrorStreak — an
|
|
208
|
-
// error streak at ERROR_STREAK_WARN, or a quiet-kill streak): the red "failing run after
|
|
209
|
-
// run" banner. Keyed on the streak, not on the rendered phase, so it stays up while the
|
|
210
|
-
// role's next run is in progress — where rolePhase reads "working"/"merge"/"reviewing" rather
|
|
211
|
-
// than "failing" — and clears only when the streak resets (BUGS.md 2026-10-14). The
|
|
212
|
-
// wording names what happened (a streak of failed or rejected runs), not a single repeated
|
|
213
|
-
// error, because a rejection streak is three different rejected changes.
|
|
214
|
-
const failing = roles.filter((l) => l.errorStreak);
|
|
215
|
-
if (failing.length) {
|
|
216
|
-
out.push({
|
|
217
|
-
key: "failing",
|
|
218
|
-
tone: "red",
|
|
219
|
-
title: `${listRoles(failing)} ${agree(failing.length, "is", "are")} failing run after run`,
|
|
220
|
-
detail: failing.some((l) => l.lastError)
|
|
221
|
-
? failing.map((l) => `${roleLabel(l.role)}: ${l.lastError || "see its transcript"}`).join(" · ")
|
|
222
|
-
: "The streak clears only on the next healthy run; the transcript names each failure.",
|
|
223
|
-
actions: openRoleActions(failing),
|
|
224
|
-
});
|
|
225
|
-
}
|
|
226
|
-
// A stall marker rides only a running label. The director is excluded from `inFlight` (it
|
|
227
|
-
// holds no maxConcurrent permit), so its own running phase is accepted too — otherwise a
|
|
228
|
-
// stalled director would go unalerted (BUGS.md 2026-10-06).
|
|
229
|
-
const stuck = roles.filter((l) => (l.inFlight || l.phase.startsWith("director working")) && STALL_RE.test(l.phase));
|
|
230
|
-
if (stuck.length) {
|
|
231
|
-
out.push({
|
|
232
|
-
key: "stuck",
|
|
233
|
-
tone: "amber",
|
|
234
|
-
title: `${listRoles(stuck)} ${agree(stuck.length, "looks", "look")} stuck`,
|
|
235
|
-
detail: stuck.map(stuckDetail).join(" · "),
|
|
236
|
-
actions: openRoleActions(stuck),
|
|
237
|
-
});
|
|
238
|
-
}
|
|
239
|
-
if (snap.build?.stale) {
|
|
240
|
-
const behind = plural(snap.build.aheadCommits ?? 0, "commit");
|
|
241
|
-
// The cooldown scopes the deferral to the redeploy, not the team (PLANS.md 2026-10-23): a
|
|
242
|
-
// restart held by the cooldown leaves the roles running on the stale build, so its alert
|
|
243
|
-
// reads blue "work continues" with the deadline, while a genuinely blocked restart (red main,
|
|
244
|
-
// a failed build, a boot refusal) stays the amber "restart blocked", and the momentary drain
|
|
245
|
-
// of an in-flight restart — the one stale state that actually holds new runs and merges —
|
|
246
|
-
// keeps the plain wording.
|
|
247
|
-
const deferred = snap.build.restartBlocked?.startsWith("cooldown until ");
|
|
248
|
-
out.push(deferred
|
|
249
|
-
? {
|
|
250
|
-
key: "build",
|
|
251
|
-
tone: "blue",
|
|
252
|
-
title: `Update available (${behind} behind) — restarts after cooldown; work continues`,
|
|
253
|
-
detail: `Work continues on the running build; the restart lands when the cooldown lapses or you force it (${localizeInstants(snap.build.restartBlocked ?? "")}).`,
|
|
254
|
-
actions: [],
|
|
255
|
-
}
|
|
256
|
-
: snap.build.restartBlocked
|
|
257
|
-
? {
|
|
258
|
-
key: "build",
|
|
259
|
-
tone: "amber",
|
|
260
|
-
title: `Update available (${behind} behind) — restart blocked`,
|
|
261
|
-
detail: localizeInstants(snap.build.restartBlocked),
|
|
262
|
-
actions: [],
|
|
263
|
-
}
|
|
264
|
-
: {
|
|
265
|
-
key: "build",
|
|
266
|
-
tone: "blue",
|
|
267
|
-
title: `Update available (${behind} behind)${snap.build.restartPending ? " — restarts when idle" : ""}`,
|
|
268
|
-
detail: snap.build.restartPending ? "The team restarts onto it once running work finishes." : "Restart tumwater start to pick it up.",
|
|
269
|
-
actions: [],
|
|
270
|
-
});
|
|
271
|
-
}
|
|
272
|
-
// A work-instance claim the operator should act on without running `tumwater doctor`
|
|
273
|
-
// (src/scheduling/claim-entries.ts, the doctor's `parallel instances` row): a stopped
|
|
274
|
-
// instance holding an entry that left its role's section, or idle past the scheduler's
|
|
275
|
-
// release window, is an amber ask. One detail line per claim, worded like the doctor row so
|
|
276
|
-
// the two cannot drift; actions open the first three claimed roles. Nothing when empty.
|
|
277
|
-
if (staleClaims.length > 0) {
|
|
278
|
-
out.push({
|
|
279
|
-
key: "stale-claims",
|
|
280
|
-
tone: "amber",
|
|
281
|
-
title: `${plural(staleClaims.length, "work-instance claim is", "work-instance claims are")} stale`,
|
|
282
|
-
detail: staleClaims.map(staleClaimDetail).join("; "),
|
|
283
|
-
actions: openRoleActions(staleClaims.map((c) => ({ role: c.id }))),
|
|
284
|
-
});
|
|
285
|
-
}
|
|
286
|
-
return out;
|
|
287
|
-
}
|
|
288
|
-
/** The backlog alerts, in emission order: open questions, refused entries, operator-held
|
|
289
|
-
* entries, stalled entries, a broken plan-dependency clause, stranded plans, duplicated
|
|
290
|
-
* backlog headings, finished history still in the live backlog files, and Fixed records with a
|
|
291
|
-
* bad Validation gap trace. Extracted from
|
|
292
|
-
* teamAlerts (part 2/5 of the teamAlerts split) so the shared module stays small; the input
|
|
293
|
-
* names exactly the fields this run reads. The caller pushes the returned alerts at the
|
|
294
|
-
* position this run used to occupy, so the emitted key order is unchanged. */
|
|
295
|
-
function buildBacklogAlerts(input) {
|
|
296
|
-
const out = [];
|
|
297
|
-
const { questions, staleQuestions, refused, operatorHeld, stalledEntries, planDependency, strandedPlans, duplicatedHeadings, liveFinished, validationGaps, fixClaims, } = input;
|
|
298
|
-
if (questions.length) {
|
|
299
|
-
out.push({
|
|
300
|
-
key: "questions",
|
|
301
|
-
tone: "indigo",
|
|
302
|
-
title: `${plural(questions.length, "question needs", "questions need")} your answer`,
|
|
303
|
-
detail: entryListDetail(questions),
|
|
304
|
-
actions: [{ label: "Answer", act: "questions" }],
|
|
305
|
-
});
|
|
306
|
-
}
|
|
307
|
-
// A stale question (doctor's STALE_QUESTION_DAYS rule, src/doctor/doctor-backlog-bugs.ts
|
|
308
|
-
// checkStaleQuestions) has waited a week without an answer — a role is stuck behind a decision,
|
|
309
|
-
// the exact class every other backlog row raises as an amber alert — so it asks (amber) the way
|
|
310
|
-
// the stale-bug alert does, naming the oldest question, its age, and the `tumwater questions
|
|
311
|
-
// answer` fix. Its callers pass it oldest-first (status-payload.ts staleOpenQuestions sorts
|
|
312
|
-
// most-days-waited first), so the first element is the most-stale question and is named with its
|
|
313
|
-
// position and days; the rest are folded through entryListDetail like the questions and refused
|
|
314
|
-
// alerts, so the wording and counts cannot drift. The `questions` action opens the Questions tab,
|
|
315
|
-
// so the alert carries no row address.
|
|
316
|
-
if (staleQuestions.length) {
|
|
317
|
-
const first = staleQuestions[0];
|
|
318
|
-
const rest = staleQuestions.slice(1);
|
|
319
|
-
out.push({
|
|
320
|
-
key: "stale-questions", // vocabulary-ok: the stale-questions amber alert's key
|
|
321
|
-
tone: "amber",
|
|
322
|
-
title: `${plural(staleQuestions.length, "open question waited", "open questions waited")} 7+ days for an answer`,
|
|
323
|
-
detail: `#${first.position} "${first.title}" has waited ${first.days}d for an answer` +
|
|
324
|
-
moreDetail(rest.map((q) => q.title)) +
|
|
325
|
-
` · answer it with \`tumwater questions answer ${first.position} "<decision>"\`, or close it in QUESTIONS.md`,
|
|
326
|
-
actions: [{ label: "Answer", act: "questions" }],
|
|
327
|
-
});
|
|
328
|
-
}
|
|
329
|
-
// Refused entries hold until a human decides; amber because the operator has to act.
|
|
330
|
-
if (refused.length) {
|
|
331
|
-
const first = refused[0];
|
|
332
|
-
out.push({
|
|
333
|
-
key: "refused",
|
|
334
|
-
tone: "amber",
|
|
335
|
-
title: `${plural(refused.length, "refused entry waits", "refused entries wait")} on your decision`,
|
|
336
|
-
detail: `Refused ${entryKindLabel(first.file).toLowerCase()}: ${entryListDetail(refused.map((r) => r.title))} · no role takes it until you release it (tumwater plan|bug --release <n>, or the entry's drawer) or cancel it`,
|
|
337
|
-
// The action lands the operator on the named entry: open the backlog card on the
|
|
338
|
-
// file's tab and open that row (the dashboard's backlog arm reads `file:index`).
|
|
339
|
-
actions: [{ label: "Open backlog", act: "backlog", arg: `${first.file}:${first.index}` }],
|
|
340
|
-
});
|
|
341
|
-
}
|
|
342
|
-
// A Needs-review/Needs-replan entry whose held count reached the operator threshold parks
|
|
343
|
-
// until a human releases it (tumwater plan|bug --release, part 2/3); amber because only the
|
|
344
|
-
// operator can clear it, right after the refused alert, naming the entry like the refused
|
|
345
|
-
// alert does. Unlike refused, a held-for-operator entry is still in the normal review
|
|
346
|
-
// cycle below the threshold — only the at-past-threshold list reaches here (operatorHeldEntries).
|
|
347
|
-
if (operatorHeld.length) {
|
|
348
|
-
const first = operatorHeld[0];
|
|
349
|
-
out.push({
|
|
350
|
-
key: "operator-held", // vocabulary-ok: the operator-held amber alert's key
|
|
351
|
-
tone: "amber",
|
|
352
|
-
title: `${plural(operatorHeld.length, "backlog entry is held", "backlog entries are held")} for you to release`,
|
|
353
|
-
detail: operatorHeld.some((r) => r.file === "bugs") && operatorHeld.some((r) => r.file === "plans")
|
|
354
|
-
? operatorHeld.map((r) => `${entryKindLabel(r.file)} ${stripEntryStamp(r.title)} (tumwater ${entryKindLabel(r.file).toLowerCase()} --release)`).join("; ")
|
|
355
|
-
: `${entryKindLabel(first.file)} ${entryListDetail(operatorHeld.map((r) => r.title))} — release it with tumwater ${entryKindLabel(first.file).toLowerCase()} --release`,
|
|
356
|
-
// The action lands the operator on the named entry: open the backlog card on the
|
|
357
|
-
// file's tab and open that row (the dashboard's backlog arm reads `file:index`).
|
|
358
|
-
actions: [{ label: "Open backlog", act: "backlog", arg: `${first.file}:${first.index}` }],
|
|
359
|
-
});
|
|
360
|
-
}
|
|
361
|
-
// An entry parked by a Needs-review/Needs-replan note (the doctor's `stalled entries` row,
|
|
362
|
-
// behind the held threshold) sits until the plan role splits or replans it — amber because
|
|
363
|
-
// the plan role is the only one that can clear it, right after the operator-held alert,
|
|
364
|
-
// naming the first such entry in file order, its file, its note kind, and the fix. Same
|
|
365
|
-
// `entryHold` classification as `operatorHeldEntries` and `checkStalledEntries`, so the
|
|
366
|
-
// always-on alert and the doctor cannot disagree on which holds are role-resolvable. A
|
|
367
|
-
// held-for-operator entry is excluded here.
|
|
368
|
-
if (stalledEntries.length) {
|
|
369
|
-
const first = stalledEntries[0];
|
|
370
|
-
out.push({
|
|
371
|
-
key: "stalled-entries",
|
|
372
|
-
tone: "amber",
|
|
373
|
-
title: `${plural(stalledEntries.length, "backlog entry is stalled", "backlog entries are stalled")} in review`,
|
|
374
|
-
detail: `${entryKindLabel(first.file)} ${entryListDetail(stalledEntries.map((p) => p.title))} — ${first.hold === "needs-replan" ? "a Needs replan note parks it" : "a Needs review note parks it"}; the plan role splits or replans the entry, so one that keeps sitting here means the plan role is not running or keeps deferring`,
|
|
375
|
-
// The action lands the operator on the named entry: open the backlog card on the
|
|
376
|
-
// file's tab and open that row (the dashboard's backlog arm reads `file:index`).
|
|
377
|
-
actions: [{ label: "Open backlog", act: "backlog", arg: `${first.file}:${first.index}` }],
|
|
378
|
-
});
|
|
379
|
-
}
|
|
380
|
-
// A broken plan-dependency clause (the doctor's `plan dependencies` rule,
|
|
381
|
-
// src/doctor/doctor-backlog-plans.ts planDependencyProblem, via status-payload.ts's
|
|
382
|
-
// planDependencyAlert) is invisible in the backlog listings — the claim allocator keeps
|
|
383
|
-
// acting on the misread clause — so it asks (amber) like the stale-bug alert, named by type
|
|
384
|
-
// and carrying the doctor's exact message. One alert at most (the problem set is the single
|
|
385
|
-
// most serious rule), and the fix is editing PLANS.md's heading, so there are no actions.
|
|
386
|
-
if (planDependency !== null) {
|
|
387
|
-
const titles = {
|
|
388
|
-
cycle: "Plan dependency cycle blocks a whole series",
|
|
389
|
-
dangling: "A plan requires a part no heading carries",
|
|
390
|
-
unparsed: "A plan's requires clause names no readable part",
|
|
391
|
-
misworded: "A plan's dependency wording holds nothing",
|
|
392
|
-
};
|
|
393
|
-
out.push({
|
|
394
|
-
key: "plan-dependencies",
|
|
395
|
-
tone: "amber",
|
|
396
|
-
title: titles[planDependency.kind],
|
|
397
|
-
detail: planDependency.message,
|
|
398
|
-
// The fix is editing PLANS.md, and the message names the heading and ref.
|
|
399
|
-
actions: [],
|
|
400
|
-
});
|
|
401
|
-
}
|
|
402
|
-
// A plan heading filed under the wrong ## section (the doctor's `plans stuck in Planned`
|
|
403
|
-
// rule, checkStrandedPlans, src/doctor/doctor-backlog-plans.ts, via the shared
|
|
404
|
-
// strandedPlanEntriesFor) sits invisible to every reader that scans the right section: a
|
|
405
|
-
// `(planned …)` heading under ## Done never enters a Planned reader, and a done-dated heading
|
|
406
|
-
// still under ## Planned invites a second implementation. The clean role repairs it only when
|
|
407
|
-
// it happens to run and the doctor warns only on request, so it asks (amber) the way the
|
|
408
|
-
// plan-dependency alert does, naming the first stranded heading, its file, its section, and
|
|
409
|
-
// the section it belongs under; the rest are folded through joinWithMore.
|
|
410
|
-
if (strandedPlans.length) {
|
|
411
|
-
const first = strandedPlans[0];
|
|
412
|
-
const rest = strandedPlans.slice(1);
|
|
413
|
-
const more = moreDetail(rest.map((e) => `"${e.title}" in ${e.file ?? "PLANS.md"} ## ${e.section}`), (labels) => joinWithMore(labels, 3));
|
|
414
|
-
out.push({
|
|
415
|
-
key: "stranded-plans",
|
|
416
|
-
tone: "amber",
|
|
417
|
-
title: `${strandedPlans.length === 1 ? "A plan sits" : "Plans sit"} under the wrong backlog section`,
|
|
418
|
-
detail: `${first.file ?? "PLANS.md"} has a plan stranded under ## ${first.section}: "${first.title}"${more} — move it under ## ${first.section === "Done" ? "Planned" : "Done"}${first.file === undefined || first.file === "PLANS.md" ? "" : " in PLANS.md"} (the clean role repairs these)`,
|
|
419
|
-
// The fix is editing a backlog file, and a PLANS-DONE.md entry has no PLANS.md drawer index.
|
|
420
|
-
actions: [],
|
|
421
|
-
});
|
|
422
|
-
}
|
|
423
|
-
// A duplicated `## ` section heading already on main (the doctor's `backlog headings` row,
|
|
424
|
-
// checkBacklogHeadings, src/doctor/doctor-backlog-plans.ts, via the shared
|
|
425
|
-
// duplicatedBacklogHeadings) sits until a deliberate removal edit — the merge gate blocks
|
|
426
|
-
// only a merge that ADDS another copy, so a duplicate already on main persists and hides
|
|
427
|
-
// every later entry under its name from the `## ` section readers. It asks (amber) the way
|
|
428
|
-
// the stranded-plan alert does, naming the first file-heading pair and folding the rest
|
|
429
|
-
// through joinWithMore; the fix is editing a backlog file, so there are no actions.
|
|
430
|
-
if (duplicatedHeadings.length) {
|
|
431
|
-
const first = duplicatedHeadings[0];
|
|
432
|
-
const rest = duplicatedHeadings.slice(1);
|
|
433
|
-
const more = moreDetail(rest.map((d) => `"## ${d.heading}" in ${d.file}`), (labels) => joinWithMore(labels, 3));
|
|
434
|
-
out.push({
|
|
435
|
-
key: "backlog-headings",
|
|
436
|
-
tone: "amber",
|
|
437
|
-
title: plural(duplicatedHeadings.length, "duplicated backlog heading", "duplicated backlog headings"),
|
|
438
|
-
detail: `${first.file} "## ${first.heading}" appears more than once${more} — readers take the first section of a name, so later entries are invisible; remove the duplicate in a deliberate edit`,
|
|
439
|
-
actions: [],
|
|
440
|
-
});
|
|
441
|
-
}
|
|
442
|
-
// Finished history still in the live backlog files (the doctor's `backlog archive` rule,
|
|
443
|
-
// checkLiveFinished, src/doctor/doctor-backlog-plans.ts, via the shared liveFinishedEntriesFor)
|
|
444
|
-
// is the one backlog-hygiene rule the doctor warned but no always-on alert surfaced — every
|
|
445
|
-
// other rule (stranded plans, duplicated headings, validation gaps, plan dependencies, stale
|
|
446
|
-
// bugs and questions) has one, so an operator watching `tumwater status` or the dashboard never
|
|
447
|
-
// sees the growing finished sections until they run `tumwater doctor`. It asks (amber) the way
|
|
448
|
-
// the duplicated-heading alert does, naming each live file, its finished section, its entry
|
|
449
|
-
// count, and its size in KB; the fix is the `tumwater archive` command, so there are no actions.
|
|
450
|
-
if (liveFinished.length) {
|
|
451
|
-
out.push({
|
|
452
|
-
key: "backlog-archive",
|
|
453
|
-
tone: "amber",
|
|
454
|
-
title: `${plural(liveFinished.length, "live backlog file still holds", "live backlog files still hold")} finished history`,
|
|
455
|
-
detail: liveFinished.map((l) => `${l.file} ## ${l.section} holds ${plural(l.entries, "finished entry", "finished entries")} (${Math.round(l.bytes / 1024)} KB)`).join("; ") +
|
|
456
|
-
" — every run that reads the file pays for them; `tumwater archive` moves them to the archive files",
|
|
457
|
-
// The fix is a command, not a drawer row.
|
|
458
|
-
actions: [],
|
|
459
|
-
});
|
|
460
|
-
}
|
|
461
|
-
// A Fixed record with a bad Validation gap trace (the doctor's `validation gaps` check,
|
|
462
|
-
// src/doctor/doctor-backlog-plans.ts checkValidationGaps) is the always-on twin of that
|
|
463
|
-
// doctor row: a newest Fixed record that omits its **Validation gap:** line, names a tag
|
|
464
|
-
// outside the closed vocabulary, or declares `unclear-invariant` without pinning a
|
|
465
|
-
// docs/invariants.md section is invisible or misleading to every closed-vocabulary consumer,
|
|
466
|
-
// so it asks (amber) the way the stranded-plan alert does, naming the first offending record
|
|
467
|
-
// and the fix. It is fed from the same scan (validationGapProblems over validationGapRecords)
|
|
468
|
-
// the doctor's check uses, so the alert and the doctor cannot drift on the problem set, and
|
|
469
|
-
// that scan runs no sourceHaystack walk, so it adds only the same two backlog-file reads the
|
|
470
|
-
// doctor's check already makes to the poll path. The fix is editing BUGS.md, so it has no
|
|
471
|
-
// buttons.
|
|
472
|
-
if (validationGaps.length) {
|
|
473
|
-
const first = validationGaps[0];
|
|
474
|
-
const rest = validationGaps.slice(1);
|
|
475
|
-
const reason = first.kind === "missing"
|
|
476
|
-
? "has no **Validation gap:** line"
|
|
477
|
-
: first.kind === "unrecognized"
|
|
478
|
-
? `names unrecognized Validation gap tag "${first.tag}"`
|
|
479
|
-
: "is an unclear-invariant fix but names no docs/invariants.md section";
|
|
480
|
-
const more = rest.length ? ` · ${joinWithMore(rest.map((v) => `"${v.title}"`), 3)}` : "";
|
|
481
|
-
out.push({
|
|
482
|
-
key: "validation-gaps",
|
|
483
|
-
tone: "amber",
|
|
484
|
-
title: plural(validationGaps.length, "Fixed record has a validation-gap problem", "Fixed records have validation-gap problems"),
|
|
485
|
-
detail: `BUGS.md "${first.title}" ${reason}${more} — every Fixed entry writes exactly one known **Validation gap:** tag (write \`none\` rather than omitting it), and an unclear-invariant fix pins its reconstructed guarantee in docs/invariants.md`,
|
|
486
|
-
// The fix is editing BUGS.md.
|
|
487
|
-
actions: [],
|
|
488
|
-
});
|
|
489
|
-
}
|
|
490
|
-
// A newest Fixed record whose Fix paragraph names only code absent from the tree (the
|
|
491
|
-
// doctor's `claimed bugs` check, src/doctor/doctor-backlog-plans.ts checkFixClaims) is the
|
|
492
|
-
// always-on twin of that doctor row: a fix claim that names no live symbol is invisible to
|
|
493
|
-
// every consumer that reads BUGS.md (BUGS-FIXED.md's long fight over an already-fixed entry),
|
|
494
|
-
// so it asks (amber) the way the validation-gaps alert does, naming the first record and the
|
|
495
|
-
// missing symbols. It is fed from the same scan (fixClaimProblems over fixClaimRecords) the
|
|
496
|
-
// doctor's check uses, so the alert and the doctor cannot drift on the problem set, and that
|
|
497
|
-
// scan reads the tree only when a file changed (cachedSourceHaystack), so it adds no re-read
|
|
498
|
-
// to the 1 Hz poll path. The fix is editing BUGS.md, so it has no buttons.
|
|
499
|
-
if (fixClaims.length) {
|
|
500
|
-
const first = fixClaims[0];
|
|
501
|
-
const rest = fixClaims.slice(1);
|
|
502
|
-
const more = rest.length ? ` · ${joinWithMore(rest.map((p) => `"${p.heading}"`), 3)}` : "";
|
|
503
|
-
out.push({
|
|
504
|
-
key: "false-fixes",
|
|
505
|
-
tone: "amber",
|
|
506
|
-
title: plural(fixClaims.length, "Fixed record names code that is not on this tree", "Fixed records name code that is not on this tree"),
|
|
507
|
-
detail: `BUGS.md records "${first.heading}" as Fixed, but none of the symbols its Fix paragraph names exist on this tree: ${missingSymbolNames(first.missing)}${more} — land the fix or keep the bug Open / refresh a stale record`,
|
|
508
|
-
// The fix is editing BUGS.md.
|
|
509
|
-
actions: [],
|
|
510
|
-
});
|
|
511
|
-
}
|
|
512
|
-
return out;
|
|
513
|
-
}
|
|
514
|
-
/** The backlog-feed alerts, in emission order: stale open bugs, a failed notify page, waiting
|
|
515
|
-
* bugs, stranded inbox prompts, a stalled merge queue, skipped merge-queue files, and merges
|
|
516
|
-
* refused by an uncommitted primary checkout. Extracted from teamAlerts (part 3/5 of the
|
|
517
|
-
* teamAlerts split) so the shared module stays small; the input names exactly the fields this
|
|
518
|
-
* run reads. The caller pushes the returned alerts at the position this run used to occupy,
|
|
519
|
-
* so the emitted key order is unchanged. */
|
|
520
|
-
function buildBacklogFeedAlerts(input) {
|
|
521
|
-
const out = [];
|
|
522
|
-
const { staleBugs, notifyFailure, staleWaitingBugs, stalePrompts, staleMerges, skippedMergeFiles, primaryOverwrite, now, } = input;
|
|
523
|
-
// A stale bug (doctor's STALE_BUG_DAYS rule, src/doctor/doctor-backlog-bugs.ts) sits when no
|
|
524
|
-
// role fixes it — the bugfix role is disabled or failing — so it asks (amber) the way the
|
|
525
|
-
// refused alert does, naming the oldest, its age, and the doctor's retract advice. Its
|
|
526
|
-
// callers pass it oldest-first (status-payload.ts staleOpenBugs sorts most-days-waited first),
|
|
527
|
-
// so the first element is the most-stale bug and is named with its position and age; the rest
|
|
528
|
-
// are folded through entryListDetail like the questions and refused alerts, so the wording and
|
|
529
|
-
// counts cannot drift.
|
|
530
|
-
if (staleBugs.length) {
|
|
531
|
-
const first = staleBugs[0];
|
|
532
|
-
const rest = staleBugs.slice(1);
|
|
533
|
-
out.push({
|
|
534
|
-
key: "stale-bugs",
|
|
535
|
-
tone: "amber",
|
|
536
|
-
title: `${plural(staleBugs.length, "open bug waited", "open bugs waited")} 3+ days for a fix`,
|
|
537
|
-
detail: `#${first.position} "${first.title}" has waited ${first.days}d for a fix` +
|
|
538
|
-
moreDetail(rest.map((r) => r.title)) +
|
|
539
|
-
` · check that the bugfix role is enabled and not failing, or retract it with \`tumwater bug --cancel ${first.position}\``,
|
|
540
|
-
// The action lands the operator on the first stale bug: the dashboard's backlog arm reads
|
|
541
|
-
// `bugs:index` with a 0-based row, matching the drawer address.
|
|
542
|
-
actions: [{ label: "Open backlog", act: "backlog", arg: `bugs:${first.index}` }],
|
|
543
|
-
});
|
|
544
|
-
}
|
|
545
|
-
// A failed notify page (doctor's NOTIFY_CHECK_WINDOW_MS rule, src/doctor/doctor-notify.ts
|
|
546
|
-
// checkNotify) leaves the team unsupervised — a dead webhook must not page the operator when
|
|
547
|
-
// something else goes wrong — so a page that failed within the last day asks (amber) the way
|
|
548
|
-
// the stale-bug alert does, naming the count, the age of the latest, its squashed message,
|
|
549
|
-
// and the `tumwater notify --test` fix. Its callers feed it the latest failure's message via
|
|
550
|
-
// status-payload.ts's notifyFailures (the same isNotifyFailure filter and window), so the
|
|
551
|
-
// alert and the doctor cannot drift.
|
|
552
|
-
if (notifyFailure) {
|
|
553
|
-
out.push({
|
|
554
|
-
key: "notify",
|
|
555
|
-
tone: "amber",
|
|
556
|
-
title: `${plural(notifyFailure.count, "failed notify page", "failed notify pages")} in the last day`,
|
|
557
|
-
detail: `the notify hook could not reach you ${humanAgo(notifyFailure.latestTs, now)}: ${squash(notifyFailure.message, 160)} — check it with \`tumwater notify --test\``,
|
|
558
|
-
actions: [],
|
|
559
|
-
});
|
|
560
|
-
}
|
|
561
|
-
// A waiting bug (doctor's STALE_WAIT_DAYS rule, src/doctor/doctor-backlog-bugs.ts
|
|
562
|
-
// checkWaitingBugs) whose flake has not come back parks indefinitely, so a hold that has
|
|
563
|
-
// outlived a week asks (amber) the way the stale-bug alert does, naming the oldest hold, its
|
|
564
|
-
// age, and the release-or-keep advice. Its callers pass it most-days-first (status-payload.ts
|
|
565
|
-
// staleWaitingBugs sorts by days descending, ties in file order), so the first element is the
|
|
566
|
-
// longest-held bug and is named with its position and days; the rest are folded through
|
|
567
|
-
// entryListDetail like the questions and refused alerts, so the wording and counts cannot
|
|
568
|
-
// drift.
|
|
569
|
-
if (staleWaitingBugs.length) {
|
|
570
|
-
const first = staleWaitingBugs[0];
|
|
571
|
-
const rest = staleWaitingBugs.slice(1);
|
|
572
|
-
out.push({
|
|
573
|
-
key: "waiting-bugs", // vocabulary-ok: the waiting-bugs amber alert's key
|
|
574
|
-
tone: "amber",
|
|
575
|
-
title: `${plural(staleWaitingBugs.length, "waiting bug held", "waiting bugs held")} 7+ days`,
|
|
576
|
-
detail: `#${first.position} "${first.title}" has waited ${first.days}d for its next failure` +
|
|
577
|
-
moreDetail(rest.map((r) => `#${r.position} "${r.title}"`)) +
|
|
578
|
-
` · the hold has outlived a week and this surface omits the doctor's recurrence scan — \`tumwater doctor\` reports whether the flake came back; release it with \`tumwater bug --release ${first.position}\`, or keep the hold if the failure is still expected`,
|
|
579
|
-
// The action lands the operator on the first stale waiting bug: the dashboard's backlog arm
|
|
580
|
-
// reads `bugs:index` with a 0-based row, matching the drawer address.
|
|
581
|
-
actions: [{ label: "Open backlog", act: "backlog", arg: `bugs:${first.index}` }],
|
|
582
|
-
});
|
|
583
|
-
}
|
|
584
|
-
// A stranded inbox prompt (doctor's STALE_PROMPT_MS rule, src/doctor/doctor-backlog.ts) sits
|
|
585
|
-
// when a role is paused, disabled, or failing and never reaches the run that would dequeue the
|
|
586
|
-
// prompt, so it asks (amber) the way the stale-bug alert does, naming the oldest prompt, its
|
|
587
|
-
// wait, and the deliver-or-drop advice. Its callers feed it oldest-first (status-payload.ts
|
|
588
|
-
// stalePrompts sorts by queuedAtMs ascending), so the first element is the most-stale prompt
|
|
589
|
-
// and is named with its role, position and wait; the rest are folded through entryListDetail
|
|
590
|
-
// like the questions and refused alerts, so the wording and counts cannot drift.
|
|
591
|
-
if (stalePrompts.length) {
|
|
592
|
-
const first = stalePrompts[0];
|
|
593
|
-
const rest = stalePrompts.slice(1);
|
|
594
|
-
out.push({
|
|
595
|
-
key: "stale-prompts",
|
|
596
|
-
tone: "amber",
|
|
597
|
-
title: `${plural(stalePrompts.length, "queued prompt waited", "queued prompts waited")} 1+ day for its role`,
|
|
598
|
-
detail: `inbox prompt for ${roleLabel(first.role)} #${first.position} has waited ${duration(first.waitedMs)} — the role is not dequeuing it (paused, disabled, or failing); resume or enable it, or drop the prompt with \`tumwater prompt --cancel ${first.position} --role ${first.role}\`` +
|
|
599
|
-
moreDetail(rest.map((p) => `${roleLabel(p.role)} #${p.position}`)),
|
|
600
|
-
// The action lands the operator on the Inbox tab (the dashboard's backlog arm reads `act:
|
|
601
|
-
// "queued"` as the Inbox view), where the stranded prompt is cancellable.
|
|
602
|
-
actions: [{ label: "Open inbox", act: "queued" }],
|
|
603
|
-
});
|
|
604
|
-
}
|
|
605
|
-
// A stalled merge queue (the doctor's MERGE_QUEUE_STALE_MS rule,
|
|
606
|
-
// src/doctor/doctor-backlog.ts, set by the stale-merges alert) sits when a queued change
|
|
607
|
-
// never starts — neither verified nor flagged — so a drain that has stopped reads healthy
|
|
608
|
-
// except for this row. Its callers feed it oldest-first (status-payload.ts staleMerges reads
|
|
609
|
-
// queuedMerges in queue-file order), so the first element is the longest-waiting merge and is
|
|
610
|
-
// named with its role, summary and wait; the rest are folded through joinWithMore like the
|
|
611
|
-
// actions and no-image alerts, so the wording and counts cannot drift.
|
|
612
|
-
if (staleMerges.length) {
|
|
613
|
-
const first = staleMerges[0];
|
|
614
|
-
const rest = staleMerges.slice(1);
|
|
615
|
-
out.push({
|
|
616
|
-
key: "stale-merges",
|
|
617
|
-
tone: "amber",
|
|
618
|
-
title: `${plural(staleMerges.length, "queued merge waited", "queued merges waited")} 6+ hours to start`,
|
|
619
|
-
detail: `${roleLabel(first.role)} — "${first.summary}" — has waited ${duration(first.waitedMs)} to start` +
|
|
620
|
-
moreDetail(rest.map((m) => roleLabel(m.role)), (labels) => joinWithMore(labels, 3)) +
|
|
621
|
-
` — the merge queue has stopped moving; run \`tumwater queue\` to see it, or drop the entry with \`tumwater queue --cancel <n>\``,
|
|
622
|
-
// The header's `merge queue: N` badge and the Pending view already carry the drill-in.
|
|
623
|
-
actions: [],
|
|
624
|
-
});
|
|
625
|
-
}
|
|
626
|
-
// A merge-queue file `readMergeQueue` skips (torn, foreign, or vanished mid-listing) is the
|
|
627
|
-
// always-on twin of the doctor's `merge-queue files` row (doctor-backlog.ts
|
|
628
|
-
// skippedMergeQueueFiles, feeding this from status-payload.ts's skippedMergeFiles): it sits
|
|
629
|
-
// in .tumwater/merge-queue/ forever because the drain drops only an unreadable head, so a
|
|
630
|
-
// skipped file after a healthy entry strands its merge until someone removes it. The stale-
|
|
631
|
-
// merges alert above reads only readable entries, so it can never cover one. The doctor's row
|
|
632
|
-
// and this alert share the one relative-path list, so they name the same files; the count and
|
|
633
|
-
// the first skipped file are shown, the rest folded through joinWithMore.
|
|
634
|
-
if (skippedMergeFiles.length) {
|
|
635
|
-
const [first, ...rest] = skippedMergeFiles;
|
|
636
|
-
out.push({
|
|
637
|
-
key: "skipped-merges",
|
|
638
|
-
tone: "amber",
|
|
639
|
-
title: `${plural(skippedMergeFiles.length, "merge-queue file is", "merge-queue files are")} unreadable`,
|
|
640
|
-
detail: `the queue reader skips ${first} — a torn or foreign file never clears itself, so its merge is stranded; remove it (a crashed enqueue's merge ref still recovers on the next run)` +
|
|
641
|
-
moreDetail(rest, (labels) => joinWithMore(labels, 3)),
|
|
642
|
-
actions: [],
|
|
643
|
-
});
|
|
644
|
-
}
|
|
645
|
-
// A merge the primary checkout blocked (merge-git.ts primaryCheckoutBlockers, the
|
|
646
|
-
// overwriteBlockedReason `warning` the merge path writes) — the always-on twin of the doctor's
|
|
647
|
-
// `uncommitted backlog` row, but read off events so it reaches the status poll (the doctor's
|
|
648
|
-
// git read is async and never gets there). A team stuck retrying one dirty file is a single
|
|
649
|
-
// amber banner until the file is committed and the refusals age out; status-payload.ts's
|
|
650
|
-
// primaryOverwriteBlocks feeds it the last day's refusals, counting them and naming the
|
|
651
|
-
// latest's age, squashed message, and the commit fix.
|
|
652
|
-
if (primaryOverwrite) {
|
|
653
|
-
out.push({
|
|
654
|
-
key: "primary-overwrite",
|
|
655
|
-
tone: "amber",
|
|
656
|
-
title: `${plural(primaryOverwrite.count, "merge was", "merges were")} refused by files uncommitted in your primary checkout`,
|
|
657
|
-
detail: `most recently ${humanAgo(primaryOverwrite.latestTs, now)}: ${squash(primaryOverwrite.message, 160)} — roles cannot commit your checkout; commit the file(s) on main with \`tumwater doctor --commit-backlog\` (or by hand) so merges can proceed`,
|
|
658
|
-
actions: [],
|
|
659
|
-
});
|
|
660
|
-
}
|
|
661
|
-
return out;
|
|
662
|
-
}
|
|
663
|
-
/** The quarantine, flapped-check, and post-merge review alerts, in emission order: a
|
|
664
|
-
* stale or oversized flake quarantine, checks flapping more than once, a
|
|
665
|
-
* burst of post-merge review rejections, a post-merge review one failed run from being
|
|
666
|
-
* dropped, a post-merge review the drain dropped leaves its commit on main unreviewed, a
|
|
667
|
-
* main tip the watch's diff rules rejected, and the ci role's full suite stalled over three
|
|
668
|
-
* days without covering a new tip.
|
|
669
|
-
* Extracted from teamAlerts (part 4/5 of the teamAlerts split) so the shared module stays
|
|
670
|
-
* small; the input names exactly the fields this run reads (including `now`, which the
|
|
671
|
-
* quarantine and dropped-post-reviews age lines need). The caller pushes the returned alerts
|
|
672
|
-
* at the position this run used to occupy, so the emitted key order is unchanged. */
|
|
673
|
-
function buildMergeAlerts(input) {
|
|
674
|
-
const out = [];
|
|
675
|
-
const { quarantined, flappedChecks, postMergeChurn, postReviewRisk, droppedPostReviews, mainRuleFailure, ciParity, qaCoverage, now } = input;
|
|
676
|
-
// A stale or oversized flake quarantine — the always-on twin of the doctor's `flaky checks`
|
|
677
|
-
// row (main-guardrails.ts checkFlakyChecks, quarantineWarning): a quarantined test counts as
|
|
678
|
-
// a known failure until its filed bug leaves BUGS.md `## Open`, so an old entry means the
|
|
679
|
-
// team is merging on a suite whose red is being excused. status-payload.ts's quarantineAlert
|
|
680
|
-
// feeds it from the same quarantineWarning the doctor calls, so the alert and the doctor
|
|
681
|
-
// cannot drift on when the list is a problem; the warning phrase and the oldest test's age
|
|
682
|
-
// are shown, folded through squash like other long names.
|
|
683
|
-
if (quarantined) {
|
|
684
|
-
out.push({
|
|
685
|
-
key: "quarantine",
|
|
686
|
-
tone: "amber",
|
|
687
|
-
title: `${plural(quarantined.count, "test")} in the flake quarantine — the gate is excusing their failures`,
|
|
688
|
-
detail: `${quarantined.warning} · oldest: ${squash(quarantined.oldestTest, 80)} (${humanAgo(quarantined.oldestFirstSeen, now)}) — a quarantined test counts as a known failure until its filed bug leaves BUGS.md \`## Open\`, so fix the test or file the missing bug`,
|
|
689
|
-
actions: seeFailures(),
|
|
690
|
-
});
|
|
691
|
-
}
|
|
692
|
-
// Checks that flapped more than once in the last day — the always-on twin of the doctor's
|
|
693
|
-
// `flaky checks` row (doctor-project-checks.ts checkFlakyChecks): the gate's single re-run
|
|
694
|
-
// absorbs a one-off flake, but a check flapping twice silently pays that re-run on every
|
|
695
|
-
// merge it touches, so the tally belongs on every observer surface while it happens.
|
|
696
|
-
// status-payload.ts's flappedChecksAlert feeds it from the same repeatedFlakeCounts and
|
|
697
|
-
// flappedChecksPhrase the doctor calls, so the alert and the doctor cannot drift on the
|
|
698
|
-
// count or the wording.
|
|
699
|
-
if (flappedChecks) {
|
|
700
|
-
out.push({
|
|
701
|
-
key: "flapped-checks",
|
|
702
|
-
tone: "amber",
|
|
703
|
-
title: `${plural(flappedChecks.count, "check has", "checks have")} flapped more than once in the last day`,
|
|
704
|
-
detail: `${flappedChecks.detail} — a flapping check pays the gate's one re-run on every merge it touches; fix it or file it with \`tumwater bug\``,
|
|
705
|
-
actions: seeFailures(),
|
|
706
|
-
});
|
|
707
|
-
}
|
|
708
|
-
// Last day's post-merge review rejections — the reviewer-fix signal for the `review.afterMerge`
|
|
709
|
-
// tiering (src/review/post-merge-review.ts `reviewMergedCommit`). A post-merge rejection is the
|
|
710
|
-
// costly kind: the commit already reached main, so the rejection is answered only by the
|
|
711
|
-
// reviewer's own fix, and the always-on signal for a day crossing POST_MERGE_REJECT_WARN is
|
|
712
|
-
// this alert. status-payload.ts's postMergeChurnAlert feeds it
|
|
713
|
-
// from the same `afterMerge: true` `review_rejected` events the report's
|
|
714
|
-
// postMergeRejectionsByRole folds, so the alert and the report cannot disagree on which
|
|
715
|
-
// rejections are post-merge.
|
|
716
|
-
if (postMergeChurn) {
|
|
717
|
-
out.push({
|
|
718
|
-
key: "post-merge-churn",
|
|
719
|
-
tone: "amber",
|
|
720
|
-
title: `${plural(postMergeChurn.count, "post-merge review rejection", "post-merge review rejections")} in the last day`,
|
|
721
|
-
detail: `${postMergeChurn.roles.map((r) => `${roleLabel(r.role)} ×${r.count}`).join(", ")} — each commit already reached main and stays there with the reviewer's recorded reasons (never re-reviewed — Forward, never back); narrow \`review.afterMerge\` for the noisy role or raise the authoring bar`,
|
|
722
|
-
actions: seeFailures(),
|
|
723
|
-
});
|
|
724
|
-
}
|
|
725
|
-
// A post-merge review one failed run from being dropped — the always-on twin of the doctor's
|
|
726
|
-
// `post-merge reviews` row (src/doctor/doctor-backlog.ts checkPostReviews) — the last moment
|
|
727
|
-
// an operator can fix the reviewer backend or deliberately accept the commit unreviewed.
|
|
728
|
-
// postReviewRiskAlert feeds it from the same atRiskPostReviews traversal and phrase the doctor
|
|
729
|
-
// calls, so the alert and the doctor cannot drift on the trigger or the wording.
|
|
730
|
-
if (postReviewRisk) {
|
|
731
|
-
out.push({
|
|
732
|
-
key: "post-merge-risk",
|
|
733
|
-
tone: "amber",
|
|
734
|
-
title: `${plural(postReviewRisk.count, "post-merge review is", "post-merge reviews are")} one failed run from being dropped`,
|
|
735
|
-
detail: postReviewRisk.detail,
|
|
736
|
-
actions: seeFailures(),
|
|
737
|
-
});
|
|
738
|
-
}
|
|
739
|
-
// A post-merge review the drain actually dropped — the commit it leaves on main unreviewed
|
|
740
|
-
// (src/merge/merge-post-review.ts `startPostReview` at POST_REVIEW_FAILURE_LIMIT). Reads only
|
|
741
|
-
// the `warning` event the drop branch writes, after the at-risk alert is moot: the entry is
|
|
742
|
-
// gone, so no surface names the commit without this. droppedPostReviewsAlert feeds it from
|
|
743
|
-
// the same DROPPED_POST_REVIEW_MARKER the drain's message carries, so the alert and the drain
|
|
744
|
-
// cannot drift on what a drop is (the per-run failed-review warning never matches).
|
|
745
|
-
if (droppedPostReviews) {
|
|
746
|
-
out.push({
|
|
747
|
-
key: "dropped-post-reviews",
|
|
748
|
-
tone: "amber",
|
|
749
|
-
title: `${plural(droppedPostReviews.count, "post-merge review was dropped", "post-merge reviews were dropped")} in the last day — ${plural(droppedPostReviews.count, "the commit sits", "the commits sit")} on main unreviewed`,
|
|
750
|
-
detail: `most recently ${humanAgo(droppedPostReviews.latestTs, now)}: ${squash(droppedPostReviews.latestMessage, 160)} — the reviewer kept failing past \`POST_REVIEW_FAILURE_LIMIT\`, so the commit is beyond the review gate; fix the reviewer backend so later commits are reviewed`,
|
|
751
|
-
actions: [{ label: "See failures", act: "view", arg: "failures" }],
|
|
752
|
-
});
|
|
753
|
-
}
|
|
754
|
-
// A main tip the watch's five diff rules rejected in the trailing day — the always-on
|
|
755
|
-
// surface for the `warning` main-watch.ts's `watchRules` logs (`main's tip <short sha> fails
|
|
756
|
-
// the diff rules: <reason>`). The gate judges one change; the watch judges each first-parent
|
|
757
|
-
// commit's own diff, so a conflict marker, a protected edit, a leaked secret, a phantom fix
|
|
758
|
-
// claim or a broken backlog structure that reached main through a gate blind spot sits on
|
|
759
|
-
// main with only that event log to say so. status-payload.ts's mainRuleFailures feeds it
|
|
760
|
-
// from the same marker, so the alert and the watch cannot drift on what a rule failure is (a
|
|
761
|
-
// `main_check` or `review_rejected` carrying the same text never matches).
|
|
762
|
-
if (mainRuleFailure) {
|
|
763
|
-
out.push({
|
|
764
|
-
key: "main-diff-rules",
|
|
765
|
-
tone: "amber",
|
|
766
|
-
title: `${plural(mainRuleFailure.count, "diff-rule failure on main", "diff-rule failures on main")} in the last day`,
|
|
767
|
-
detail: `most recently ${humanAgo(mainRuleFailure.latestTs, now)}: ${squash(mainRuleFailure.latestMessage, 160)} — the gate approved a diff the watch's five rules reject, so main carries what the gate would refuse; repair the tip or revert it with \`tumwater revert <sha>\``,
|
|
768
|
-
actions: seeFailures(),
|
|
769
|
-
});
|
|
770
|
-
}
|
|
771
|
-
// The ci role's full suite silently stopped covering new tips — the always-on twin of the
|
|
772
|
-
// doctor's `ci parity` row (doctor-project-checks.ts checkCiParity), read from the same
|
|
773
|
-
// ciParityStalled predicate and CI_PARITY_STALE_MS threshold so the alert and the row cannot
|
|
774
|
-
// disagree on when a post-merge gap is a stall. A `stale` record is normal right after a
|
|
775
|
-
// merge (main moves on every merge and the role runs on its own clock), but one that stays
|
|
776
|
-
// stale past three days means the role that is now the full suite's own runner has silently
|
|
777
|
-
// stopped covering new tips (disabled, paused, wedged, or failing before it records). The
|
|
778
|
-
// fix is a role toggle the role drawer already has, so there is no action button here.
|
|
779
|
-
if (ciParity && ciParityStalled(ciParity, now)) {
|
|
780
|
-
out.push({
|
|
781
|
-
key: "ci-stale",
|
|
782
|
-
tone: "amber",
|
|
783
|
-
title: `main's full suite has not covered a tip in over ${CI_PARITY_STALE_MS / DAY_MS} days`,
|
|
784
|
-
detail: `the ci role's last full-suite run covered ${shortSha(ciParity.covered)} ${humanAgo(ciParity.lastRunAt, now)}; main has moved since, so no full-suite run covers the current tip — the ci role is disabled, paused, or unable to record; resume or enable it, or check \`tumwater doctor\``,
|
|
785
|
-
actions: [],
|
|
786
|
-
});
|
|
787
|
-
}
|
|
788
|
-
// The qa role's flow coverage silently stopped advancing — the always-on twin of the
|
|
789
|
-
// doctor's `qa coverage` row (doctor-project-checks.ts checkQaCoverage), fed from the same
|
|
790
|
-
// qaCoverageStalled predicate via status-payload.ts's qaCoverageAlert so the alert and the
|
|
791
|
-
// row cannot disagree on when a quiet qa is a stall. A missing ledger (no record yet) is not
|
|
792
|
-
// a stall, so this fires only once a recorded flow's newest exercise is over three days old —
|
|
793
|
-
// the team's only flow-level observer has silently stopped recording (disabled, paused,
|
|
794
|
-
// wedged, or failing before it records). The fix is a role toggle the role drawer already has,
|
|
795
|
-
// so there is no action button here.
|
|
796
|
-
if (qaCoverage) {
|
|
797
|
-
out.push({
|
|
798
|
-
key: "qa-stale",
|
|
799
|
-
tone: "amber",
|
|
800
|
-
title: `qa's flow coverage has not advanced in over ${QA_COVERAGE_STALE_MS / DAY_MS} days`,
|
|
801
|
-
detail: `the qa role's newest flow ${qaCoverage.newestFlow} was exercised ${humanAgo(qaCoverage.newestAt, now)}; coverage has not advanced since, so the qa role is disabled, paused, or unable to record; resume or enable it, or check \`tumwater doctor\``,
|
|
802
|
-
actions: [],
|
|
803
|
-
});
|
|
804
|
-
}
|
|
805
|
-
return out;
|
|
806
|
-
}
|
|
807
|
-
/** The quiet-hours, maintenance, provider-hold, pause, capacity-contention, stale-workspace-pin,
|
|
808
|
-
* regressed-fix, stopped and image-input alerts, in emission order: a run of quiet hours
|
|
809
|
-
* holding the team, a
|
|
810
|
-
* maintenance allowance held at its rolling cap, a standing provider failure hold, an operator
|
|
811
|
-
* pause with its countdown, the last day's capacity contention, a stale workspace pin, a
|
|
812
|
-
* fixed test that regressed, a
|
|
813
|
-
* stopped tumwater, and roles that cannot or may not read images. Extracted from teamAlerts
|
|
814
|
-
* (part 5/5 of the teamAlerts split) so the shared module stays small; the input names exactly
|
|
815
|
-
* the fields this run reads, including `now`, which the pause countdown and provider-hold
|
|
816
|
-
* re-open time need. The caller pushes the returned alerts at the position this run used to
|
|
817
|
-
* occupy, so the emitted key order is unchanged. */
|
|
818
|
-
function buildTailAlerts(input) {
|
|
819
|
-
const out = [];
|
|
820
|
-
const { snap, now, contention, regressedFixes, roles, workspacePins } = input;
|
|
821
|
-
// Quiet hours (plans: "Quiet hours … part 2/2, observability") informs rather than asks:
|
|
822
|
-
// the team holding to a schedule is not a problem, but an operator watching idle roles
|
|
823
|
-
// inside the window needs the same explanation the pause alert gives a paused team —
|
|
824
|
-
// why nothing runs, and when it starts again. The window end comes from quiet-hours.ts's
|
|
825
|
-
// quietWindowEnd, the same helper quietBadge uses for the header.
|
|
826
|
-
if (snap.inQuietHours && snap.quietHours) {
|
|
827
|
-
const end = quietWindowEnd(snap.quietHours);
|
|
828
|
-
out.push({
|
|
829
|
-
key: "quiet",
|
|
830
|
-
tone: "blue",
|
|
831
|
-
title: `Quiet hours — roles start no new runs until ${end}`,
|
|
832
|
-
detail: `The schedule (${snap.quietHours} local time, quietHours in tumwater.json) holds them. Runs in progress finish, and the director still runs your prompts.`,
|
|
833
|
-
actions: [],
|
|
834
|
-
});
|
|
835
|
-
}
|
|
836
|
-
// The maintenance allowance (plans/work-ratio.md, part 1b/4): by design and self-clearing —
|
|
837
|
-
// the scheduler holds maintenance roles at the rolling 24 h allowance until merges free room,
|
|
838
|
-
// then they run again on their own — so it informs (gray, no actions), the quiet alert's
|
|
839
|
-
// tone, rather than amber like the failed/refused asks. An operator watching idle maintenance
|
|
840
|
-
// roles gets the same explanation the hold's own row carries, team-wide.
|
|
841
|
-
if (snap.maintenanceQuota.held.length > 0) {
|
|
842
|
-
const mq = snap.maintenanceQuota;
|
|
843
|
-
out.push({
|
|
844
|
-
key: "maintenance",
|
|
845
|
-
tone: "gray",
|
|
846
|
-
title: "Maintenance is held at its rolling allowance",
|
|
847
|
-
detail: `${mq.used} of ${mq.allowance} maintenance allowance used over the last 24 h; ${listRoleIds(mq.held)} ${agree(mq.held.length, "resumes", "resume")} as work merges.`,
|
|
848
|
-
actions: [],
|
|
849
|
-
});
|
|
850
|
-
}
|
|
851
|
-
// A standing provider failure hold (src/team/team-hold.ts `standingHolds`, published through
|
|
852
|
-
// the orchestrator info file): a provider storm holds roles on that provider, so an operator
|
|
853
|
-
// watching idle roles with no log open sees why nothing new starts there. Self-clearing by
|
|
854
|
-
// design — the hold re-opens at its own deadline — so it informs (blue, no actions), the
|
|
855
|
-
// quiet and maintenance alerts' tone, rather than asking. The snapshot carries the holds only
|
|
856
|
-
// while a live orchestrator publishes one.
|
|
857
|
-
if (snap.providerHolds && snap.providerHolds.length > 0) {
|
|
858
|
-
const phrased = snap.providerHolds
|
|
859
|
-
.map((h) => `${h.provider ?? "pi's default backend"} (${h.kind}) until it re-opens in ${humanUntil(h.until, now)}`)
|
|
860
|
-
.join("; ");
|
|
861
|
-
out.push({
|
|
862
|
-
key: "provider-hold",
|
|
863
|
-
tone: "blue",
|
|
864
|
-
title: agree(snap.providerHolds.length, "Provider limit is holding new runs", "Provider limits are holding new runs"),
|
|
865
|
-
detail: `${phrased}. Runs in progress finish; roles on a held provider start no new runs until it re-opens.`,
|
|
866
|
-
actions: [],
|
|
867
|
-
});
|
|
868
|
-
}
|
|
869
|
-
if (snap.paused) {
|
|
870
|
-
const left = pauseCountdown(snap.pausedUntil, now);
|
|
871
|
-
out.push({
|
|
872
|
-
key: "paused",
|
|
873
|
-
tone: "amber",
|
|
874
|
-
// The operator's pause reason (`pause --reason <text>`) states verbatim, so an
|
|
875
|
-
// operator returning to a paused team knows why without digging through history.
|
|
876
|
-
title: left
|
|
877
|
-
? `The team is paused and resumes in ${left}${pauseReasonSuffix(snap.pauseReason)}`
|
|
878
|
-
: `The team is paused${pauseReasonSuffix(snap.pauseReason)}`,
|
|
879
|
-
detail: "Roles start no new runs. Runs in progress finish, and the director still runs your prompts.",
|
|
880
|
-
actions: [{ label: "Resume now", act: "resume" }],
|
|
881
|
-
});
|
|
882
|
-
}
|
|
883
|
-
// Last day's capacity contention — the always-on twin of the doctor's `workspaces`,
|
|
884
|
-
// `provider contention` and `check contention` rows (doctor-contention-checks.ts). The
|
|
885
|
-
// instantaneous badges show only current waits, and the 24 h total is the tuning signal that
|
|
886
|
-
// says a cap is too small; status-payload.ts's capacityContention feeds it from the same
|
|
887
|
-
// `*_wait` events and ten-minute thresholds the doctor sums, so the alert and the doctor
|
|
888
|
-
// cannot drift on the trigger or the knob-raising advice.
|
|
889
|
-
if (contention.length > 0) {
|
|
890
|
-
out.push({
|
|
891
|
-
key: "contention",
|
|
892
|
-
tone: "amber",
|
|
893
|
-
title: plural(contention.length, "capacity bottleneck in the last day", "capacity bottlenecks in the last day"),
|
|
894
|
-
detail: contention.map((c) => `${c.name}: ${squash(c.detail, 200)}`).join(" · "),
|
|
895
|
-
actions: [],
|
|
896
|
-
});
|
|
897
|
-
}
|
|
898
|
-
// A stale workspace pin — the always-on twin of the doctor's `worktree pool` row's stuck-pin
|
|
899
|
-
// clause (doctor-contention-checks.ts checkWorktreePool): a slot pinned past a day by a run
|
|
900
|
-
// that never resumed silently shrinks the pool, and no other observer surface names it until
|
|
901
|
-
// an operator runs `tumwater doctor`. status-payload.ts feeds it the same staleWorkspacePins
|
|
902
|
-
// predicate the doctor's row uses, so the alert and the row cannot drift on what is stale;
|
|
903
|
-
// the "Open <role>" buttons reach the role drawer like the other role alerts.
|
|
904
|
-
if (workspacePins.length > 0) {
|
|
905
|
-
out.push({
|
|
906
|
-
key: "workspace-pins",
|
|
907
|
-
tone: "amber",
|
|
908
|
-
title: plural(workspacePins.length, "workspace pinned past a day", "workspaces pinned past a day"),
|
|
909
|
-
detail: `${workspacePins
|
|
910
|
-
.map(stalePinPhrase)
|
|
911
|
-
.join("; ")} — a pinned workspace is held out of the pool; resume the role, or retire its leftovers and run \`tumwater doctor\``,
|
|
912
|
-
actions: openRoleActions(workspacePins),
|
|
913
|
-
});
|
|
914
|
-
}
|
|
915
|
-
// A Fixed record whose quoted test failed again in the trailing day — the always-on twin of
|
|
916
|
-
// the doctor's `regressed fixes` row (doctor-backlog-bugs.ts checkRegressedFixes).
|
|
917
|
-
// status-payload.ts's regressedFixesAlert feeds it the same candidate selection, failureMentions
|
|
918
|
-
// match and verbatim sentence, so the alert and the doctor cannot drift on the trigger or the
|
|
919
|
-
// wording; the Failures view shows the naming events.
|
|
920
|
-
if (regressedFixes) {
|
|
921
|
-
out.push({
|
|
922
|
-
key: "regressed-fixes",
|
|
923
|
-
tone: "amber",
|
|
924
|
-
title: plural(regressedFixes.count, "Fixed test has regressed", "Fixed tests have regressed"),
|
|
925
|
-
detail: regressedFixes.detail,
|
|
926
|
-
actions: seeFailures(),
|
|
927
|
-
});
|
|
928
|
-
}
|
|
929
|
-
if (!snap.running) {
|
|
930
|
-
out.push({
|
|
931
|
-
key: "stopped",
|
|
932
|
-
tone: "gray",
|
|
933
|
-
title: "tumwater is not running — start it with `tumwater start`",
|
|
934
|
-
detail: "Start it with tumwater start in the project directory; the dashboards pick it up on their own.",
|
|
935
|
-
actions: [{ label: "Copy the command", act: "copy", arg: "tumwater start" }],
|
|
936
|
-
});
|
|
937
|
-
}
|
|
938
|
-
// Image input (plans/image-input.md "Surfaces", part 5/5): a role whose model cannot read
|
|
939
|
-
// images. Blue when every text-only role has a reroute target (a run that reads an image
|
|
940
|
-
// stops and reruns — nothing fails); amber when some text-only role has none (a blind read
|
|
941
|
-
// fails and image submissions are refused); a separate amber when a claimed model failed
|
|
942
|
-
// doctor's live probe (the claim is contradicted, whatever any role resolved to). No alert
|
|
943
|
-
// when every enabled role reads images (imageInput true or unknown — an unknown seam is not
|
|
944
|
-
// counted as text-only, mirroring the submit gate and the doctor's `tier models` stance).
|
|
945
|
-
const textOnly = roles.filter((l) => l.imageInput === false);
|
|
946
|
-
const noTarget = textOnly.filter((l) => l.imageReroute == null || l.imageReroute === "");
|
|
947
|
-
if (noTarget.length > 0) {
|
|
948
|
-
out.push({
|
|
949
|
-
key: "images",
|
|
950
|
-
tone: "amber",
|
|
951
|
-
title: `No configured model can read images for ${listRoles(noTarget)}`,
|
|
952
|
-
detail: "A run that reads an image fails, and image attachments to these roles are refused. Declare image input on a vision model in pi's models.json, or point a tier at one.",
|
|
953
|
-
actions: openRoleActions(noTarget),
|
|
954
|
-
});
|
|
955
|
-
}
|
|
956
|
-
else if (textOnly.length > 0) {
|
|
957
|
-
const targets = [...new Set(textOnly.map((l) => l.imageReroute ?? ""))].filter(Boolean);
|
|
958
|
-
out.push({
|
|
959
|
-
key: "images",
|
|
960
|
-
tone: "blue",
|
|
961
|
-
title: `${listRoles(textOnly)} ${agree(textOnly.length, "runs", "run")} on models that can't read images`,
|
|
962
|
-
detail: `A run that reads an image stops and reruns on ${targets.join(", ")}.`,
|
|
963
|
-
actions: [],
|
|
964
|
-
});
|
|
965
|
-
}
|
|
966
|
-
if (snap.imageProbeFailures.length > 0) {
|
|
967
|
-
out.push({
|
|
968
|
-
key: "images-probe",
|
|
969
|
-
tone: "amber",
|
|
970
|
-
title: `A claimed image-capable model failed doctor's probe`,
|
|
971
|
-
detail: `${snap.imageProbeFailures.join(", ")} declare image input but failed the live probe — run \`tumwater doctor --reprobe\` to re-check.`,
|
|
972
|
-
actions: seeFailures(),
|
|
973
|
-
});
|
|
974
|
-
}
|
|
975
|
-
return out;
|
|
976
|
-
}
|
|
8
|
+
* the cap editor…).
|
|
9
|
+
*
|
|
10
|
+
* This module is the composition root: it imports the three per-domain builder modules
|
|
11
|
+
* (team-alert-status, team-alert-backlog,
|
|
12
|
+
* team-alert-tail) and runs them in emission order, and it re-exports the shared alert types
|
|
13
|
+
* (defined in team-alert-shared.ts) so consumers keep importing them from this one path. */
|
|
14
|
+
import { buildStatusAlerts } from "./team-alert-status.js";
|
|
15
|
+
import { buildBacklogAlerts } from "./team-alert-backlog.js";
|
|
16
|
+
import { buildTailAlerts } from "./team-alert-tail.js";
|
|
977
17
|
/** Everything about the team that needs the operator, most urgent first: a spent budget, a red
|
|
978
18
|
* main, roles failing run after run, roles that look stuck, a stale running build, open
|
|
979
|
-
* questions, refused entries,
|
|
980
|
-
*
|
|
981
|
-
* prompt, a stalled merge queue, a
|
|
982
|
-
* waiting bug held a week, a merge-queue file the reader skips, a plan stalled by a Needs
|
|
983
|
-
* review or Needs replan note, checks that flapped more than once in the last day, a stale or
|
|
984
|
-
* oversized flake quarantine, a burst of post-merge review rejections, each a commit that
|
|
985
|
-
* already reached main, a post-merge review one failed run from being dropped, a post-merge
|
|
986
|
-
* review dropped after repeated failures, leaving its commit on main unreviewed, a main tip the
|
|
987
|
-
* watch's five diff rules rejected, the team
|
|
19
|
+
* questions, refused entries, a plan stalled by a Needs
|
|
20
|
+
* review note, the team
|
|
988
21
|
* waiting on capacity slots (workspace, provider or
|
|
989
|
-
* check), a
|
|
22
|
+
* check), parallel instances sharing a workspace pool smaller than the run cap, a plan
|
|
23
|
+
* filed under the wrong backlog section, a duplicated
|
|
990
24
|
* backlog section heading, finished history still under a live backlog file's `## Done` or
|
|
991
25
|
* `## Fixed`, a Fixed record with a bad Validation gap trace, a pause, a
|
|
992
26
|
* stopped team.
|
|
993
27
|
* `now` is passed so a timed
|
|
994
28
|
* pause's countdown agrees with the rest of the frame. */
|
|
995
|
-
export function teamAlerts(snap, questions, roles, now, refused = [],
|
|
29
|
+
export function teamAlerts(snap, questions, roles, now, refused = [], planDependency = null, stalledEntries = [], strandedPlans = [], duplicatedHeadings = [], validationGaps = [], contention = [], regressedFixes = null, staleClaims = [], staleQuestions = [], fixClaims = [], workspacePins = [], liveFinished = [], instancePool = [], providerCaps = []) {
|
|
996
30
|
const out = [];
|
|
997
31
|
out.push(...buildStatusAlerts({ snap, roles, staleClaims }));
|
|
998
|
-
out.push(...buildBacklogAlerts({ questions, staleQuestions, refused,
|
|
999
|
-
out.push(...
|
|
1000
|
-
out.push(...buildMergeAlerts({ quarantined, flappedChecks, postMergeChurn, postReviewRisk, droppedPostReviews, mainRuleFailure, ciParity: snap.ciParity, qaCoverage, now }));
|
|
1001
|
-
out.push(...buildTailAlerts({ snap, now, contention, regressedFixes, roles, workspacePins }));
|
|
32
|
+
out.push(...buildBacklogAlerts({ questions, staleQuestions, refused, stalledEntries, planDependency, strandedPlans, duplicatedHeadings, liveFinished, validationGaps, fixClaims }));
|
|
33
|
+
out.push(...buildTailAlerts({ snap, now, contention, regressedFixes, roles, workspacePins, instancePool, providerCaps }));
|
|
1002
34
|
return out;
|
|
1003
35
|
}
|
|
1004
36
|
/** Does this alert ask something of the operator (red, amber, indigo), rather than inform? */
|