@worca/app 1.3.0 → 1.4.0-rc.1

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.
Files changed (187) hide show
  1. package/README.md +85 -6
  2. package/agents/clarify.meta.json +1 -0
  3. package/agents/memoryDefragmenter.meta.json +2 -1
  4. package/agents/reviewer.meta.json +60 -0
  5. package/agents/worca-cc-code-reviewer.md +33 -0
  6. package/agents/worca-cc-memory-defragmenter.md +5 -3
  7. package/agents/workspaceScanner.meta.json +1 -0
  8. package/package.json +14 -10
  9. package/scripts/git-diff.mjs +25 -0
  10. package/scripts/gitDiff.meta.json +18 -0
  11. package/scripts/js-inline.mjs +11 -0
  12. package/scripts/js.meta.json +22 -0
  13. package/scripts/py-inline.py +27 -0
  14. package/scripts/py.meta.json +22 -0
  15. package/scripts/shell.meta.json +24 -0
  16. package/skills/worca/SKILL.md +3 -2
  17. package/src/cli/models.mjs +247 -0
  18. package/src/cli/render.mjs +72 -4
  19. package/src/cli/schedule.mjs +494 -0
  20. package/src/cli/worca-cc.mjs +1001 -22
  21. package/src/core/agent-registry.mjs +75 -23
  22. package/src/core/agent-store.mjs +51 -2
  23. package/src/core/artifacts.mjs +73 -10
  24. package/src/core/ask/events.mjs +119 -1
  25. package/src/core/ask/limits.mjs +32 -4
  26. package/src/core/ask/mcp-stdio.mjs +12 -0
  27. package/src/core/ask/model-deps.mjs +126 -0
  28. package/src/core/ask/model-proposal.mjs +370 -0
  29. package/src/core/ask/models.mjs +12 -0
  30. package/src/core/ask/policy-deps.mjs +124 -0
  31. package/src/core/ask/policy-proposal.mjs +363 -0
  32. package/src/core/ask/prompt.mjs +74 -9
  33. package/src/core/ask/proposal.mjs +54 -5
  34. package/src/core/ask/schedule-deps.mjs +83 -0
  35. package/src/core/ask/schedule-spec.mjs +310 -0
  36. package/src/core/ask/script-deps.mjs +357 -0
  37. package/src/core/ask/source-deps.mjs +52 -0
  38. package/src/core/ask/source-spec.mjs +157 -0
  39. package/src/core/ask/spawn.mjs +1 -0
  40. package/src/core/ask/store.mjs +6 -3
  41. package/src/core/ask/tool-deps.mjs +4 -0
  42. package/src/core/ask/tools.mjs +657 -37
  43. package/src/core/ask/turn.mjs +109 -2
  44. package/src/core/ask-files.mjs +406 -0
  45. package/src/core/ask-forms.mjs +195 -0
  46. package/src/core/ask-projection.mjs +72 -0
  47. package/src/core/bridge/errors.mjs +84 -0
  48. package/src/core/bridge/provider-ops.mjs +281 -0
  49. package/src/core/bridge/providers/copilot.mjs +269 -0
  50. package/src/core/bridge/providers/endpoint.mjs +257 -0
  51. package/src/core/bridge/registry.mjs +88 -0
  52. package/src/core/bridge/semaphore.mjs +73 -0
  53. package/src/core/bridge/server.mjs +184 -0
  54. package/src/core/bridge/telemetry.mjs +53 -0
  55. package/src/core/bridge/translate/request.mjs +252 -0
  56. package/src/core/bridge/translate/response.mjs +82 -0
  57. package/src/core/bridge/translate/stream.mjs +242 -0
  58. package/src/core/bridge/upstream.mjs +209 -0
  59. package/src/core/chat/command-router.mjs +58 -4
  60. package/src/core/chat/notifier.mjs +14 -1
  61. package/src/core/chat/renderers.mjs +35 -0
  62. package/src/core/claude-runner.mjs +126 -19
  63. package/src/core/config.mjs +212 -31
  64. package/src/core/cost-budget.mjs +3 -2
  65. package/src/core/db.mjs +169 -15
  66. package/src/core/failure-policy.mjs +10 -0
  67. package/src/core/fs-browse.mjs +16 -4
  68. package/src/core/git-info.mjs +22 -0
  69. package/src/core/graph/builtin-workflows.mjs +3 -1
  70. package/src/core/graph/exec-io.mjs +71 -0
  71. package/src/core/graph/executor.mjs +139 -70
  72. package/src/core/graph/human-evidence.mjs +131 -0
  73. package/src/core/graph/python-probe.mjs +172 -0
  74. package/src/core/graph/registry-ports.mjs +10 -6
  75. package/src/core/graph/scheduler.mjs +39 -24
  76. package/src/core/graph/script-child.mjs +81 -0
  77. package/src/core/graph/script-runner.mjs +597 -0
  78. package/src/core/graph/worca_script.py +207 -0
  79. package/src/core/guardrail-store.mjs +16 -0
  80. package/src/core/human-backfill.mjs +108 -0
  81. package/src/core/human-rate.mjs +17 -0
  82. package/src/core/index-html.mjs +6 -2
  83. package/src/core/memory-defrag-model.mjs +112 -0
  84. package/src/core/memory-store.mjs +70 -18
  85. package/src/core/memory-sync.mjs +22 -11
  86. package/src/core/metrics/read.mjs +4 -1
  87. package/src/core/metrics/record.mjs +50 -2
  88. package/src/core/metrics/sync.mjs +6 -4
  89. package/src/core/model-env.mjs +149 -0
  90. package/src/core/model-test.mjs +14 -1
  91. package/src/core/notifications.mjs +128 -0
  92. package/src/core/onboarding.mjs +8 -2
  93. package/src/core/orchestrator.mjs +357 -26
  94. package/src/core/phases.mjs +95 -6
  95. package/src/core/plugin-api.mjs +24 -7
  96. package/src/core/plugin-manifest.mjs +184 -18
  97. package/src/core/plugin-models.mjs +1 -0
  98. package/src/core/plugin-script-cases.mjs +118 -0
  99. package/src/core/plugin-store.mjs +163 -17
  100. package/src/core/plugin-workflows.mjs +71 -17
  101. package/src/core/policy/cache.mjs +116 -0
  102. package/src/core/policy/effective.mjs +175 -0
  103. package/src/core/policy/gate.mjs +91 -0
  104. package/src/core/policy/local.mjs +145 -0
  105. package/src/core/policy/registry.mjs +330 -0
  106. package/src/core/policy/scope.mjs +61 -0
  107. package/src/core/policy/state.mjs +79 -0
  108. package/src/core/policy/sync.mjs +513 -0
  109. package/src/core/protocol.mjs +43 -0
  110. package/src/core/run-harness.mjs +421 -72
  111. package/src/core/scheduler.mjs +980 -0
  112. package/src/core/script-bench.mjs +628 -0
  113. package/src/core/script-registry.mjs +116 -0
  114. package/src/core/script-store.mjs +563 -0
  115. package/src/core/settings.mjs +489 -14
  116. package/src/core/stats.mjs +33 -2
  117. package/src/core/workflow-export.mjs +94 -3
  118. package/src/core/workflow-share.mjs +67 -18
  119. package/src/core/workflows.mjs +47 -11
  120. package/src/core/workspaces.mjs +18 -12
  121. package/src/shared/forms/answer.mjs +164 -0
  122. package/src/shared/forms/catalog.mjs +91 -0
  123. package/src/shared/forms/form-def.mjs +290 -0
  124. package/src/shared/forms/layout.mjs +67 -0
  125. package/src/shared/forms/paths.mjs +47 -0
  126. package/src/shared/forms/project.mjs +309 -0
  127. package/src/shared/forms/schema.mjs +205 -0
  128. package/src/shared/graph/agent-meta.mjs +55 -5
  129. package/src/shared/graph/constants.mjs +14 -2
  130. package/src/shared/graph/flow-layout.mjs +2 -1
  131. package/src/shared/graph/isomorphic.mjs +5 -3
  132. package/src/shared/graph/manifest.mjs +22 -13
  133. package/src/shared/graph/ports.mjs +45 -19
  134. package/src/shared/graph/script-cases.mjs +257 -0
  135. package/src/shared/graph/script-icons.mjs +46 -0
  136. package/src/shared/graph/script-infer.mjs +259 -0
  137. package/src/shared/graph/script-meta.mjs +408 -0
  138. package/src/shared/graph/script-templates.mjs +201 -0
  139. package/src/shared/graph/template.mjs +4 -4
  140. package/src/shared/graph/validate.mjs +89 -16
  141. package/src/shared/human-estimate.mjs +100 -0
  142. package/src/shared/schedule/recurrence.mjs +353 -0
  143. package/src/shared/team-metrics/aggregate.mjs +51 -11
  144. package/{scripts → tools}/install.mjs +3 -3
  145. package/ui/public/app.js +4390 -683
  146. package/ui/public/artifact-picker.mjs +189 -0
  147. package/ui/public/ask/dom.mjs +121 -0
  148. package/ui/public/ask/form-preview.mjs +55 -0
  149. package/ui/public/ask/form-renderer.mjs +250 -0
  150. package/ui/public/ask/registry.mjs +53 -0
  151. package/ui/public/ask/widgets-display.mjs +370 -0
  152. package/ui/public/ask/widgets-input.mjs +624 -0
  153. package/ui/public/ask/widgets-layout.mjs +90 -0
  154. package/ui/public/ask-panel.mjs +401 -27
  155. package/ui/public/ask-run-card.mjs +1 -1
  156. package/ui/public/bridge-view.mjs +694 -0
  157. package/ui/public/chat-settings-view.mjs +24 -0
  158. package/ui/public/code-editor.mjs +181 -0
  159. package/ui/public/getting-started.mjs +34 -7
  160. package/ui/public/graph/composer.mjs +138 -10
  161. package/ui/public/graph/inspector.mjs +61 -58
  162. package/ui/public/graph/palette.mjs +27 -9
  163. package/ui/public/graph/run-decor.mjs +34 -17
  164. package/ui/public/graph/run-hosts.mjs +7 -1
  165. package/ui/public/graph/save-dialog.mjs +3 -0
  166. package/ui/public/graph/view.mjs +23 -7
  167. package/ui/public/guardrails-view.mjs +15 -3
  168. package/ui/public/guide-spot.mjs +87 -9
  169. package/ui/public/index.html +480 -141
  170. package/ui/public/memory-view.mjs +22 -4
  171. package/ui/public/models-view.mjs +162 -17
  172. package/ui/public/node-tunables.mjs +33 -4
  173. package/ui/public/plugins-view.mjs +23 -1
  174. package/ui/public/results-view.mjs +4 -2
  175. package/ui/public/schedule-sheet.mjs +430 -0
  176. package/ui/public/schedules-view.mjs +432 -0
  177. package/ui/public/script-bench-view.mjs +1154 -0
  178. package/ui/public/script-forms.mjs +282 -0
  179. package/ui/public/script-wizard.mjs +529 -0
  180. package/ui/public/scripts-view.mjs +868 -0
  181. package/ui/public/stats-view.mjs +159 -52
  182. package/ui/public/style.css +1461 -44
  183. package/ui/public/team-metrics-surfaces.mjs +77 -16
  184. package/ui/public/team-metrics-view.mjs +68 -5
  185. package/ui/public/team-policy-view.mjs +1402 -0
  186. package/ui/public/ui-level.mjs +237 -0
  187. package/ui/server.mjs +1827 -73
