@worca/app 1.0.0 → 1.1.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 (138) hide show
  1. package/README.md +22 -9
  2. package/agents/clarify.meta.json +4 -4
  3. package/agents/decomposer.meta.json +5 -5
  4. package/agents/implementer.meta.json +15 -5
  5. package/agents/manualTestsChecklist.meta.json +5 -4
  6. package/agents/manualWebUiTesting.meta.json +9 -4
  7. package/agents/planReviewer.meta.json +12 -4
  8. package/agents/planner.meta.json +12 -5
  9. package/agents/refiner.meta.json +15 -4
  10. package/agents/reviewer.meta.json +14 -4
  11. package/agents/worca-cc-clarify.md +7 -0
  12. package/agents/worca-cc-code-reviewer.md +11 -6
  13. package/agents/worca-cc-decomposer.md +7 -0
  14. package/agents/worca-cc-implementer.md +9 -0
  15. package/agents/worca-cc-manual-tests-checklist.md +8 -5
  16. package/agents/worca-cc-manual-web-ui-testing.md +10 -6
  17. package/agents/worca-cc-plan-refiner.md +11 -6
  18. package/agents/worca-cc-plan-reviewer.md +10 -7
  19. package/agents/worca-cc-planner.md +9 -0
  20. package/agents/worca-cc-workspace-reviewer.md +11 -4
  21. package/agents/worca-cc-workspace-scanner.md +8 -4
  22. package/agents/workspaceReviewer.meta.json +15 -4
  23. package/agents/workspaceScanner.meta.json +5 -4
  24. package/package.json +8 -2
  25. package/skills/worca/SKILL.md +5 -5
  26. package/src/cli/render.mjs +148 -0
  27. package/src/cli/worca-cc.mjs +319 -45
  28. package/src/core/agent-gen.mjs +69 -31
  29. package/src/core/agent-registry.mjs +124 -144
  30. package/src/core/agent-store.mjs +164 -4
  31. package/src/core/artifacts.mjs +189 -21
  32. package/src/core/ask/catalog.mjs +111 -0
  33. package/src/core/ask/comment-deps.mjs +55 -0
  34. package/src/core/ask/events.mjs +506 -0
  35. package/src/core/ask/follow.mjs +107 -0
  36. package/src/core/ask/git-allowlist.mjs +226 -0
  37. package/src/core/ask/limits.mjs +54 -0
  38. package/src/core/ask/mcp-stdio.mjs +135 -0
  39. package/src/core/ask/models.mjs +125 -0
  40. package/src/core/ask/prompt.mjs +261 -0
  41. package/src/core/ask/proposal.mjs +170 -0
  42. package/src/core/ask/redact.mjs +30 -0
  43. package/src/core/ask/spawn.mjs +153 -0
  44. package/src/core/ask/store.mjs +360 -0
  45. package/src/core/ask/tool-deps.mjs +63 -0
  46. package/src/core/ask/tools.mjs +848 -0
  47. package/src/core/ask/turn.mjs +416 -0
  48. package/src/core/ask/worktree-deps.mjs +27 -0
  49. package/src/core/ask/worktrees.mjs +285 -0
  50. package/src/core/chat/command-router.mjs +20 -3
  51. package/src/core/claude-runner.mjs +434 -57
  52. package/src/core/config.mjs +264 -41
  53. package/src/core/cost-budget.mjs +29 -2
  54. package/src/core/db.mjs +684 -47
  55. package/src/core/diff-anchor.mjs +213 -0
  56. package/src/core/diff-comments.mjs +273 -0
  57. package/src/core/engine-select.mjs +32 -0
  58. package/src/core/git-info.mjs +49 -10
  59. package/src/core/graph/builtin-workflows.mjs +51 -0
  60. package/src/core/graph/executor.mjs +894 -0
  61. package/src/core/graph/registry-ports.mjs +12 -0
  62. package/src/core/graph/scheduler.mjs +1065 -0
  63. package/src/core/graph/seed-templates.mjs +318 -0
  64. package/src/core/model-env.mjs +112 -8
  65. package/src/core/model-test.mjs +79 -0
  66. package/src/core/orchestrator.mjs +902 -4098
  67. package/src/core/overview-agent.mjs +15 -3
  68. package/src/core/phases.mjs +208 -537
  69. package/src/core/pipeline-delete.mjs +13 -2
  70. package/src/core/plugin-api.mjs +8 -3
  71. package/src/core/plugin-config.mjs +178 -28
  72. package/src/core/plugin-inventory.mjs +6 -2
  73. package/src/core/plugin-manifest.mjs +199 -11
  74. package/src/core/plugin-models.mjs +1 -0
  75. package/src/core/plugin-repo.mjs +16 -4
  76. package/src/core/plugin-shim-child.mjs +9 -3
  77. package/src/core/plugin-shim.mjs +77 -14
  78. package/src/core/plugin-store.mjs +236 -29
  79. package/src/core/plugin-workflows.mjs +90 -41
  80. package/src/core/preflight.mjs +135 -3
  81. package/src/core/projects.mjs +7 -5
  82. package/src/core/protocol.mjs +8 -35
  83. package/src/core/recoverable-error.mjs +1 -1
  84. package/src/core/run-harness.mjs +3585 -0
  85. package/src/core/run-manifest.mjs +5 -1
  86. package/src/core/settings.mjs +109 -13
  87. package/src/core/skills.mjs +10 -3
  88. package/src/core/source-bindings.mjs +175 -0
  89. package/src/core/sources.mjs +87 -25
  90. package/src/core/stats.mjs +25 -6
  91. package/src/core/title.mjs +51 -4
  92. package/src/core/workflows.mjs +358 -259
  93. package/src/core/workspace-scan.mjs +4 -0
  94. package/src/core/worktree.mjs +98 -7
  95. package/src/shared/graph/agent-meta.mjs +278 -0
  96. package/src/shared/graph/constants.mjs +105 -0
  97. package/src/shared/graph/geometry.mjs +157 -0
  98. package/src/shared/graph/layout.mjs +134 -0
  99. package/src/shared/graph/loops.mjs +130 -0
  100. package/src/shared/graph/manifest.mjs +257 -0
  101. package/src/shared/graph/ports.mjs +153 -0
  102. package/src/shared/graph/route.mjs +397 -0
  103. package/src/shared/graph/template.mjs +165 -0
  104. package/src/shared/graph/thumbnail.mjs +67 -0
  105. package/src/shared/graph/validate.mjs +491 -0
  106. package/src/shared/graph/verdict.mjs +41 -0
  107. package/ui/public/app.js +4008 -1670
  108. package/ui/public/ask-markdown.mjs +145 -0
  109. package/ui/public/ask-model.mjs +264 -0
  110. package/ui/public/ask-panel.mjs +1880 -0
  111. package/ui/public/chat-settings-view.mjs +6 -2
  112. package/ui/public/diff-view.mjs +66 -11
  113. package/ui/public/file-tree.mjs +305 -0
  114. package/ui/public/graph/composer.mjs +889 -0
  115. package/ui/public/graph/inspector.mjs +183 -0
  116. package/ui/public/graph/model.mjs +37 -0
  117. package/ui/public/graph/palette.mjs +144 -0
  118. package/ui/public/graph/run-decor.mjs +410 -0
  119. package/ui/public/graph/run-hosts.mjs +201 -0
  120. package/ui/public/graph/save-dialog.mjs +56 -0
  121. package/ui/public/graph/view.mjs +858 -0
  122. package/ui/public/guardrails-view.mjs +4 -2
  123. package/ui/public/hljs-loader.mjs +180 -0
  124. package/ui/public/index.html +269 -265
  125. package/ui/public/log-filter.mjs +22 -4
  126. package/ui/public/log-line.mjs +45 -19
  127. package/ui/public/models-view.mjs +171 -9
  128. package/ui/public/plugins-view.mjs +106 -4
  129. package/ui/public/source-pane.mjs +190 -8
  130. package/ui/public/stats-view.mjs +81 -1
  131. package/ui/public/style.css +1459 -229
  132. package/ui/public/syntax-highlight.mjs +270 -0
  133. package/ui/public/thinking-orb.mjs +110 -0
  134. package/ui/server.mjs +1667 -98
  135. package/src/core/channels.mjs +0 -302
  136. package/src/core/runners.mjs +0 -167
  137. package/src/core/workflow-validator.mjs +0 -185
  138. package/ui/public/composer-core.mjs +0 -211
