tumwater 0.4.3 → 0.4.4
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 +1 -1
- package/dist/build-info.json +2 -2
- package/dist/src/agent/agent-run.js +2 -1
- package/dist/src/agent/claude/claude-account.js +6 -20
- package/dist/src/agent/codex/codex-account.js +5 -6
- package/dist/src/agent/codex/codex-args.js +5 -5
- package/dist/src/agent/codex/codex-rollout.js +1 -1
- package/dist/src/agent/opencode/opencode-backend.js +24 -22
- package/dist/src/agent/opencode/opencode-catalog.js +35 -9
- package/dist/src/agent/session-ledger.js +22 -26
- package/dist/src/agent/subscription-account.js +2 -83
- package/dist/src/backlog/backlog-archive.js +3 -4
- package/dist/src/backlog/backlog-eligibility.js +14 -36
- package/dist/src/backlog/backlog-question-answer.js +1 -1
- package/dist/src/backlog/backlog.js +2 -1
- package/dist/src/baseline/main-guardrails.js +2 -2
- package/dist/src/baseline/main-watch.js +84 -50
- package/dist/src/brief/brief.js +26 -13
- package/dist/src/budget/budget.js +4 -5
- package/dist/src/build/build-check-report.js +10 -0
- package/dist/src/build/build-check-scoped.js +0 -2
- package/dist/src/build/build-stage.js +6 -4
- package/dist/src/change/change-data.js +1 -4
- package/dist/src/cli/cli-completion-command.js +75 -0
- package/dist/src/cli/cli-doctor-command.js +103 -0
- package/dist/src/cli/cli-init.js +2 -2
- package/dist/src/cli/cli-query-commands.js +1 -1
- package/dist/src/cli/cli-role-command.js +2 -41
- package/dist/src/cli/cli-start-summary.js +4 -3
- package/dist/src/cli/cli-start-supervisor.js +190 -0
- package/dist/src/cli/cli-start.js +4 -187
- package/dist/src/cli/completion.js +3 -3
- package/dist/src/cli/follow-view.js +20 -2
- package/dist/src/cli/help.js +2 -7
- package/dist/src/cli.js +19 -171
- package/dist/src/collections.js +11 -14
- package/dist/src/concurrency/lock.js +1 -1
- package/dist/src/config/config-keys.js +61 -20
- package/dist/src/config/config-request.js +11 -1
- package/dist/src/config/config-schema.js +1 -1
- package/dist/src/config/config-validation-roles.js +6 -22
- package/dist/src/config/config-validation.js +0 -1
- package/dist/src/config/config-views.js +17 -16
- package/dist/src/config/config-write.js +122 -79
- package/dist/src/config/config.js +1 -9
- package/dist/src/doctor/doctor-backlog-plans.js +29 -96
- package/dist/src/doctor/doctor-backlog.js +10 -10
- package/dist/src/doctor/doctor-checks.js +4 -4
- package/dist/src/doctor/doctor-claude-checks.js +27 -9
- package/dist/src/doctor/doctor-contention-checks.js +13 -5
- package/dist/src/doctor/doctor-fallback-checks.js +46 -24
- package/dist/src/doctor/doctor-holds.js +2 -2
- package/dist/src/doctor/doctor-orphans.js +3 -2
- package/dist/src/doctor/doctor-project-checks.js +21 -8
- package/dist/src/events/event-format.js +15 -11
- package/dist/src/events/event-legacy.js +2 -2
- package/dist/src/events/event-notify.js +4 -4
- package/dist/src/events/event-window.js +1 -1
- package/dist/src/events/events.js +8 -0
- package/dist/src/failure/failure-cluster.js +4 -13
- package/dist/src/failure/failure-data.js +4 -4
- package/dist/src/failure/failure-render.js +2 -2
- package/dist/src/failure/failure-state-change.js +50 -86
- package/dist/src/gates/gate-polls.js +5 -6
- package/dist/src/gates/startup-gates.js +2 -1
- package/dist/src/git/git-diff.js +6 -2
- package/dist/src/gui/gui-args.js +12 -0
- package/dist/src/gui/gui-config-commands.js +2 -64
- package/dist/src/gui/gui-endpoint-commands.js +1 -1
- package/dist/src/gui/gui-endpoint-common.js +19 -1
- package/dist/src/gui/gui-operator-commands.js +21 -25
- package/dist/src/gui/gui-prompt-commands.js +2 -2
- package/dist/src/gui/gui-server.js +1 -2
- package/dist/src/gui/gui-team-commands.js +2 -1
- package/dist/src/history/history.js +4 -4
- package/dist/src/merge/merge-check-failures.js +12 -9
- package/dist/src/merge/merge-core.js +6 -31
- package/dist/src/merge/merge-drain.js +3 -2
- package/dist/src/merge/merge-post-review-drain.js +1 -1
- package/dist/src/merge/merge-queue-render.js +3 -3
- package/dist/src/merge/merge-stack.js +17 -18
- package/dist/src/merge/merge-to-main.js +13 -58
- package/dist/src/merge/merge-verify.js +4 -3
- package/dist/src/operator/operator-commands.js +4 -3
- package/dist/src/orchestrator/orchestrator-poll.js +18 -38
- package/dist/src/orchestrator/orchestrator-scheduling.js +5 -9
- package/dist/src/orchestrator/scheduling-scope.js +17 -26
- package/dist/src/orchestrator/scheduling-skip.js +9 -13
- package/dist/src/paths.js +2 -2
- package/dist/src/pi/pi-event-line.js +27 -0
- package/dist/src/pi/pi-listed-models.js +13 -0
- package/dist/src/pi/pi-stream.js +9 -2
- package/dist/src/pi/pi-watchdogs.js +31 -10
- package/dist/src/process/process-group.js +2 -21
- package/dist/src/process/process.js +12 -4
- package/dist/src/process/run-marker.js +2 -2
- package/dist/src/prompt/gate-prompts.js +1 -44
- package/dist/src/redeploy/redeployer.js +21 -1
- package/dist/src/review/diff-rules.js +35 -21
- package/dist/src/review/post-merge-review.js +47 -40
- package/dist/src/review/review-precheck.js +3 -3
- package/dist/src/review/review.js +13 -22
- package/dist/src/roles/legacy-role-migration.js +6 -3
- package/dist/src/roles/role-render.js +6 -32
- package/dist/src/roles/role-view.js +2 -13
- package/dist/src/run/ci-parity.js +19 -8
- package/dist/src/run/run-apply.js +0 -9
- package/dist/src/run/run-stage.js +6 -6
- package/dist/src/run/run-verdict.js +13 -4
- package/dist/src/run/stage-check.js +17 -24
- package/dist/src/runner/runner-leftover.js +9 -26
- package/dist/src/runner/runner-outcome.js +1 -1
- package/dist/src/runner/runner-pi.js +6 -12
- package/dist/src/runner/runner-recovery.js +4 -12
- package/dist/src/runner/runner-revision.js +1 -2
- package/dist/src/runner/runner-run.js +7 -16
- package/dist/src/runner/runner-state-load.js +6 -19
- package/dist/src/runner/runner-state.js +0 -14
- package/dist/src/scheduling/backoff.js +8 -3
- package/dist/src/scheduling/claim-entries.js +2 -1
- package/dist/src/status/status-budget.js +4 -43
- package/dist/src/status/status-data.js +34 -38
- package/dist/src/status/status-roles.js +1 -2
- package/dist/src/team/team-hold.js +7 -5
- package/dist/src/team/team-orchestrator-info.js +53 -21
- package/dist/src/team/team-polls.js +36 -86
- package/dist/src/text/datetime.js +16 -7
- package/dist/src/text/phrases.js +9 -0
- package/dist/src/text/suggest.js +1 -1
- package/dist/src/text/text.js +3 -2
- package/dist/src/ui/alert-feeds.js +0 -41
- package/dist/src/ui/badges.js +14 -75
- package/dist/src/ui/gui/gui-client-boot.js +0 -6
- package/dist/src/ui/gui/gui-client-drawer-config.js +1 -16
- package/dist/src/ui/gui/gui-client-feed.js +1 -1
- package/dist/src/ui/gui/gui-client-operator.js +7 -5
- package/dist/src/ui/gui/gui-client-team.js +8 -2
- package/dist/src/ui/progress-data.js +54 -4
- package/dist/src/ui/run-progress-model.js +12 -3
- package/dist/src/ui/status-payload.js +10 -25
- package/dist/src/ui/status-render.js +10 -17
- package/dist/src/ui/team-alert-backlog.js +15 -205
- package/dist/src/ui/team-alert-shared.js +6 -29
- package/dist/src/ui/team-alerts.js +4 -10
- package/dist/src/ui/tui/tui-backlog.js +2 -2
- package/dist/src/ui/tui/tui-editors.js +5 -4
- package/dist/src/ui/tui/tui-input.js +8 -6
- package/dist/src/ui/tui/tui-pane-activity.js +2 -1
- package/dist/src/ui/tui/tui-pane-health.js +21 -23
- package/dist/src/ui/tui/tui-pane-history.js +7 -5
- package/dist/src/ui/tui/tui-pane-pending.js +19 -15
- package/dist/src/ui/tui/tui-pane-session.js +4 -3
- package/dist/src/ui/tui/tui-panes.js +9 -1
- package/dist/src/ui/tui/tui.js +3 -5
- package/dist/src/verdict/fix-claim.js +36 -7
- package/dist/src/verdict/reply-contract.js +3 -4
- package/package.json +2 -2
- package/dist/src/review/review-followup.js +0 -87
- package/dist/src/review/review-run.js +0 -395
- /package/dist/src/cli/{brief-command.js → cli-brief-command.js} +0 -0
- /package/dist/src/cli/{config-commands.js → cli-config-commands.js} +0 -0
- /package/dist/src/cli/{log-commands.js → cli-log-commands.js} +0 -0
- /package/dist/src/cli/{notify-command.js → cli-notify-command.js} +0 -0
- /package/dist/src/cli/{question-commands.js → cli-question-commands.js} +0 -0
package/README.md
CHANGED
|
@@ -42,7 +42,7 @@ and is also outside `npm test`.
|
|
|
42
42
|
## Status
|
|
43
43
|
|
|
44
44
|
<!-- tumwater:status:start -->
|
|
45
|
-
**v0.4.
|
|
45
|
+
**v0.4.4**: working tumwater. The director and 15 of the 17 roles are enabled by default; `selfcheck`,
|
|
46
46
|
which files tumwater bugs from tumwater's own event log, and `feedback`, which files them as GitHub
|
|
47
47
|
issues on znation/tumwater, are opt-in (`roles.selfcheck.enabled`, `roles.feedback.enabled`).
|
|
48
48
|
|
package/dist/build-info.json
CHANGED
|
@@ -107,6 +107,7 @@ export function runAgent(opts) {
|
|
|
107
107
|
const wd = startPiWatchdogs({
|
|
108
108
|
progressCount: () => parser.progressCount,
|
|
109
109
|
openToolCalls: () => parser.openToolCalls,
|
|
110
|
+
pendingReply: () => parser.pendingReply,
|
|
110
111
|
runTimeoutMs: opts.config.runTimeoutSeconds * 1000,
|
|
111
112
|
quietMs: opts.config.quietTimeoutSeconds * 1000,
|
|
112
113
|
stallMs: Math.max(0, opts.config.toolCallStallSeconds) * 1000,
|
|
@@ -120,7 +121,7 @@ export function runAgent(opts) {
|
|
|
120
121
|
// deadline stays right for them.
|
|
121
122
|
extendOnProgress: opts.kind === "gate",
|
|
122
123
|
kill: () => terminateChild(child),
|
|
123
|
-
|
|
124
|
+
onStallWarning: opts.onStallWarning,
|
|
124
125
|
});
|
|
125
126
|
let aborted = false;
|
|
126
127
|
// A blind image read on an authoring run deliberately kills it, like the watchdogs do.
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
* subscription-account.ts) — which billing pays for claude runs, and the newest subscription
|
|
3
3
|
* usage-window reading each run reported (plans/agent-backends.md §7 "Billing" and §8 "The
|
|
4
4
|
* subscription usage window"). Persisted at `.tumwater/state/claude-account.json`, written only
|
|
5
|
-
* by the orchestrator process, so the sibling parts
|
|
6
|
-
*
|
|
5
|
+
* by the orchestrator process, so the sibling parts read one answer instead of re-deriving it
|
|
6
|
+
* per surface.
|
|
7
7
|
*
|
|
8
8
|
* Everything here is a thin wrapper over the generic per-backend store (found
|
|
9
9
|
* grounds/codex-backend.md "Groundwork: the subscription account is per backend", item 1),
|
|
@@ -17,7 +17,7 @@ import { spawnSync } from "node:child_process";
|
|
|
17
17
|
import os from "node:os";
|
|
18
18
|
import { resolvedBackendConfig } from "../../config/config-views.js";
|
|
19
19
|
import { parseJsonObject } from "../../files/json-object.js";
|
|
20
|
-
import { observeAccount, readAccount, subscriptionWindowRejected,
|
|
20
|
+
import { observeAccount, readAccount, subscriptionWindowRejected, writeAccount, } from "../subscription-account.js";
|
|
21
21
|
import { claudeBackend, claudeConfigDir } from "./claude-backend.js";
|
|
22
22
|
import { claudeEnv, claudeThirdPartyProvider } from "./claude-env.js";
|
|
23
23
|
/** Read the persisted claude account state, or null when it is absent or unreadable. A file
|
|
@@ -43,23 +43,9 @@ export function writeClaudeAccount(root, account) {
|
|
|
43
43
|
export function observeClaudeAccount(root, run, now = Date.now()) {
|
|
44
44
|
return observeAccount(root, "claude", run, now);
|
|
45
45
|
}
|
|
46
|
-
/**
|
|
47
|
-
*
|
|
48
|
-
export
|
|
49
|
-
return windowUsedUp(account, now, includeOverage, reserve);
|
|
50
|
-
}
|
|
51
|
-
/** Re-export of the per-backend helpers under their historical claude names for callers that
|
|
52
|
-
* predate the per-backend store (budget gates, team holds, the doctor, the role view, the
|
|
53
|
-
* status poll). */
|
|
54
|
-
export { subscriptionWindowRejected, windowReserved };
|
|
55
|
-
/** The window type as a compact label — subscription-account.ts's windowLabel, under its
|
|
56
|
-
* historical claude name. */
|
|
57
|
-
export function claudeWindowLabel(type) {
|
|
58
|
-
return windowLabel(type);
|
|
59
|
-
}
|
|
60
|
-
export function claudeWindowEntries(account) {
|
|
61
|
-
return windowEntries(account);
|
|
62
|
-
}
|
|
46
|
+
/** Re-export of the per-backend rejection signal under its historical claude name for callers
|
|
47
|
+
* that predate the per-backend store (the runner's run-verdict and hold wiring). */
|
|
48
|
+
export { subscriptionWindowRejected };
|
|
63
49
|
/** Seed the claude account file before the first run (plans/agent-backends.md §7):
|
|
64
50
|
* `ANTHROPIC_API_KEY` set means claude prefers the key, so billing is API; otherwise
|
|
65
51
|
* `claude auth status` decides — a claude.ai OAuth login is a subscription, any other login is
|
|
@@ -84,12 +84,11 @@ export function parseCodexLoginStatus(stdout) {
|
|
|
84
84
|
return "api";
|
|
85
85
|
return null;
|
|
86
86
|
}
|
|
87
|
-
/** The refusal message
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
* the resolved `CODEX_HOME` path. */
|
|
87
|
+
/** The refusal message for a codex logged in with an API key, shared by the startup gate, the
|
|
88
|
+
* live-config warn and the doctor's codex auth check (decision 5): Codex reports no cost, so
|
|
89
|
+
* per-token billing would spend money the budget cap cannot see. `home` names the codex home
|
|
90
|
+
* the operator should re-login under; the gate and the live warn pass the literal "<home>"
|
|
91
|
+
* placeholder because they do not resolve one, while the doctor passes the resolved home. */
|
|
93
92
|
export function codexApiKeyRefusalMessage(home) {
|
|
94
93
|
return `Codex is logged in with an API key; tumwater runs Codex on a ChatGPT subscription only — run \`CODEX_HOME=${home} codex login --device-auth\``;
|
|
95
94
|
}
|
|
@@ -106,11 +106,11 @@ export const EFFORT_BY_THINKING = {
|
|
|
106
106
|
* spawn on a model that lists it. */
|
|
107
107
|
const KNOWN_EFFORTS = new Set(Object.values(EFFORT_BY_THINKING));
|
|
108
108
|
/** Map a thinking level to the Codex `model_reasoning_effort` it means, gated by the model's own
|
|
109
|
-
* catalog. `levels` is the model's `supported_reasoning_levels` (design finding 14), which
|
|
110
|
-
*
|
|
111
|
-
* result is returned unclamped. An unknown thinking level returns `undefined` (no effort
|
|
112
|
-
* the level simply has no Codex effort, unlike a thinking level that maps but the model
|
|
113
|
-
* support, which is also `undefined`. */
|
|
109
|
+
* catalog. `levels` is the model's `supported_reasoning_levels` (design finding 14), which the
|
|
110
|
+
* codex catalog (part 8/9) supplies; when the caller has no catalog it passes `undefined`, and
|
|
111
|
+
* the result is returned unclamped. An unknown thinking level returns `undefined` (no effort
|
|
112
|
+
* flag) — the level simply has no Codex effort, unlike a thinking level that maps but the model
|
|
113
|
+
* does not support, which is also `undefined`. */
|
|
114
114
|
export function codexEffort(thinking, levels) {
|
|
115
115
|
if (!Object.hasOwn(EFFORT_BY_THINKING, thinking))
|
|
116
116
|
return undefined;
|
|
@@ -163,7 +163,7 @@ function rateLimitLines(lastRateLimits) {
|
|
|
163
163
|
/** The model this turn switched to when its `turn_context.model` differs from what the run
|
|
164
164
|
* requested (finding 24): the model itself, else undefined for no switch. */
|
|
165
165
|
function switchedToModel(model, requestedModel) {
|
|
166
|
-
return model !== undefined &&
|
|
166
|
+
return model !== undefined && model !== requestedModel ? model : undefined;
|
|
167
167
|
}
|
|
168
168
|
/** Assemble the reading's canonical `run_usage`, `billing` and up-to-two `rate_limit` lines
|
|
169
169
|
* (in that order), plus the `switchedTo` model comparison, from scanned records. */
|
|
@@ -79,42 +79,44 @@ export function opencodeSpillDir(root) {
|
|
|
79
79
|
function catalogModelKey(model) {
|
|
80
80
|
return opencodeModelKey(model);
|
|
81
81
|
}
|
|
82
|
-
/** Run `opencode session export <id> --standalone`
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
|
|
82
|
+
/** Run the bounded `opencode session export <id> --standalone` spawn that both the resume
|
|
83
|
+
* existence check and the reconciling export issue, under the same env/cwd and the shared
|
|
84
|
+
* 10 s bound. `capture` is false for the existence check, which reads only the exit status:
|
|
85
|
+
* `stdio: "ignore"` keeps the export — which carries every message of the session — out of
|
|
86
|
+
* memory and below the default 1 MiB `maxBuffer`, whose `ENOBUFS` kill would otherwise make a
|
|
87
|
+
* large but real session read as absent and mint a fresh one. Returns the spawn result, or null
|
|
88
|
+
* when the spawn itself threw — a missing binary or an invalid option, resolved the same way
|
|
89
|
+
* both callers treated a throw. The command line, env, cwd and timeout live here once, so the
|
|
90
|
+
* existence check and the reconciling export cannot drift on what they run. */
|
|
91
|
+
function runSessionExport(id, env, bin, cwd, capture = true) {
|
|
87
92
|
try {
|
|
88
|
-
|
|
93
|
+
return spawnSync(bin, ["session", "export", id, "--standalone"], {
|
|
89
94
|
env,
|
|
90
95
|
cwd,
|
|
91
96
|
timeout: EXPORT_TIMEOUT_MS,
|
|
92
|
-
|
|
97
|
+
encoding: "utf8",
|
|
98
|
+
stdio: capture ? "pipe" : "ignore",
|
|
93
99
|
});
|
|
94
|
-
return res.status === 0;
|
|
95
100
|
}
|
|
96
101
|
catch {
|
|
97
|
-
return
|
|
102
|
+
return null;
|
|
98
103
|
}
|
|
99
104
|
}
|
|
105
|
+
/** Run `opencode session export <id> --standalone` (the same database the run used, bounded at
|
|
106
|
+
* 10 s) and return whether it reported success — the existence check that decides a resume.
|
|
107
|
+
* Nonzero exit, a timeout, or a spawn failure all read as "no such session", which is the safe
|
|
108
|
+
* direction: `--session` would otherwise silently create a missing session. */
|
|
109
|
+
function opencodeSessionExists(id, env, bin, cwd) {
|
|
110
|
+
const res = runSessionExport(id, env, bin, cwd, false);
|
|
111
|
+
return res?.status === 0;
|
|
112
|
+
}
|
|
100
113
|
/** Run the reconciling export after a successful run and return its two canonical lines
|
|
101
114
|
* (`run_cost`, `run_usage`) from the session's `info`. Anything that goes wrong — a spawn or
|
|
102
115
|
* timeout, a nonzero exit, unparseable or malformed output — returns `[]`, and the caller
|
|
103
116
|
* (afterRun) reports `opencode_usage_partial` so the run keeps its streamed numbers. */
|
|
104
117
|
function opencodeExport(result) {
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
res = spawnSync(result.bin, ["session", "export", result.id, "--standalone"], {
|
|
108
|
-
env: result.env,
|
|
109
|
-
cwd: result.cwd,
|
|
110
|
-
timeout: EXPORT_TIMEOUT_MS,
|
|
111
|
-
encoding: "utf8",
|
|
112
|
-
});
|
|
113
|
-
}
|
|
114
|
-
catch {
|
|
115
|
-
return [];
|
|
116
|
-
}
|
|
117
|
-
if (res.status !== 0 || !res.stdout)
|
|
118
|
+
const res = runSessionExport(result.id, result.env, result.bin, result.cwd);
|
|
119
|
+
if (res?.status !== 0 || !res.stdout)
|
|
118
120
|
return [];
|
|
119
121
|
let parsed;
|
|
120
122
|
try {
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import { spawnSync } from "node:child_process";
|
|
10
10
|
import { isJsonObject, validEntries } from "../../files/json-object.js";
|
|
11
11
|
import { readJsonFile, writeJsonAtomic } from "../../files/json-files.js";
|
|
12
|
+
import { cachedByStat } from "../../files/stat-cache.js";
|
|
12
13
|
import { errorMessage } from "../../text/text.js";
|
|
13
14
|
import { DAY_MS } from "../../text/datetime.js";
|
|
14
15
|
import { opencodeModelsPath } from "../../paths.js";
|
|
@@ -21,6 +22,25 @@ const CATALOG_URL = "https://models.dev/api.json";
|
|
|
21
22
|
const CATALOG_FETCH_TIMEOUT_MS = 30_000;
|
|
22
23
|
/** How long a fetched cache is trusted before it is refreshed. */
|
|
23
24
|
const CATALOG_CACHE_TTL_MS = DAY_MS;
|
|
25
|
+
/** Per-catalog cache of the parsed catalog, keyed by the models.json path: the status
|
|
26
|
+
* snapshot and the budget gate both poll it every second while the file changes only when a
|
|
27
|
+
* refresh re-fetches models.dev — and it grows with the models.dev catalog — so an
|
|
28
|
+
* unchanged file costs one stat per poll instead of a re-read plus a JSON.parse and a
|
|
29
|
+
* validation of the whole file (the same stat-keyed contract readOpencodeCache's siblings
|
|
30
|
+
* readCodexCatalog, readPiProviders and readMergeQueue already use). Any write invalidates
|
|
31
|
+
* via dev/ino/mtime/size (stat-cache.cachedByStat's freshness check). */
|
|
32
|
+
const catalogCache = new Map();
|
|
33
|
+
/** A structural copy of a parsed catalog, so a caller that mutates its own result (e.g.
|
|
34
|
+
* observeOpencodeRun sets `billedAt` on an entry) never reaches the shared cache entry; the
|
|
35
|
+
* next poll re-derives from the file. The entries are plain data (`efforts` arrays included)
|
|
36
|
+
* so a shallow model-object spread suffices. */
|
|
37
|
+
function cloneCache(cache) {
|
|
38
|
+
const models = {};
|
|
39
|
+
for (const [key, entry] of Object.entries(cache.models)) {
|
|
40
|
+
models[key] = entry.efforts !== undefined ? { ...entry, efforts: [...entry.efforts] } : { ...entry };
|
|
41
|
+
}
|
|
42
|
+
return { fetchedAt: cache.fetchedAt, cliVersion: cache.cliVersion, models };
|
|
43
|
+
}
|
|
24
44
|
const defaultFetch = async (url, timeoutMs) => {
|
|
25
45
|
const res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) });
|
|
26
46
|
if (!res.ok)
|
|
@@ -160,15 +180,21 @@ function isOpencodeCatalogModel(value) {
|
|
|
160
180
|
* fills the file in. Entries that are not well-formed catalog models are dropped, so a foreign
|
|
161
181
|
* entry reads as a model missing from the cache — not free. */
|
|
162
182
|
export function readOpencodeCache(root) {
|
|
163
|
-
const
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
183
|
+
const file = opencodeModelsPath(root);
|
|
184
|
+
const cached = cachedByStat(catalogCache, file, file, () => {
|
|
185
|
+
// readJsonFile is the shared "missing or torn reads as no data" policy; a torn write
|
|
186
|
+
// or a malformed file reads as an empty cache (fetchedAt 0), exactly as before.
|
|
187
|
+
const existing = readJsonFile(file);
|
|
188
|
+
if (existing !== null && typeof existing.fetchedAt === "number" && isJsonObject(existing.models)) {
|
|
189
|
+
return {
|
|
190
|
+
fetchedAt: existing.fetchedAt,
|
|
191
|
+
cliVersion: typeof existing.cliVersion === "string" ? existing.cliVersion : undefined,
|
|
192
|
+
models: validEntries(existing.models, isOpencodeCatalogModel),
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
return { fetchedAt: 0, models: {} };
|
|
196
|
+
}, cloneCache);
|
|
197
|
+
return cached ?? { fetchedAt: 0, models: {} };
|
|
172
198
|
}
|
|
173
199
|
/** Persist the catalog cache atomically under `.tumwater/state/`. */
|
|
174
200
|
export function writeOpencodeCache(root, cache) {
|
|
@@ -191,6 +191,24 @@ export function pruneSessionLedgers(sessionsRoot, days) {
|
|
|
191
191
|
/** How long one `opencode session delete <id> --standalone` may take before it gives up: the
|
|
192
192
|
* session export measured 0.18 s live, so 10 s is a generous bound on a slow or wedged CLI. */
|
|
193
193
|
const SESSION_DELETE_TIMEOUT_MS = 10_000;
|
|
194
|
+
/** Run one bounded headless CLI session delete (`bin args…`) under `env`, best-effort: a
|
|
195
|
+
* spawn that throws (a missing binary, an unwritable env) returns false without throwing, so
|
|
196
|
+
* a failed delete never fails the prune. Shared by the OpenCode and Codex session purges below
|
|
197
|
+
* so neither drift on the 10 s bound, the status-0 success rule, or the swallow policy. */
|
|
198
|
+
function sessionDeleteSucceeds(bin, args, env) {
|
|
199
|
+
try {
|
|
200
|
+
const res = spawnSync(bin, args, {
|
|
201
|
+
env,
|
|
202
|
+
timeout: SESSION_DELETE_TIMEOUT_MS,
|
|
203
|
+
stdio: "ignore",
|
|
204
|
+
});
|
|
205
|
+
return res.status === 0;
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
// Best effort: an unwritable env or a missing binary must not fail the prune.
|
|
209
|
+
return false;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
194
212
|
/** Delete each OpenCode session the ledger records as expired, with one
|
|
195
213
|
* `opencode session delete <id> --standalone` each (bounded), plus the spill files older than
|
|
196
214
|
* the window in the team's shell spill dir. The session's spill files and server state live
|
|
@@ -209,18 +227,8 @@ export function pruneOpencodeSessions(sessionsRoot, days, opts) {
|
|
|
209
227
|
for (const record of readSessions(path.dirname(file))) {
|
|
210
228
|
if (record.agent !== "opencode" || record.at >= cutoff)
|
|
211
229
|
continue;
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
env: opts.env,
|
|
215
|
-
timeout: SESSION_DELETE_TIMEOUT_MS,
|
|
216
|
-
stdio: "ignore",
|
|
217
|
-
});
|
|
218
|
-
if (res.status === 0)
|
|
219
|
-
deleted += 1;
|
|
220
|
-
}
|
|
221
|
-
catch {
|
|
222
|
-
// Best effort: an unwritable env or a missing binary must not fail the prune.
|
|
223
|
-
}
|
|
230
|
+
if (sessionDeleteSucceeds(opts.bin, ["session", "delete", record.id, "--standalone"], opts.env))
|
|
231
|
+
deleted += 1;
|
|
224
232
|
}
|
|
225
233
|
});
|
|
226
234
|
}
|
|
@@ -276,20 +284,8 @@ export function pruneCodexSessions(sessionsRoot, days, opts) {
|
|
|
276
284
|
for (const id of expired) {
|
|
277
285
|
if (live.has(id))
|
|
278
286
|
continue;
|
|
279
|
-
if (opts.bin !== undefined)
|
|
280
|
-
|
|
281
|
-
const res = spawnSync(opts.bin, ["delete", "--force", id], {
|
|
282
|
-
env: opts.env,
|
|
283
|
-
timeout: SESSION_DELETE_TIMEOUT_MS,
|
|
284
|
-
stdio: "ignore",
|
|
285
|
-
});
|
|
286
|
-
if (res.status === 0)
|
|
287
|
-
deleted += 1;
|
|
288
|
-
}
|
|
289
|
-
catch {
|
|
290
|
-
// Best effort: an unwritable env or a missing binary must not fail the prune.
|
|
291
|
-
}
|
|
292
|
-
}
|
|
287
|
+
if (opts.bin !== undefined && sessionDeleteSucceeds(opts.bin, ["delete", "--force", id], opts.env))
|
|
288
|
+
deleted += 1;
|
|
293
289
|
// Fallback: unlink the home's rollout for this session so neither the ledger line nor
|
|
294
290
|
// its file outlives the window. A real `codex delete` removes the rollout itself, so a
|
|
295
291
|
// successful delete has nothing left to unlink; a failed or skipped delete does, and
|
|
@@ -3,8 +3,7 @@
|
|
|
3
3
|
* "Billing" and §8 "The subscription usage window"). Persisted at
|
|
4
4
|
* `.tumwater/state/<backend>-account.json` (plan design grounds/codex-backend.md "Groundwork:
|
|
5
5
|
* the subscription account is per backend", item 1), written only by the orchestrator process,
|
|
6
|
-
* so the sibling parts
|
|
7
|
-
* of re-deriving it per surface.
|
|
6
|
+
* so the sibling parts read one answer instead of re-deriving it per surface.
|
|
8
7
|
*
|
|
9
8
|
* The file is a cache, never a source of truth: a run that reports billing overwrites it, and a
|
|
10
9
|
* missing or unreadable file reads as "no subscription". `resetsAt` is stored as epoch
|
|
@@ -28,85 +27,6 @@ export function subscriptionWindowRejected(run) {
|
|
|
28
27
|
run.billing === "subscription" &&
|
|
29
28
|
(run.rateWindows ?? []).some((w) => w.status === "rejected" && w.isUsingOverage !== true));
|
|
30
29
|
}
|
|
31
|
-
/** The subscription account's used-up usage window, or null: a window reading `rejected` with
|
|
32
|
-
* no overage, or (with `includeOverage`, the default) a window whose current reading shows
|
|
33
|
-
* `isUsingOverage`, and a `resetsAt` still ahead of `now`; among several, the one that resets
|
|
34
|
-
* last. With a `reserve` (the subscription-window reserve), a window whose utilization has
|
|
35
|
-
* reached its type's fraction counts too and comes back with `reserve: true`; a rejected or
|
|
36
|
-
* overage window outranks a reserve one, so the flag is set only when no window is used up
|
|
37
|
-
* for real. The budget gate's window move (pollBudgetGate) reads the default; the team hold
|
|
38
|
-
* (windowObservation) passes `includeOverage: false`, because overage never holds a
|
|
39
|
-
* seam. Only subscription billing produces one; a missing account reads null. */
|
|
40
|
-
export function windowUsedUp(account, now, includeOverage = true, reserve) {
|
|
41
|
-
if (account?.billing !== "subscription")
|
|
42
|
-
return null;
|
|
43
|
-
let furthest = null;
|
|
44
|
-
let reserved = null;
|
|
45
|
-
for (const [type, w] of Object.entries(account.windows)) {
|
|
46
|
-
if (typeof w.resetsAt !== "number" || w.resetsAt <= now)
|
|
47
|
-
continue;
|
|
48
|
-
const used = w.isUsingOverage === true ? includeOverage : w.status === "rejected";
|
|
49
|
-
if (used) {
|
|
50
|
-
if (furthest === null || w.resetsAt > furthest.resetsAt)
|
|
51
|
-
furthest = { type, resetsAt: w.resetsAt };
|
|
52
|
-
continue;
|
|
53
|
-
}
|
|
54
|
-
if (windowReserved(w, reserve?.[type]) && (reserved === null || w.resetsAt > reserved.resetsAt))
|
|
55
|
-
reserved = { type, resetsAt: w.resetsAt, reserve: true };
|
|
56
|
-
}
|
|
57
|
-
return furthest ?? reserved;
|
|
58
|
-
}
|
|
59
|
-
/** Has this window's utilization reached its reserve fraction? False without a fraction or a
|
|
60
|
-
* reading. */
|
|
61
|
-
export function windowReserved(w, fraction) {
|
|
62
|
-
return fraction !== undefined && typeof w.utilization === "number" && w.utilization >= fraction;
|
|
63
|
-
}
|
|
64
|
-
/** Status severity for ordering: a rejected window limits, an allowed_warning nears its limit,
|
|
65
|
-
* every other reading (allowed/ok) rides at the bottom, and a reset one no longer limits. */
|
|
66
|
-
const WINDOW_RANK = { rejected: 3, allowed_warning: 2, reset: 0 };
|
|
67
|
-
/** The window type as a compact label: `five_hour` → `5h`, `seven_day` → `7d`; an unknown
|
|
68
|
-
* type falls back to its words with underscores as spaces. The one home of the window spelling
|
|
69
|
-
* the status badge and the role view share. */
|
|
70
|
-
export function windowLabel(type) {
|
|
71
|
-
const m = /^(\w+)_(hour|day|week)$/.exec(type);
|
|
72
|
-
if (!m)
|
|
73
|
-
return type.replace(/_/g, " ");
|
|
74
|
-
const words = { one: "1", two: "2", three: "3", four: "4", five: "5", six: "6", seven: "7", eight: "8", nine: "9", ten: "10" };
|
|
75
|
-
const unit = m[2] === "hour" ? "h" : m[2] === "day" ? "d" : "w";
|
|
76
|
-
const word = m[1] ?? "";
|
|
77
|
-
return `${words[word] ?? word}${unit}`;
|
|
78
|
-
}
|
|
79
|
-
/** The fraction of a subscription usage window consumed (0–1) as a whole-number percent: a
|
|
80
|
-
* `0.64` utilization reads `64`. The one home of the fraction→percent conversion, shared by
|
|
81
|
-
* the status badge, the doctor's hold reason and the event formatter so they render a window's
|
|
82
|
-
* fill identically (the browser dashboard's own window line is embedded client-side and keeps
|
|
83
|
-
* its inline copy). */
|
|
84
|
-
export function utilizationPct(utilization) {
|
|
85
|
-
return Math.round(utilization * 100);
|
|
86
|
-
}
|
|
87
|
-
/** Order two window entries by the windowEntries rule: status severity, then the reported
|
|
88
|
-
* utilization (a missing reading sorts below any number), then the earlier reset, then the
|
|
89
|
-
* type name. Exported so a list of per-backend blocks can be ordered by their primary entry
|
|
90
|
-
* the same way windowEntries orders one account's windows (part 3a/9). */
|
|
91
|
-
export function compareWindowEntries(a, b) {
|
|
92
|
-
const rank = (WINDOW_RANK[b.status] ?? 1) - (WINDOW_RANK[a.status] ?? 1);
|
|
93
|
-
if (rank !== 0)
|
|
94
|
-
return rank;
|
|
95
|
-
const util = (b.utilization ?? -1) - (a.utilization ?? -1);
|
|
96
|
-
if (util !== 0)
|
|
97
|
-
return util;
|
|
98
|
-
const reset = (a.resetsAt ?? Number.POSITIVE_INFINITY) - (b.resetsAt ?? Number.POSITIVE_INFINITY);
|
|
99
|
-
if (reset !== 0)
|
|
100
|
-
return reset;
|
|
101
|
-
return a.type.localeCompare(b.type);
|
|
102
|
-
}
|
|
103
|
-
/** The account's windows as a list, most constrained first (by compareWindowEntries). Empty
|
|
104
|
-
* when the account reported none. */
|
|
105
|
-
export function windowEntries(account) {
|
|
106
|
-
return Object.entries(account.windows)
|
|
107
|
-
.map(([type, w]) => ({ type, ...w }))
|
|
108
|
-
.sort((a, b) => compareWindowEntries(a, b));
|
|
109
|
-
}
|
|
110
30
|
/** Read the persisted account state for a backend, or null when it is absent or unreadable. A
|
|
111
31
|
* file without a usable top-level `observedAt` reads as absent (the plan's torn-file rule),
|
|
112
32
|
* and a window whose `resetsAt` is at or before `now` reads back with `status: "reset"` — the
|
|
@@ -144,8 +64,7 @@ function readWindow(value, fileObservedAt, now) {
|
|
|
144
64
|
const resetsAt = finiteNumber(value.resetsAt, Number.NaN);
|
|
145
65
|
const resets = Number.isFinite(resetsAt);
|
|
146
66
|
// utilization heals like the two stamps above: JSON's `1e999` parses to Infinity, and a
|
|
147
|
-
// non-finite fraction would
|
|
148
|
-
// windowEntries comparator (Infinity - Infinity is NaN).
|
|
67
|
+
// non-finite fraction would poison any consumer's utilization read.
|
|
149
68
|
const utilization = finiteNumber(value.utilization, undefined);
|
|
150
69
|
const reset = resets && resetsAt <= now;
|
|
151
70
|
const window = {
|
|
@@ -319,10 +319,9 @@ function archiveCommitWhat(plan) {
|
|
|
319
319
|
}
|
|
320
320
|
/** One live backlog file's finished-history rows as liveFinishedHistory reports them, phrased
|
|
321
321
|
* and joined: `file ## <section> holds N finished entries (X KB)` rows separated by "; " — the
|
|
322
|
-
*
|
|
323
|
-
*
|
|
324
|
-
*
|
|
325
|
-
* explanation, so the two texts stay byte-identical up to that suffix. */
|
|
322
|
+
* detail head the doctor's `backlog archive` check (checkLiveFinished) renders, so the row
|
|
323
|
+
* shape, the plural, and the KB rounding stay stable. Each caller appends its own trailing
|
|
324
|
+
* explanation. */
|
|
326
325
|
export function liveFinishedRowList(rows) {
|
|
327
326
|
return rows.map((l) => `${l.file} ## ${l.section} holds ${plural(l.entries, "finished entry", "finished entries")} (${Math.round(l.bytes / 1024)} KB)`).join("; ");
|
|
328
327
|
}
|
|
@@ -54,11 +54,10 @@ export function needsReviewNote(body) {
|
|
|
54
54
|
* for the bugfix role's wait-for-the-next-failure note ("by bugfix: …"), which parks the
|
|
55
55
|
* entry for selfcheck to lift when the cluster fails again (selfcheck step 4) and which no
|
|
56
56
|
* role can act on until then. entryHold returns "needs-review" for every such note (all of
|
|
57
|
-
* them park the entry), so the stalled
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
* the doctor row cannot disagree on which notes the plan role clears; the eligibility mark
|
|
57
|
+
* them park the entry), so the doctor's `stalled entries` row (checkStalledEntries) — the
|
|
58
|
+
* stalled-with-the-plan-role surface — classifies by this agreed subset, keeping the bugfix
|
|
59
|
+
* parks out of the plan-role blame. Reads the note's text after the NEEDS_REVIEW_PREFIX once,
|
|
60
|
+
* so the row cannot disagree on which notes the plan role clears; the eligibility mark
|
|
62
61
|
* (holdMark) continues to hold every needs-review note, a by-bugfix park included. */
|
|
63
62
|
export function isPlanStalled(entry) {
|
|
64
63
|
const note = needsReviewNote(entry.body);
|
|
@@ -279,49 +278,28 @@ export function backlogHolds(root) {
|
|
|
279
278
|
* `tumwater backlog` prints. A surface derives its marks from the one hold decision and the
|
|
280
279
|
* one claim lookup rather than re-walking the entries. An eligible entry nobody is working on
|
|
281
280
|
* renders as the empty string, so a client can append the mark without a separate check.
|
|
282
|
-
* Questions carry no holds. backlogHolds alone still drives the allocator.
|
|
283
|
-
|
|
281
|
+
* Questions carry no holds. backlogHolds alone still drives the allocator. A caller that
|
|
282
|
+
* already resolved the claim lookup (for the same poll's marks AND its own parallel checks,
|
|
283
|
+
* e.g. statusPayload's `working` or the TUI's `runners`) passes it as `working` so the claims
|
|
284
|
+
* walk is done once, not re-run inside both calls — the same shared single read the module's
|
|
285
|
+
* other single-home polls observe. */
|
|
286
|
+
export function backlogMarks(root, working) {
|
|
284
287
|
const holds = backlogHolds(root);
|
|
285
|
-
const
|
|
288
|
+
const inProgress = working ?? backlogInProgress(root);
|
|
286
289
|
const mark = (hold, runner) => holdMark(hold) + inProgressMark(runner ?? null);
|
|
287
290
|
return {
|
|
288
|
-
plans: holds.plans.map((h, i) => mark(h,
|
|
289
|
-
bugs: holds.bugs.map((h, i) => mark(h,
|
|
291
|
+
plans: holds.plans.map((h, i) => mark(h, inProgress.plans[i])),
|
|
292
|
+
bugs: holds.bugs.map((h, i) => mark(h, inProgress.bugs[i])),
|
|
290
293
|
};
|
|
291
294
|
}
|
|
292
295
|
/** The display noun for a backlog-file discriminator — the `"plans" | "bugs"` `file` on
|
|
293
|
-
* RefusedEntry
|
|
296
|
+
* RefusedEntry (and the same discriminator the prompt's named-hold wording
|
|
294
297
|
* and the doctor's Stalled row take): BUGS.md is a "Bug" and PLANS.md a "Plan", and the
|
|
295
298
|
* lowercase form is the `Refused plan|bug:` label and the `tumwater plan|bug --release`
|
|
296
299
|
* command noun. */
|
|
297
300
|
export function entryKindLabel(file) {
|
|
298
301
|
return file === "bugs" ? "Bug" : "Plan";
|
|
299
302
|
}
|
|
300
|
-
/** The planned plans and open bugs parked with the plan role — whose Needs-review hold the plan
|
|
301
|
-
* role clears by splitting or replanning (the feature role's too-large note, the convergence hold,
|
|
302
|
-
* a final-round rejection), in file order: the `stalled-entries` team alert's input, each
|
|
303
|
-
* carrying its backlog file, 0-based index and the hold. Same `backlogHolds(root)` + the
|
|
304
|
-
* planned/`openBugEntries` readers `refusedEntries` uses, so the alert and the doctor's
|
|
305
|
-
* `stalled entries` row classify through the same `entryHold` + `isPlanStalled` and cannot
|
|
306
|
-
* disagree on which holds are role-resolvable. The bugfix role's wait-for-the-next-failure
|
|
307
|
-
* parks ("by bugfix:") are excluded: selfcheck lifts them, not the plan role. A missing
|
|
308
|
-
* file yields `[]`. */
|
|
309
|
-
export function stalledEntries(root) {
|
|
310
|
-
const holds = backlogHolds(root);
|
|
311
|
-
const out = [];
|
|
312
|
-
const entriesOf = (entries, held, file) => {
|
|
313
|
-
entries.forEach((e, i) => {
|
|
314
|
-
const hold = held[i];
|
|
315
|
-
// Only holds the plan role can clear surface as stalled: the bugfix role's
|
|
316
|
-
// wait-for-the-next-failure parks stay out of the plan-role blame.
|
|
317
|
-
if (hold === "needs-review" && isPlanStalled(e))
|
|
318
|
-
out.push({ file, index: i, title: e.title, hold });
|
|
319
|
-
});
|
|
320
|
-
};
|
|
321
|
-
entriesOf(plannedPlanEntries(root), holds.plans, "plans");
|
|
322
|
-
entriesOf(openBugEntries(root), holds.bugs, "bugs");
|
|
323
|
-
return out;
|
|
324
|
-
}
|
|
325
303
|
/** The planned plans, then the open bugs, whose hold is `"refused"`, in file order: the
|
|
326
304
|
* entries that wait on an operator decision (the `refused` team alert's input), each carrying
|
|
327
305
|
* the backlog file and its 0-based position so a surface opening the row can land on it.
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* output helpers, so every surface can drive it without ending the process.
|
|
8
8
|
*
|
|
9
9
|
* Shared by every read/answer surface: the CLI's `tumwater questions` command body wraps it in
|
|
10
|
-
* src/cli/question-commands.ts (the say/fail/editor layer), while the TUI's answer box
|
|
10
|
+
* src/cli/cli-question-commands.ts (the say/fail/editor layer), while the TUI's answer box
|
|
11
11
|
* (src/ui/tui/tui-keys.ts) and the dashboard's POST route (src/gui/gui-endpoint-commands.ts)
|
|
12
12
|
* import the non-exiting answer core directly. It lives with the rest of the BUGS/PLANS/QUESTIONS
|
|
13
13
|
* markdown (src/backlog/), not under src/cli/, so the non-command surfaces never depend on the
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import { cachedTextByStat } from "../files/stat-cache.js";
|
|
3
|
+
import { DAY_MS } from "../text/datetime.js";
|
|
3
4
|
import { entryDates, entryLags, finishedArchiveOf, firstDate, lastDate, parseEntryDetails } from "./backlog-md.js";
|
|
4
5
|
import { CHECK_DECLARATION_MARKER } from "../roles/role-guidance.js";
|
|
5
6
|
/** The stat-cached file readers of the project backlog data shown on both dashboards: planned
|
|
@@ -104,7 +105,7 @@ export function completionDate(title, dateRe) {
|
|
|
104
105
|
/** Whole days between a filing stamp and `now`, floored at 0 so a future-dated entry reads as
|
|
105
106
|
* fresh (0d) rather than negative. Shared by the suffix and the `--list --json` `ageDays` field. */
|
|
106
107
|
export function ageInDays(since, now = Date.now()) {
|
|
107
|
-
return Math.max(0, Math.floor((now - Date.parse(since)) /
|
|
108
|
+
return Math.max(0, Math.floor((now - Date.parse(since)) / DAY_MS));
|
|
108
109
|
}
|
|
109
110
|
/** The ` · since YYYY-MM-DD (Nd)`/`(today)` tail a listing line gains for its entry's filing
|
|
110
111
|
* stamp — the one place the wording and the today/`Nd` choice live, so the plans/bugs list
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* Both take an injected `now` so tests never read the clock. */
|
|
14
14
|
import { readEventsSince } from "../events/event-window.js";
|
|
15
15
|
import { loadQuarantine } from "../review/known-flakes.js";
|
|
16
|
-
import { DAY_MS, clockHm, humanAgo } from "../text/datetime.js";
|
|
16
|
+
import { DAY_MS, HOUR_MS, clockHm, humanAgo } from "../text/datetime.js";
|
|
17
17
|
import { shortSha } from "../text/format.js";
|
|
18
18
|
import { roleLabel } from "../roles/role-labels.js";
|
|
19
19
|
import { plural } from "../text/phrases.js";
|
|
@@ -193,5 +193,5 @@ function redDurationPhrase(ms) {
|
|
|
193
193
|
const minutes = Math.round(ms / 60_000);
|
|
194
194
|
if (minutes < 24 * 60)
|
|
195
195
|
return `${minutes} min`;
|
|
196
|
-
return `${Math.round(ms /
|
|
196
|
+
return `${Math.round(ms / HOUR_MS)} h`;
|
|
197
197
|
}
|