@@ -6,9 +6,11 @@
6
6
  // tree. app.js owns endpoint calls and mounting; node:test drives these via
7
7
  // jsdom. Interactive elements carry data-* + routing classes (cb-override,
8
8
  // cb-settings, ch-hit, data-nav) so app.js wires delegated listeners.
9
- // Formatters are injected via opts.fmt = { usd, usd4, duration, estTitle };
9
+ // Formatters are injected via opts.fmt = { usd, usd4, duration, hours, estTitle };
10
10
  // DEFAULT_FMT keeps pure tests standalone.
11
11
 
12
+ import { renderTeamCapPauseBanner, POLICY_PAUSE_REASONS } from './team-policy-view.mjs';
13
+
12
14
  const SVG_NS = 'http://www.w3.org/2000/svg';
13
15
 
14
16
  /** Spend meter turns amber at 80% of the total limit (display-only). */
@@ -17,9 +19,13 @@ export const BUDGET_WARN_AT = 0.8;
17
19
  const WD = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
18
20
  const MO = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
19
21
 
22
+ // en-US grouping ($10,456.12), the same shape Team metrics prints (TM_FMT.usd); app.js's fmtUsd matches.
23
+ const USD_2DP = { minimumFractionDigits: 2, maximumFractionDigits: 2 };
24
+ const USD_4DP = { minimumFractionDigits: 4, maximumFractionDigits: 4 };
25
+
20
26
  export const DEFAULT_FMT = {
21
- usd: (n) => `$${(Math.round(((n || 0) + Number.EPSILON) * 100) / 100).toFixed(2)}`,
22
- usd4: (n) => `$${(n || 0).toFixed(4)}`,
27
+ usd: (n) => `$${(Math.round(((n || 0) + Number.EPSILON) * 100) / 100).toLocaleString('en-US', USD_2DP)}`,
28
+ usd4: (n) => `$${(n || 0).toLocaleString('en-US', USD_4DP)}`,
23
29
  duration: (ms) => {
24
30
  const s = Math.floor((ms || 0) / 1000);
25
31
  if (s < 60) return `${s}s`;
@@ -27,7 +33,11 @@ export const DEFAULT_FMT = {
27
33
  if (m < 60) return `${m}m ${s % 60}s`;
28
34
  return `${Math.floor(m / 60)}h ${m % 60}m`;
29
35
  },
30
- estTitle: (n) => `Estimated cost $${(n || 0).toFixed(4)} — Claude Code client-side estimate (total_cost_usd), not authoritative billing`,
36
+ hours: (h) => {
37
+ const v = Math.round(((h || 0) + Number.EPSILON) * 10) / 10;
38
+ return `${v.toLocaleString('en-US', { maximumFractionDigits: 1 })} h`;
39
+ },
40
+ estTitle: (n) => `Estimated cost $${(n || 0).toLocaleString('en-US', USD_4DP)} — Claude Code client-side estimate (total_cost_usd), not authoritative billing`,
31
41
  };
32
42
 
33
43
  function h(doc, tag, cls, text) {
@@ -60,6 +70,8 @@ const ICONS = {
60
70
  ask: 'M12 19.6l-3.2-2.8H6.4A3.4 3.4 0 0 1 3 13.4V7.8a3.4 3.4 0 0 1 3.4-3.4h11.2A3.4 3.4 0 0 1 21 7.8v5.6a3.4 3.4 0 0 1-3.4 3.4h-2.4L12 19.6Z',
61
71
  time: 'M12 8v4l3 2M12 20a8 8 0 1 1 0-16 8 8 0 0 1 0 16Z',
62
72
  finished: 'M20 7 9 18l-5-5',
73
+ // Money bag resting on an open hand (not a board glyph): bag tie, knot, body; thumb, fingers, cuff.
74
+ saved: 'M9.6 4.6h3.8 M9.9 4.6c.3-1.5 2.9-1.5 3.2 0 M9.6 4.6C7.8 6.2 6.8 7.8 6.8 9.4c0 1.6.6 2.9 1.4 3.8h6.6c.8-.9 1.4-2.2 1.4-3.8 0-1.6-1-3.2-2.8-4.8 M8.2 13.2c-.5 0-1 .2-1.3.6L3 17.6 M7 21l1.6-1.4c.3-.4.8-.6 1.4-.6h4c1.1 0 2.1-.4 2.8-1.2l4.6-4.4a2 2 0 0 0-2.75-2.91L14.8 13.2 M2 16.6l6 6',
63
75
  prs: 'M6 8.6v6.8M18 15.4V11a4 4 0 0 0-4-4h-2M6 3.4a2.6 2.6 0 1 1 0 5.2 2.6 2.6 0 0 1 0-5.2ZM6 15.4a2.6 2.6 0 1 1 0 5.2 2.6 2.6 0 0 1 0-5.2ZM18 15.4a2.6 2.6 0 1 1 0 5.2 2.6 2.6 0 0 1 0-5.2Z',
64
76
  };
65
77
 
@@ -87,6 +99,19 @@ function deltaChip(doc, cur, prevVal, range) {
87
99
  return chip;
88
100
  }
89
101
 
102
+ /** "9.0× spend" pill: Saved ÷ spent over the same window. Null unless both are positive — a
103
+ * loss or a free window has no multiplier worth printing. One decimal below 10×, a whole
104
+ * number from 10× up (rounded to the decimal band first, so 9.96 prints "10×", never "10.0×"). */
105
+ export function savedMultChip(doc, saved, spent) {
106
+ if (!(saved > 0) || !(spent > 0)) return null;
107
+ const ratio = saved / spent;
108
+ const tenth = Math.round(ratio * 10) / 10;
109
+ const text = tenth >= 10 ? Math.round(ratio).toLocaleString('en-US') : tenth.toFixed(1);
110
+ const chip = h(doc, 'span', 'stat-mult', `${text}× spend`);
111
+ chip.title = 'Saved ÷ spent in this period';
112
+ return chip;
113
+ }
114
+
90
115
  // Numeric tokens in tile sub-lines ($50.00, 3d 4h, bare counts) get <b> so the
91
116
  // figures read at a glance; surrounding prose stays plain. Unit groups like
92
117
  // "3d 4h" bold as one token.
@@ -162,6 +187,24 @@ export function renderKpiRow(model, { doc = globalThis.document, fmt = DEFAULT_F
162
187
  title: fmt.estTitle(totals.spentUsd),
163
188
  }));
164
189
 
190
+ // Saved (money-saved design §10): hours × rate − spent. Negative is a real outcome. No
191
+ // delta pill here: the "× spend" multiplier is the tile's one pill (a week-over-week swing in
192
+ // a derived figure read as noise next to it — dropped 2026-09-22).
193
+ const fmtHours = fmt.hours || DEFAULT_FMT.hours;
194
+ const saved = Number(totals.savedUsd || 0);
195
+ const savedTile = tile(doc, {
196
+ iconD: ICONS.saved, label: 'Saved',
197
+ chip: null,
198
+ valueNodes: [doc.createTextNode(`${saved < 0 ? '−' : ''}${fmt.usd(Math.abs(saved))}`)],
199
+ sub: `≈ ${fmtHours(totals.humanHours)} of human work`,
200
+ });
201
+ savedTile.querySelector('.stat-value').classList.toggle('is-neg', saved < 0);
202
+ savedTile.querySelector('.stat-value').classList.toggle('is-pos', saved > 0);
203
+ // Far right of the label row: how many times over the spend paid for itself.
204
+ const mult = savedMultChip(doc, saved, totals.spentUsd);
205
+ if (mult) savedTile.querySelector('.stat-label').appendChild(mult);
206
+ row.appendChild(savedTile);
207
+
165
208
  // Pipeline spend (D7): pipeline-only money; sub = share of the combined
166
209
  // total. Falls back to spentUsd when the payload predates the ask split.
167
210
  const pipeSpend = totals.pipelineSpendUsd ?? totals.spentUsd;
@@ -221,80 +264,141 @@ export function renderKpiRow(model, { doc = globalThis.document, fmt = DEFAULT_F
221
264
  return row;
222
265
  }
223
266
 
224
- /** Sidebar spend indicator (whole block navigates to #stats). */
267
+ /** The payload's "Saved this <period>" figure, or null when it has none: /api/budget
268
+ * degrades it to null when the savings read fails, and a server that predates the
269
+ * field sends nothing — either way the indicator shows Spent alone rather than "$0". */
270
+ function windowSaved(b) {
271
+ return typeof b.windowSavedUsd === 'number' && Number.isFinite(b.windowSavedUsd)
272
+ ? b.windowSavedUsd : null;
273
+ }
274
+
275
+ /** Signed money in the Statistics Saved tile's own shape: "−$12.50", never "$-12.50". */
276
+ const signedUsd = (fmt, n) => `${n < 0 ? '−' : ''}${fmt.usd(Math.abs(n))}`;
277
+
278
+ /** Where "Saved" comes from, appended to both sidebar titles when the figure shows. */
279
+ const SAVED_NOTE = 'Saved = estimated human hours × your rate − spent';
280
+
281
+ function indRow(doc, label, amount) {
282
+ const row = h(doc, 'span', 'spend-ind-row');
283
+ row.appendChild(h(doc, 'span', 'spend-ind-label', label));
284
+ row.appendChild(h(doc, 'span', 'spend-ind-amt mono', amount));
285
+ return row;
286
+ }
287
+
288
+ /** Sidebar spend indicator (whole block navigates to #stats). With a total limit: Spent +
289
+ * meter. Without one there is no meter to show, so the second row states what the period
290
+ * saved instead — the old "no total limit" note said only what was missing. */
225
291
  export function renderBudgetIndicator(budget, { doc = globalThis.document, fmt = DEFAULT_FMT } = {}) {
226
292
  const b = budget || {};
227
293
  const btn = h(doc, 'button', 'spend-ind');
228
294
  btn.type = 'button';
229
295
  btn.dataset.nav = 'stats';
230
- const ratio = b.totalLimitUsd != null ? b.windowSpendUsd / b.totalLimitUsd : 0;
296
+ const hasLimit = b.totalLimitUsd != null;
297
+ const ratio = hasLimit ? b.windowSpendUsd / b.totalLimitUsd : 0;
298
+ const saved = hasLimit ? null : windowSaved(b);
231
299
  if (b.blocked) btn.classList.add('over');
232
- else if (b.totalLimitUsd != null && ratio >= BUDGET_WARN_AT) btn.classList.add('warn');
300
+ else if (hasLimit && ratio >= BUDGET_WARN_AT) btn.classList.add('warn');
233
301
  btn.title = `Estimated spend this ${periodWord(b)}: ${fmt.usd4(b.windowSpendUsd)}` +
234
- (b.totalLimitUsd != null ? ` of ${fmt.usd(b.totalLimitUsd)}` : '') +
235
- ` · resets ${fmtResetAt(b.windowEndMs)} — Claude Code client-side estimate (total_cost_usd), not authoritative billing`;
236
- const rowEl = h(doc, 'span', 'spend-ind-row');
237
- rowEl.appendChild(h(doc, 'span', 'spend-ind-label', `Spent this ${periodWord(b)}`));
238
- rowEl.appendChild(h(doc, 'span', 'spend-ind-amt mono', fmt.usd(b.windowSpendUsd)));
239
- btn.appendChild(rowEl);
240
- if (b.totalLimitUsd != null) {
302
+ (hasLimit ? ` of ${fmt.usd(b.totalLimitUsd)}` : '') +
303
+ ` · resets ${fmtResetAt(b.windowEndMs)} — Claude Code client-side estimate (total_cost_usd), not authoritative billing` +
304
+ (saved != null ? `. Saved this ${periodWord(b)}: ${signedUsd(fmt, saved)} (${SAVED_NOTE})` : '');
305
+ btn.appendChild(indRow(doc, `Spent this ${periodWord(b)}`, fmt.usd(b.windowSpendUsd)));
306
+ if (hasLimit) {
241
307
  btn.appendChild(meterEl(doc, 'spend-ind-meter', b.blocked ? 100 : ratio * 100));
242
308
  if (b.blocked) btn.appendChild(h(doc, 'small', 'spend-ind-sub', 'limit reached · new runs blocked'));
243
- } else {
244
- btn.appendChild(h(doc, 'small', 'spend-ind-sub', 'no total limit'));
309
+ } else if (saved != null) {
310
+ // A gain is green (.pos → --green-ink-strong, which clears verify:theme's 4.5:1 on the
311
+ // card's hover fill). A loss stays neutral ink — --red-ink is 4.07:1 there — and its
312
+ // "−" sign carries it.
313
+ const row = indRow(doc, `Saved this ${periodWord(b)}`, signedUsd(fmt, saved));
314
+ row.classList.add('spend-ind-saved');
315
+ if (saved >= 0) row.classList.add('pos');
316
+ btn.appendChild(row);
245
317
  }
246
318
  return btn;
247
319
  }
248
320
 
249
- /** Compact centre label for the ring: $4 · $317 · $3k · $12k. Four glyphs is
250
- * what fits inside a 29px disc at 9.5px mono, so thousands are WHOLE — "$8.8k"
251
- * is five glyphs and overflows the disc.
252
- * NAME: `ringAmount`, not `compactUsd` — this module ALREADY declares
253
- * `compactUsd(fmt, v)` at :410 (the spend-chart y-axis formatter), and a second
254
- * top-level function declaration in an ES module is a fatal SyntaxError, not a
255
- * shadow: it takes down stats-view.mjs, app.js and the whole UI. */
256
- function ringAmount(n) {
257
- const v = n || 0;
258
- // The ROUNDED value decides: 999.5 would otherwise fall through and render
259
- // "$1000", five glyphs in a four-glyph disc.
260
- if (Math.round(v) >= 1000) return `$${Math.round(v / 1000)}k`;
261
- return `$${Math.round(v)}`;
321
+ /** Compact signed money for the collapsed rail's 40px stack:
322
+ * $4 · $317 · $1.2k · $9.9k · $11k · $999k · $1.2M · $12M, with "−" before any of them.
323
+ * At most five glyphs unsigned, six signed ("−$8.8k" ≈ 36px at 10px mono, inside the
324
+ * stack's 38px). Every tier is decided on the ROUNDED value, so 999.5 is "$1k" (not
325
+ * "$1000"), 9,950 is "$10k" (not "$10.0k") and 999,500 is "$1M" (not "$1000k").
326
+ * NAME: `railUsd` — this module already declares `compactUsd(fmt, v)` (the spend-chart
327
+ * y-axis formatter), and a second top-level declaration of one name in an ES module is
328
+ * a fatal SyntaxError that takes down stats-view.mjs, app.js and the whole UI. */
329
+ export function railUsd(n) {
330
+ const v = Number(n) || 0;
331
+ const a = Math.abs(v);
332
+ let body;
333
+ if (Math.round(a) < 1000) body = `$${Math.round(a)}`;
334
+ else if (Math.round(a / 100) / 10 < 10) body = `$${Math.round(a / 100) / 10}k`;
335
+ else if (Math.round(a / 1000) < 1000) body = `$${Math.round(a / 1000)}k`;
336
+ else if (Math.round(a / 1e5) / 10 < 10) body = `$${Math.round(a / 1e5) / 10}M`;
337
+ else body = `$${Math.round(a / 1e6)}M`;
338
+ return v < 0 && body !== '$0' ? `−${body}` : body;
339
+ }
340
+
341
+ function stackPair(doc, label, value) {
342
+ const pair = h(doc, 'span', 'spend-stack-pair');
343
+ pair.appendChild(h(doc, 'span', 'spend-stack-lbl', label));
344
+ pair.appendChild(h(doc, 'span', 'spend-stack-val', value));
345
+ return pair;
346
+ }
347
+
348
+ /** Collapsed-rail Spent/Saved stack — what the 76px rail shows while NO total limit is
349
+ * set. A ring needs a denominator; without one it could only print a bare amount in a
350
+ * disc, with nothing saying what the amount was. The stack drops the disc and states both
351
+ * figures, a small caps label over a compact amount, in a 40px column: the width of every
352
+ * other rail square, so it overhangs the 39px content box exactly as they do and nothing
353
+ * clips. Exact figures live in the title and the accessible name. Keeps `.spend-ind` and
354
+ * data-nav="stats" so app.js's closest('.spend-ind') click routing still reaches it. */
355
+ export function renderBudgetStack(budget, { doc = globalThis.document, fmt = DEFAULT_FMT } = {}) {
356
+ const b = budget || {};
357
+ const word = periodWord(b);
358
+ const saved = windowSaved(b);
359
+ const btn = h(doc, 'button', 'spend-ind spend-stack');
360
+ btn.type = 'button';
361
+ btn.dataset.nav = 'stats';
362
+ const figures = `Spent this ${word}: ${fmt.usd(b.windowSpendUsd)}` +
363
+ (saved != null ? ` · Saved this ${word}: ${signedUsd(fmt, saved)}` : '');
364
+ btn.setAttribute('aria-label', figures);
365
+ btn.title = `${figures} · resets ${fmtResetAt(b.windowEndMs)} — Claude Code client-side ` +
366
+ `estimate (total_cost_usd), not authoritative billing` + (saved != null ? `. ${SAVED_NOTE}` : '');
367
+ btn.appendChild(stackPair(doc, 'Spent', railUsd(b.windowSpendUsd)));
368
+ if (saved != null) {
369
+ const pair = stackPair(doc, 'Saved', railUsd(saved));
370
+ if (saved >= 0) pair.classList.add('pos');
371
+ btn.appendChild(pair);
372
+ }
373
+ return btn;
262
374
  }
263
375
 
264
- /** Collapsed-rail budget ring — the sidebar indicator's 38px twin.
265
- * Keeps the `spend-ind` class because app.js routes the sidebar spend click
266
- * through `closest('.spend-ind')` (app.js:517). The arc percentage travels as the
267
- * custom property `--ring-pct` and the gradient is composed in the stylesheet,
268
- * so there is one definition of it and the cascade can swap the band colours by
269
- * class. With no total limit there is no denominator, so the ring shows a flat
270
- * neutral track and the amount rather than a fabricated percentage. */
271
- export function renderBudgetRing(budget, { doc = globalThis.document, fmt = DEFAULT_FMT } = {}) {
376
+ /** Collapsed-rail budget control — the sidebar indicator's compact twin. Under a total
377
+ * limit: a 38px ring metering spend against it. Without one there is no denominator, so
378
+ * it hands over to renderBudgetStack (app.js calls this one function for the rail either
379
+ * way). Keeps the `spend-ind` class because app.js routes the sidebar spend click through
380
+ * `closest('.spend-ind')`. The arc percentage travels as the custom property `--ring-pct`
381
+ * and the gradient is composed in the stylesheet, so there is one definition of it and
382
+ * the cascade can swap the band colours by class. */
383
+ export function renderBudgetRing(budget, opts = {}) {
272
384
  const b = budget || {};
385
+ if (b.totalLimitUsd == null) return renderBudgetStack(b, opts);
386
+ const { doc = globalThis.document, fmt = DEFAULT_FMT } = opts;
273
387
  const btn = h(doc, 'button', 'spend-ind spend-ring');
274
388
  btn.type = 'button';
275
389
  btn.dataset.nav = 'stats';
276
- const hasLimit = b.totalLimitUsd != null;
277
- const ratio = hasLimit ? b.windowSpendUsd / b.totalLimitUsd : 0;
390
+ const ratio = b.windowSpendUsd / b.totalLimitUsd;
278
391
  // Clamped both ways: over-cap spend must not sweep past a full circle, and a
279
392
  // refund must not sweep a negative arc.
280
393
  const pct = b.blocked ? 100 : Math.max(0, Math.min(100, Math.round(ratio * 100)));
281
-
282
- // `no-limit` is tested FIRST, unlike renderBudgetIndicator, which tests
283
- // `blocked` first. Not a divergence: src/core/cost-budget.mjs:90 is
284
- // `blocked = totalLimitUsd != null && windowSpendUsd >= totalLimitUsd`, and
285
- // budgetStatus() is the only producer the UI ever sees, so a no-limit budget
286
- // can never arrive blocked. Do not "fix" the order.
287
- if (!hasLimit) btn.classList.add('no-limit');
288
- else if (b.blocked) btn.classList.add('over');
394
+ if (b.blocked) btn.classList.add('over');
289
395
  else if (ratio >= BUDGET_WARN_AT) btn.classList.add('warn');
290
-
291
- btn.style.setProperty('--ring-pct', String(hasLimit ? pct : 0));
396
+ btn.style.setProperty('--ring-pct', String(pct));
292
397
  btn.title = `Estimated spend this ${periodWord(b)}: ${fmt.usd4(b.windowSpendUsd)}` +
293
- (hasLimit ? ` of ${fmt.usd(b.totalLimitUsd)}` : ' — no total limit') +
398
+ ` of ${fmt.usd(b.totalLimitUsd)}` +
294
399
  ` · resets ${fmtResetAt(b.windowEndMs)} — Claude Code client-side estimate ` +
295
400
  `(total_cost_usd), not authoritative billing`;
296
- btn.appendChild(h(doc, 'span', 'spend-ring-val',
297
- hasLimit ? `${pct}%` : ringAmount(b.windowSpendUsd)));
401
+ btn.appendChild(h(doc, 'span', 'spend-ring-val', `${pct}%`));
298
402
  return btn;
299
403
  }
300
404
 
@@ -322,6 +426,9 @@ export function renderBudgetReadout(budget, { doc = globalThis.document, fmt = D
322
426
  /** Cost-pause banner for run/history cards. rec = {pauseReason, pipelineId,
323
427
  * totalCostUsd}; opts.budget supplies limits + window figures. */
324
428
  export function renderCostPauseBanner(rec, { doc = globalThis.document, fmt = DEFAULT_FMT, budget = null } = {}) {
429
+ // Team policy (team-policy design board 9): a pause on a TEAM cap is the blue variant with
430
+ // "continue past" — every existing caller keeps calling this one function.
431
+ if (POLICY_PAUSE_REASONS.includes(rec.pauseReason)) return renderTeamCapPauseBanner(rec, { doc, budget });
325
432
  const b = budget || {};
326
433
  const kind = rec.pauseReason === 'cost_total' ? 'cb-total' : 'cb-pipeline';
327
434
  const el = h(doc, 'div', `cost-banner ${kind}`);