@@ -34,13 +34,62 @@ function collectInputs(pane) {
34
34
  return inputs;
35
35
  }
36
36
 
37
- function taskRow(doc, t) {
37
+ const MINUTE = 60_000;
38
+ const HOUR = 60 * MINUTE;
39
+ const DAY = 24 * HOUR;
40
+
41
+ /**
42
+ * Task timestamps read as "how stale is this", so recent ones are relative
43
+ * ("3d ago") and older ones absolute ("28 May") — past a week, relative time
44
+ * stops distinguishing two tickets from each other. Always LOCAL time: the
45
+ * contract carries UTC ISO strings, and printing the Z value would be an hour
46
+ * or two off for most readers.
47
+ * @returns {{ text: string, title: string }} short label + full-precision hover
48
+ */
49
+ export function formatUpdated(iso, now = Date.now()) {
50
+ const d = new Date(iso);
51
+ if (Number.isNaN(d.getTime())) return { text: String(iso), title: String(iso) };
52
+ const title = d.toLocaleString(undefined, {
53
+ day: 'numeric', month: 'long', year: 'numeric', hour: '2-digit', minute: '2-digit',
54
+ });
55
+ const age = now - d.getTime();
56
+ let text;
57
+ if (age < 0 || age < MINUTE) text = 'just now'; // clock skew reads as "now"
58
+ else if (age < HOUR) text = `${Math.floor(age / MINUTE)}m ago`;
59
+ else if (age < DAY) text = `${Math.floor(age / HOUR)}h ago`;
60
+ else if (age < 7 * DAY) text = `${Math.floor(age / DAY)}d ago`;
61
+ else {
62
+ const sameYear = d.getFullYear() === new Date(now).getFullYear();
63
+ text = d.toLocaleDateString(undefined, { day: 'numeric', month: 'short', ...(sameYear ? {} : { year: 'numeric' }) });
64
+ }
65
+ return { text, title };
66
+ }
67
+
68
+ function taskRow(doc, t, now) {
38
69
  const row = h(doc, 'div', 'sp-row');
39
70
  row.dataset.taskId = t.id;
40
- row.appendChild(h(doc, 'span', 'sp-row-title', t.title));
71
+ // The id goes INSIDE the title span, not beside it: .sp-row is a
72
+ // space-between flex with exactly two children, so a third would spread all
73
+ // three across the row. Prefixing t.title in the connector would be worse
74
+ // still — that string also feeds the preview header and sourceMeta.title,
75
+ // which is stored on the pipeline and shown as provenance.
76
+ const title = h(doc, 'span', 'sp-row-title');
77
+ const id = t.id == null ? '' : String(t.id);
78
+ const text = t.title == null ? '' : String(t.title);
79
+ if (id && !text.startsWith(id)) title.appendChild(h(doc, 'span', 'sp-key', id));
80
+ title.appendChild(doc.createTextNode(text));
81
+ row.appendChild(title);
41
82
  const meta = h(doc, 'span', 'sp-row-meta');
42
83
  for (const l of t.labels || []) meta.appendChild(h(doc, 'span', 'sp-label', l));
43
- if (t.updatedAt) meta.appendChild(h(doc, 'span', 'sp-updated mono', t.updatedAt));
84
+ if (t.updatedAt) {
85
+ // Say WHICH date it is: the summary contract carries only a last-modified
86
+ // time, so a bare date leaves the reader guessing created-vs-updated.
87
+ const { text, title } = formatUpdated(t.updatedAt, now);
88
+ const el = h(doc, 'span', 'sp-updated', `updated ${text}`);
89
+ el.title = `Updated ${title}`;
90
+ el.dataset.updatedAt = t.updatedAt;
91
+ meta.appendChild(el);
92
+ }
44
93
  row.appendChild(meta);
45
94
  return row;
46
95
  }
@@ -51,10 +100,15 @@ function taskRow(doc, t) {
51
100
  // dropdown populated ONCE on first focus via call(optionsFrom) (promise kept on
52
101
  // ._load for deterministic tests); task-browser -> debounced (300ms) search +
53
102
  // result list + preview (call getTask on pick) + hidden selected taskId.
54
- export function renderSourcePane(source, { call, doc = globalThis.document, timers } = {}) {
103
+ export function renderSourcePane(source, { call, doc = globalThis.document, timers, now } = {}) {
104
+ const clock = typeof now === 'function' ? now : () => Date.now();
55
105
  const pane = h(doc, 'div', 'sp-pane');
56
106
  pane.dataset.plugin = source.plugin;
57
107
  pane.dataset.sourceId = source.sourceId;
108
+ // Set by the task-browser branch below; re-runs the listing with whatever
109
+ // the other inputs currently hold. Declared out here because those inputs
110
+ // may be built BEFORE the browser they filter.
111
+ let rerun = null;
58
112
  for (const input of source.inputs || []) {
59
113
  const field = h(doc, 'div', 'field');
60
114
  field.appendChild(h(doc, 'label', '', input.label || input.key));
@@ -108,20 +162,39 @@ export function renderSourcePane(source, { call, doc = globalThis.document, time
108
162
  preview.hidden = true;
109
163
  const hidden = h(doc, 'input', 'sp-task-id');
110
164
  hidden.type = 'hidden';
165
+ // Latest-wins: responses paint only if no newer search started meanwhile.
166
+ // The debounce collapses pending TIMERS, not in-flight fetches — without
167
+ // this, the slow mount-time empty search can resolve after a fast typed
168
+ // one and repaint the unfiltered list over the filtered results.
169
+ let searchSeq = 0;
111
170
  const runSearch = async (text) => {
171
+ const seq = ++searchSeq;
112
172
  results.replaceChildren(h(doc, 'div', 'hint', 'Searching…'));
173
+ const inputs = collectInputs(pane);
174
+ // Nothing asked for is not the same as nothing found — a source may
175
+ // legitimately list nothing (or even refuse to list: the contract lets
176
+ // listTasks throw) until it is given something to go on.
177
+ const asked = text || Object.values(inputs).some((v) => String(v || '').trim());
113
178
  try {
114
- const r = await call('listTasks', { inputs: collectInputs(pane), search: text });
179
+ const r = await call('listTasks', { inputs, search: text });
180
+ if (seq !== searchSeq) return;
115
181
  results.replaceChildren();
116
182
  const tasks = (r && r.tasks) || [];
117
- for (const t of tasks) results.appendChild(taskRow(doc, t));
118
- if (!tasks.length) results.appendChild(h(doc, 'div', 'hint', 'No tasks matched.'));
183
+ for (const t of tasks) results.appendChild(taskRow(doc, t, clock()));
184
+ if (!tasks.length) {
185
+ results.appendChild(h(doc, 'div', 'hint', asked ? 'No tasks matched.' : 'Type to search.'));
186
+ }
119
187
  } catch (e) {
120
- results.replaceChildren(h(doc, 'div', 'hint err', `search failed: ${e.message}`));
188
+ if (seq !== searchSeq) return;
189
+ results.replaceChildren(asked
190
+ ? h(doc, 'div', 'hint err', `search failed: ${e.message}`)
191
+ : h(doc, 'div', 'hint', 'Type to search.'));
121
192
  }
122
193
  };
123
194
  const debounced = debounce(runSearch, 300, timers);
124
195
  search.addEventListener('input', () => debounced(search.value.trim()));
196
+ rerun = () => debounced(search.value.trim());
197
+ pane._search = runSearch; // deterministic awaiting in tests
125
198
  results.addEventListener('click', (e) => {
126
199
  const row = e.target.closest('.sp-row');
127
200
  if (!row) return;
@@ -143,9 +216,118 @@ export function renderSourcePane(source, { call, doc = globalThis.document, time
143
216
  }
144
217
  pane.appendChild(field);
145
218
  }
219
+ // A filter the user types must actually take effect: every non-browser input
220
+ // re-runs the listing. Without this, editing a JQL/filter field changed
221
+ // nothing until you also touched the search box, which reads as "my filter
222
+ // is ignored". 'change' covers <select>, 'input' covers text; the shared
223
+ // debounce collapses the two when a browser fires both.
224
+ if (rerun) {
225
+ for (const node of pane.querySelectorAll('[data-input-key]')) {
226
+ if (node.classList.contains('sp-task-browser')) continue;
227
+ node.addEventListener('input', rerun);
228
+ node.addEventListener('change', rerun);
229
+ }
230
+ // Populate on open rather than showing an empty list until the user types.
231
+ pane._initial = pane._search('');
232
+ }
146
233
  return pane;
147
234
  }
148
235
 
236
+ /** <option> per profile, accepting both {id,label} and bare id strings. */
237
+ function profileOptions(doc, sel, profiles) {
238
+ for (const p of profiles || []) {
239
+ const id = typeof p === 'string' ? p : String(p.id || '');
240
+ const label = typeof p === 'string' ? '' : (p.label || '');
241
+ const opt = h(doc, 'option', '', label ? `${label} (${id})` : id);
242
+ opt.value = id;
243
+ sel.appendChild(opt);
244
+ }
245
+ return sel;
246
+ }
247
+
248
+ /** How the profile was arrived at, in words. An inherited or implicit profile
249
+ * must not read as a deliberate choice — that is how the wrong one survives. */
250
+ const VIA_NOTE = {
251
+ only: 'the only profile configured',
252
+ members: 'inherited from this workspace’s projects',
253
+ binding: '',
254
+ };
255
+
256
+ /**
257
+ * The standing profile row above the pane: which configuration this
258
+ * project/workspace pulls from, always visible once resolved.
259
+ *
260
+ * It stays on screen deliberately. The gate below answers "which instance?"
261
+ * once; this answers "which instance am I reading from right now?" every time,
262
+ * which is the check that catches a wrong binding before a run rather than
263
+ * after. Changing it REBINDS the scope (the same PUT the gate makes), so it is
264
+ * a persistent property of the project — never a per-run filter.
265
+ *
266
+ * @param {{source, profiles, profile, via, scopeLabel?}} info
267
+ * @param {{doc?, onChange?: (profileId: string) => any}} opts
268
+ */
269
+ export function renderProfileBar(info, { doc = globalThis.document, onChange } = {}) {
270
+ const { source = {}, profiles = [], profile, via, scopeLabel = '' } = info || {};
271
+ const bar = h(doc, 'div', 'sp-profile-bar');
272
+ bar.dataset.plugin = source.plugin || '';
273
+ bar.dataset.sourceId = source.sourceId || '';
274
+ if (profile) bar.dataset.profile = profile;
275
+ bar.appendChild(h(doc, 'label', '', 'Profile'));
276
+
277
+ const sel = profileOptions(doc, h(doc, 'select', 'select sp-profile-sel'), profiles);
278
+ if (profile) sel.value = profile;
279
+ sel.addEventListener('change', () => { if (typeof onChange === 'function') onChange(sel.value); });
280
+ bar.appendChild(sel);
281
+
282
+ const note = VIA_NOTE[via] || '';
283
+ if (note) bar.appendChild(h(doc, 'small', 'hint', note));
284
+ else if (scopeLabel) bar.appendChild(h(doc, 'small', 'hint', `bound to ${scopeLabel}`));
285
+ return bar;
286
+ }
287
+
288
+ /**
289
+ * The gate shown INSTEAD of the pane when a multi-profile source has no
290
+ * resolved profile for the selected project/workspace.
291
+ *
292
+ * Why a gate and not a dropdown inside the pane: the choice is "which tracker
293
+ * does this project belong to", which is a property of the project, not of the
294
+ * run. Asked once and remembered, a wrong answer is visible immediately and
295
+ * fixable; asked every run, it is a control nobody reads until a pipeline has
296
+ * already been started against the wrong instance.
297
+ *
298
+ * @param {{source, profiles, via, candidates?, scopeLabel?}} info
299
+ * via: 'none' (nothing bound) | 'conflict' (workspace members disagree)
300
+ * @param {{doc?, onPick: (profileId: string) => any}} opts
301
+ */
302
+ export function renderProfileGate(info, { doc = globalThis.document, onPick } = {}) {
303
+ const { source = {}, profiles = [], via, candidates = [], scopeLabel = '' } = info || {};
304
+ const root = h(doc, 'div', 'sp-profile-gate');
305
+ const name = source.displayName || source.sourceId || 'This source';
306
+
307
+ if (!profiles.length) {
308
+ root.appendChild(h(doc, 'p', 'hint', `${name} has no profiles configured yet.`));
309
+ const link = h(doc, 'a', 'sp-profile-settings', 'Add one in Plugins settings');
310
+ link.href = '#settings/plugins';
311
+ root.appendChild(link);
312
+ return root;
313
+ }
314
+
315
+ root.appendChild(h(doc, 'p', 'hint', via === 'conflict'
316
+ // Naming them is the point: the reader has to know it is ambiguous, and
317
+ // between WHICH instances, to fix it at the right project.
318
+ ? `The projects in ${scopeLabel || 'this workspace'} disagree about which ${name} profile to use (${candidates.join(', ')}). Pick the one this run should read from.`
319
+ : `Which ${name} profile does ${scopeLabel || 'this project'} pull from? Asked once, then remembered.`));
320
+
321
+ const sel = profileOptions(doc, h(doc, 'select', 'select sp-profile-sel'), profiles);
322
+ root.appendChild(sel);
323
+
324
+ const use = h(doc, 'button', 'btn btn-primary btn-mini sp-profile-use', 'Use this profile');
325
+ use.type = 'button';
326
+ use.addEventListener('click', () => { if (typeof onPick === 'function') onPick(sel.value); });
327
+ root.appendChild(use);
328
+ return root;
329
+ }
330
+
149
331
  // collectSourcePane(paneEl) -> { inputs, taskId } | { error } when nothing picked.
150
332
  export function collectSourcePane(paneEl) {
151
333
  const inputs = collectInputs(paneEl);
@@ -56,6 +56,8 @@ function icon(doc, d) {
56
56
 
57
57
  const ICONS = {
58
58
  spent: 'M12 4v16M16 6.8c-.8-1-2.2-1.6-4-1.6-2.2 0-3.8 1-3.8 2.7 0 3.6 7.8 1.7 7.8 5.4 0 1.7-1.7 2.9-4 2.9-1.9 0-3.4-.7-4.2-1.8',
59
+ pipeline: 'M12 3.2 20.5 8 12 12.8 3.5 8 12 3.2ZM3.5 12l8.5 4.8 8.5-4.8M3.5 16l8.5 4.8 8.5-4.8',
60
+ 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',
59
61
  time: 'M12 8v4l3 2M12 20a8 8 0 1 1 0-16 8 8 0 0 1 0 16Z',
60
62
  finished: 'M20 7 9 18l-5-5',
61
63
  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',
@@ -125,7 +127,7 @@ function meterEl(doc, cls, pct) {
125
127
  return m;
126
128
  }
127
129
 
128
- /** KPI row: Spent · Time worked · Pipelines finished · PRs merged (spec §6.13). */
130
+ /** KPI row: Spent · Pipeline spend · Ask Worca · Time worked · Pipelines finished · PRs merged (D4). */
129
131
  export function renderKpiRow(model, { doc = globalThis.document, fmt = DEFAULT_FMT } = {}) {
130
132
  const { totals, prev, budget, range } = model;
131
133
  const row = h(doc, 'div', 'stat-row');
@@ -160,6 +162,32 @@ export function renderKpiRow(model, { doc = globalThis.document, fmt = DEFAULT_F
160
162
  title: fmt.estTitle(totals.spentUsd),
161
163
  }));
162
164
 
165
+ // Pipeline spend (D7): pipeline-only money; sub = share of the combined
166
+ // total. Falls back to spentUsd when the payload predates the ask split.
167
+ const pipeSpend = totals.pipelineSpendUsd ?? totals.spentUsd;
168
+ row.appendChild(tile(doc, {
169
+ iconD: ICONS.pipeline, label: 'Pipeline spend',
170
+ chip: prev ? deltaChip(doc, pipeSpend, prev.pipelineSpendUsd, range) : null,
171
+ valueNodes: [doc.createTextNode(fmt.usd(pipeSpend))],
172
+ sub: totals.spentUsd > 0
173
+ ? `${Math.round((pipeSpend / totals.spentUsd) * 100)}% of spend`
174
+ : 'no spend in this period',
175
+ title: fmt.estTitle(pipeSpend),
176
+ }));
177
+
178
+ // Ask Worca (D5/D6): chat spend; a session = a thread with a costed turn in
179
+ // the range, so cost/session is window spend over that count.
180
+ const ask = totals.ask || { spendUsd: 0, sessions: 0, turns: 0 };
181
+ row.appendChild(tile(doc, {
182
+ iconD: ICONS.ask, label: 'Ask Worca',
183
+ chip: prev ? deltaChip(doc, ask.spendUsd, prev.ask?.spendUsd, range) : null,
184
+ valueNodes: [doc.createTextNode(fmt.usd(ask.spendUsd))],
185
+ sub: ask.sessions > 0
186
+ ? `${ask.sessions} session${ask.sessions === 1 ? '' : 's'} · ${fmt.usd(ask.spendUsd / ask.sessions)}/session · ${ask.turns} turn${ask.turns === 1 ? '' : 's'}`
187
+ : 'no sessions in this period',
188
+ title: fmt.estTitle(ask.spendUsd),
189
+ }));
190
+
163
191
  // Time worked
164
192
  row.appendChild(tile(doc, {
165
193
  iconD: ICONS.time, label: 'Time worked',
@@ -218,6 +246,58 @@ export function renderBudgetIndicator(budget, { doc = globalThis.document, fmt =
218
246
  return btn;
219
247
  }
220
248
 
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)}`;
262
+ }
263
+
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 } = {}) {
272
+ const b = budget || {};
273
+ const btn = h(doc, 'button', 'spend-ind spend-ring');
274
+ btn.type = 'button';
275
+ btn.dataset.nav = 'stats';
276
+ const hasLimit = b.totalLimitUsd != null;
277
+ const ratio = hasLimit ? b.windowSpendUsd / b.totalLimitUsd : 0;
278
+ // Clamped both ways: over-cap spend must not sweep past a full circle, and a
279
+ // refund must not sweep a negative arc.
280
+ 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');
289
+ else if (ratio >= BUDGET_WARN_AT) btn.classList.add('warn');
290
+
291
+ btn.style.setProperty('--ring-pct', String(hasLimit ? pct : 0));
292
+ btn.title = `Estimated spend this ${periodWord(b)}: ${fmt.usd4(b.windowSpendUsd)}` +
293
+ (hasLimit ? ` of ${fmt.usd(b.totalLimitUsd)}` : ' — no total limit') +
294
+ ` · resets ${fmtResetAt(b.windowEndMs)} — Claude Code client-side estimate ` +
295
+ `(total_cost_usd), not authoritative billing`;
296
+ btn.appendChild(h(doc, 'span', 'spend-ring-val',
297
+ hasLimit ? `${pct}%` : ringAmount(b.windowSpendUsd)));
298
+ return btn;
299
+ }
300
+
221
301
  /** Settings budget readout: meter + one summary line. */
222
302
  export function renderBudgetReadout(budget, { doc = globalThis.document, fmt = DEFAULT_FMT } = {}) {
223
303
  const b = budget || {};