@worca/app 0.0.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 (114) hide show
  1. package/README.md +403 -0
  2. package/agents/clarify.meta.json +19 -0
  3. package/agents/decomposer.meta.json +21 -0
  4. package/agents/implementer.meta.json +20 -0
  5. package/agents/manualTestsChecklist.meta.json +18 -0
  6. package/agents/manualWebUiTesting.meta.json +18 -0
  7. package/agents/planReviewer.meta.json +19 -0
  8. package/agents/planner.meta.json +20 -0
  9. package/agents/refiner.meta.json +19 -0
  10. package/agents/reviewer.meta.json +19 -0
  11. package/agents/worca-cc-clarify.md +67 -0
  12. package/agents/worca-cc-code-reviewer.md +66 -0
  13. package/agents/worca-cc-decomposer.md +84 -0
  14. package/agents/worca-cc-implementer.md +69 -0
  15. package/agents/worca-cc-manual-tests-checklist.md +63 -0
  16. package/agents/worca-cc-manual-web-ui-testing.md +64 -0
  17. package/agents/worca-cc-plan-refiner.md +69 -0
  18. package/agents/worca-cc-plan-reviewer.md +70 -0
  19. package/agents/worca-cc-planner.md +70 -0
  20. package/agents/worca-cc-workspace-reviewer.md +56 -0
  21. package/agents/worca-cc-workspace-scanner.md +55 -0
  22. package/agents/workspaceReviewer.meta.json +20 -0
  23. package/agents/workspaceScanner.meta.json +18 -0
  24. package/package.json +61 -0
  25. package/scripts/install.mjs +209 -0
  26. package/skills/worca/SKILL.md +66 -0
  27. package/src/cli/worca-cc.mjs +1520 -0
  28. package/src/core/agent-gen.mjs +206 -0
  29. package/src/core/agent-registry.mjs +417 -0
  30. package/src/core/agent-store.mjs +143 -0
  31. package/src/core/artifacts.mjs +2019 -0
  32. package/src/core/channels.mjs +302 -0
  33. package/src/core/chat/allowlist.mjs +27 -0
  34. package/src/core/chat/channel-host.mjs +562 -0
  35. package/src/core/chat/channel-protocol.mjs +117 -0
  36. package/src/core/chat/channel-worker-child.mjs +211 -0
  37. package/src/core/chat/chat-context.mjs +66 -0
  38. package/src/core/chat/command-router.mjs +343 -0
  39. package/src/core/chat/notifier.mjs +120 -0
  40. package/src/core/chat/parser.mjs +30 -0
  41. package/src/core/chat/rate-limiter.mjs +133 -0
  42. package/src/core/chat/redact.mjs +27 -0
  43. package/src/core/chat/renderers.mjs +136 -0
  44. package/src/core/claude-runner.mjs +1356 -0
  45. package/src/core/config.mjs +882 -0
  46. package/src/core/cost-budget.mjs +103 -0
  47. package/src/core/db.mjs +864 -0
  48. package/src/core/fanout.mjs +48 -0
  49. package/src/core/folder-dialog.mjs +138 -0
  50. package/src/core/fs-browse.mjs +49 -0
  51. package/src/core/git-info.mjs +200 -0
  52. package/src/core/guardrail-store.mjs +204 -0
  53. package/src/core/guardrails.mjs +302 -0
  54. package/src/core/marketplaces.mjs +267 -0
  55. package/src/core/migrate-fs-to-db.mjs +612 -0
  56. package/src/core/model-env.mjs +74 -0
  57. package/src/core/orchestrator.mjs +4279 -0
  58. package/src/core/overview-agent.mjs +124 -0
  59. package/src/core/phases.mjs +1279 -0
  60. package/src/core/pipeline-delete.mjs +428 -0
  61. package/src/core/plugin-api.mjs +13 -0
  62. package/src/core/plugin-config.mjs +100 -0
  63. package/src/core/plugin-inventory.mjs +50 -0
  64. package/src/core/plugin-manifest.mjs +447 -0
  65. package/src/core/plugin-models.mjs +130 -0
  66. package/src/core/plugin-repo.mjs +303 -0
  67. package/src/core/plugin-shim-child.mjs +76 -0
  68. package/src/core/plugin-shim.mjs +197 -0
  69. package/src/core/plugin-store.mjs +485 -0
  70. package/src/core/plugin-workflows.mjs +179 -0
  71. package/src/core/plugins-lock.mjs +49 -0
  72. package/src/core/preflight-node.mjs +122 -0
  73. package/src/core/preflight.mjs +341 -0
  74. package/src/core/projects.mjs +157 -0
  75. package/src/core/protocol.mjs +257 -0
  76. package/src/core/recoverable-error.mjs +51 -0
  77. package/src/core/results.mjs +188 -0
  78. package/src/core/run-context.mjs +1375 -0
  79. package/src/core/run-log.mjs +64 -0
  80. package/src/core/run-manifest.mjs +317 -0
  81. package/src/core/runners.mjs +167 -0
  82. package/src/core/settings.mjs +682 -0
  83. package/src/core/skills.mjs +210 -0
  84. package/src/core/sources.mjs +232 -0
  85. package/src/core/stats.mjs +182 -0
  86. package/src/core/store.mjs +67 -0
  87. package/src/core/title.mjs +64 -0
  88. package/src/core/workflow-validator.mjs +185 -0
  89. package/src/core/workflows.mjs +568 -0
  90. package/src/core/workspace-scan.mjs +420 -0
  91. package/src/core/workspaces.mjs +353 -0
  92. package/src/core/worktree.mjs +708 -0
  93. package/src/feature.mjs +9 -0
  94. package/ui/public/app.js +10647 -0
  95. package/ui/public/assets/worca-favicon.png +0 -0
  96. package/ui/public/assets/worca-logo.png +0 -0
  97. package/ui/public/chat-settings-view.mjs +89 -0
  98. package/ui/public/composer-core.mjs +211 -0
  99. package/ui/public/fonts/jetbrains-mono-latin-400-normal.woff2 +0 -0
  100. package/ui/public/fonts/poppins-latin-400-normal.woff2 +0 -0
  101. package/ui/public/fonts/poppins-latin-500-normal.woff2 +0 -0
  102. package/ui/public/fonts/poppins-latin-600-normal.woff2 +0 -0
  103. package/ui/public/fonts/poppins-latin-700-normal.woff2 +0 -0
  104. package/ui/public/guardrails-view.mjs +244 -0
  105. package/ui/public/index.html +1145 -0
  106. package/ui/public/log-filter.mjs +81 -0
  107. package/ui/public/log-line.mjs +86 -0
  108. package/ui/public/models-view.mjs +433 -0
  109. package/ui/public/plugins-view.mjs +430 -0
  110. package/ui/public/results-view.mjs +121 -0
  111. package/ui/public/source-pane.mjs +156 -0
  112. package/ui/public/stats-view.mjs +523 -0
  113. package/ui/public/style.css +1557 -0
  114. package/ui/server.mjs +3573 -0
@@ -0,0 +1,156 @@
1
+ // ui/public/source-pane.mjs
2
+ // Declarative New-Pipeline pane for plugin task sources (spec §7.4). Pure DOM
3
+ // construction: `call(op, args)` — injected by app.js, wrapping
4
+ // POST /api/sources/call — is the ONLY I/O. Testable under jsdom with a fake
5
+ // `call` and injected timers; app.js owns mounting + submit.
6
+
7
+ function h(doc, tag, cls, text) {
8
+ const n = doc.createElement(tag);
9
+ if (cls) n.className = cls;
10
+ if (text != null) n.textContent = text;
11
+ return n;
12
+ }
13
+
14
+ // debounce(fn, ms, timers?) — trailing-edge. `timers` lets tests inject a
15
+ // manual clock ({ setTimeout, clearTimeout }); defaults wrap the globals.
16
+ export function debounce(fn, ms, timers = {}) {
17
+ const set = timers.setTimeout || ((f, t) => setTimeout(f, t));
18
+ const clear = timers.clearTimeout || ((id) => clearTimeout(id));
19
+ let pending = null;
20
+ return (...args) => {
21
+ if (pending != null) clear(pending);
22
+ pending = set(() => { pending = null; fn(...args); }, ms);
23
+ };
24
+ }
25
+
26
+ // Values of every non-task-browser input in the pane, keyed by input key —
27
+ // these travel as `inputs` to connector ops and into body.source at submit.
28
+ function collectInputs(pane) {
29
+ const inputs = {};
30
+ for (const node of pane.querySelectorAll('[data-input-key]')) {
31
+ if (node.classList.contains('sp-task-browser')) continue;
32
+ inputs[node.dataset.inputKey] = node.value;
33
+ }
34
+ return inputs;
35
+ }
36
+
37
+ function taskRow(doc, t) {
38
+ const row = h(doc, 'div', 'sp-row');
39
+ row.dataset.taskId = t.id;
40
+ row.appendChild(h(doc, 'span', 'sp-row-title', t.title));
41
+ const meta = h(doc, 'span', 'sp-row-meta');
42
+ 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));
44
+ row.appendChild(meta);
45
+ return row;
46
+ }
47
+
48
+ // renderSourcePane(source, { call, doc, timers }) -> detached pane element.
49
+ // source = /api/sources entry { type:'plugin', plugin, sourceId, displayName, inputs }.
50
+ // Per input type: text -> input; select -> static dropdown; remote-select ->
51
+ // dropdown populated ONCE on first focus via call(optionsFrom) (promise kept on
52
+ // ._load for deterministic tests); task-browser -> debounced (300ms) search +
53
+ // result list + preview (call getTask on pick) + hidden selected taskId.
54
+ export function renderSourcePane(source, { call, doc = globalThis.document, timers } = {}) {
55
+ const pane = h(doc, 'div', 'sp-pane');
56
+ pane.dataset.plugin = source.plugin;
57
+ pane.dataset.sourceId = source.sourceId;
58
+ for (const input of source.inputs || []) {
59
+ const field = h(doc, 'div', 'field');
60
+ field.appendChild(h(doc, 'label', '', input.label || input.key));
61
+ if (input.type === 'text') {
62
+ const t = h(doc, 'input', 'input');
63
+ t.type = 'text';
64
+ t.value = input.default != null ? String(input.default) : '';
65
+ t.dataset.inputKey = input.key;
66
+ field.appendChild(t);
67
+ } else if (input.type === 'select') {
68
+ const s = h(doc, 'select', 'select');
69
+ for (const o of input.options || []) {
70
+ const opt = h(doc, 'option', '', typeof o === 'object' ? (o.label ?? o.value) : String(o));
71
+ opt.value = typeof o === 'object' ? String(o.value) : String(o);
72
+ s.appendChild(opt);
73
+ }
74
+ if (input.default != null) s.value = String(input.default);
75
+ s.dataset.inputKey = input.key;
76
+ field.appendChild(s);
77
+ } else if (input.type === 'remote-select') {
78
+ const s = h(doc, 'select', 'select sp-remote');
79
+ const ph = h(doc, 'option', '', 'Click to load…');
80
+ ph.value = '';
81
+ s.appendChild(ph);
82
+ s.dataset.inputKey = input.key;
83
+ s.dataset.optionsFrom = input.optionsFrom || '';
84
+ s.addEventListener('focus', () => {
85
+ if (s._load) return; // fetch once
86
+ s._load = (async () => {
87
+ const options = await call(input.optionsFrom, {});
88
+ s.replaceChildren();
89
+ for (const o of options || []) {
90
+ const opt = h(doc, 'option', '', o.label != null ? o.label : String(o.value));
91
+ opt.value = String(o.value);
92
+ s.appendChild(opt);
93
+ }
94
+ })().catch(() => {
95
+ s.replaceChildren(h(doc, 'option', '', 'failed to load — refocus to retry'));
96
+ s._load = null; // allow retry
97
+ });
98
+ });
99
+ field.appendChild(s);
100
+ } else if (input.type === 'task-browser') {
101
+ const tb = h(doc, 'div', 'sp-task-browser');
102
+ tb.dataset.inputKey = input.key;
103
+ const search = h(doc, 'input', 'input sp-search');
104
+ search.type = 'text';
105
+ search.placeholder = `Search ${source.displayName || 'tasks'}…`;
106
+ const results = h(doc, 'div', 'sp-results');
107
+ const preview = h(doc, 'div', 'sp-preview viewer');
108
+ preview.hidden = true;
109
+ const hidden = h(doc, 'input', 'sp-task-id');
110
+ hidden.type = 'hidden';
111
+ const runSearch = async (text) => {
112
+ results.replaceChildren(h(doc, 'div', 'hint', 'Searching…'));
113
+ try {
114
+ const r = await call('listTasks', { inputs: collectInputs(pane), search: text });
115
+ results.replaceChildren();
116
+ 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.'));
119
+ } catch (e) {
120
+ results.replaceChildren(h(doc, 'div', 'hint err', `search failed: ${e.message}`));
121
+ }
122
+ };
123
+ const debounced = debounce(runSearch, 300, timers);
124
+ search.addEventListener('input', () => debounced(search.value.trim()));
125
+ results.addEventListener('click', (e) => {
126
+ const row = e.target.closest('.sp-row');
127
+ if (!row) return;
128
+ for (const r of results.querySelectorAll('.sp-row.sel')) r.classList.remove('sel');
129
+ row.classList.add('sel');
130
+ hidden.value = row.dataset.taskId;
131
+ preview.hidden = false;
132
+ preview.textContent = 'Loading task…';
133
+ preview._load = (async () => { // kept for deterministic awaiting
134
+ const task = await call('getTask', { id: row.dataset.taskId });
135
+ preview.replaceChildren(
136
+ h(doc, 'b', 'sp-prev-title', (task && task.title) || row.dataset.taskId),
137
+ h(doc, 'pre', 'sp-prev-body', (task && task.body) || ''),
138
+ );
139
+ })().catch((e) => { preview.textContent = `preview failed: ${e.message}`; });
140
+ });
141
+ tb.append(search, results, preview, hidden);
142
+ field.appendChild(tb);
143
+ }
144
+ pane.appendChild(field);
145
+ }
146
+ return pane;
147
+ }
148
+
149
+ // collectSourcePane(paneEl) -> { inputs, taskId } | { error } when nothing picked.
150
+ export function collectSourcePane(paneEl) {
151
+ const inputs = collectInputs(paneEl);
152
+ const hidden = paneEl.querySelector('.sp-task-id');
153
+ const taskId = hidden ? hidden.value : '';
154
+ if (!taskId) return { error: 'Pick a task from the list first.' };
155
+ return { inputs, taskId };
156
+ }
@@ -0,0 +1,523 @@
1
+ // ui/public/stats-view.mjs
2
+ // Pure DOM renderers for the Statistics view, the sidebar budget indicator,
3
+ // the Settings budget readout, and the cost-pause banners. Every function
4
+ // takes the target `document` via opts (defaults to the browser global) and
5
+ // returns DETACHED elements — no fetch, no listeners outside the returned
6
+ // tree. app.js owns endpoint calls and mounting; node:test drives these via
7
+ // jsdom. Interactive elements carry data-* + routing classes (cb-override,
8
+ // cb-settings, ch-hit, data-nav) so app.js wires delegated listeners.
9
+ // Formatters are injected via opts.fmt = { usd, usd4, duration, estTitle };
10
+ // DEFAULT_FMT keeps pure tests standalone.
11
+
12
+ const SVG_NS = 'http://www.w3.org/2000/svg';
13
+
14
+ /** Spend meter turns amber at 80% of the total limit (display-only). */
15
+ export const BUDGET_WARN_AT = 0.8;
16
+
17
+ const WD = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
18
+ const MO = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
19
+
20
+ export const DEFAULT_FMT = {
21
+ usd: (n) => `$${(Math.round(((n || 0) + Number.EPSILON) * 100) / 100).toFixed(2)}`,
22
+ usd4: (n) => `$${(n || 0).toFixed(4)}`,
23
+ duration: (ms) => {
24
+ const s = Math.floor((ms || 0) / 1000);
25
+ if (s < 60) return `${s}s`;
26
+ const m = Math.floor(s / 60);
27
+ if (m < 60) return `${m}m ${s % 60}s`;
28
+ return `${Math.floor(m / 60)}h ${m % 60}m`;
29
+ },
30
+ estTitle: (n) => `Estimated cost $${(n || 0).toFixed(4)} — Claude Code client-side estimate (total_cost_usd), not authoritative billing`,
31
+ };
32
+
33
+ function h(doc, tag, cls, text) {
34
+ const n = doc.createElement(tag);
35
+ if (cls) n.className = cls;
36
+ if (text != null) n.textContent = text;
37
+ return n;
38
+ }
39
+
40
+ function s(doc, tag, attrs = {}) {
41
+ const n = doc.createElementNS(SVG_NS, tag);
42
+ for (const [k, v] of Object.entries(attrs)) n.setAttribute(k, String(v));
43
+ return n;
44
+ }
45
+
46
+ /** 16px stroke icon matching the app's nav glyph style. */
47
+ function icon(doc, d) {
48
+ const svg = s(doc, 'svg', {
49
+ class: 'stat-ico', viewBox: '0 0 24 24', fill: 'none',
50
+ stroke: 'currentColor', 'stroke-width': '1.9',
51
+ 'stroke-linecap': 'round', 'stroke-linejoin': 'round',
52
+ });
53
+ svg.appendChild(s(doc, 'path', { d }));
54
+ return svg;
55
+ }
56
+
57
+ const ICONS = {
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
+ time: 'M12 8v4l3 2M12 20a8 8 0 1 1 0-16 8 8 0 0 1 0 16Z',
60
+ finished: 'M20 7 9 18l-5-5',
61
+ 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',
62
+ };
63
+
64
+ function fmtResetAt(ms) {
65
+ const d = new Date(ms);
66
+ const p = (x) => String(x).padStart(2, '0');
67
+ return `${WD[d.getDay()]} ${MO[d.getMonth()]} ${d.getDate()}, ${p(d.getHours())}:${p(d.getMinutes())}`;
68
+ }
69
+
70
+ function fmtIn(ms) {
71
+ const days = Math.floor(ms / 86400000);
72
+ const hours = Math.floor((ms % 86400000) / 3600000);
73
+ return days > 0 ? `${days}d ${hours}h` : `${hours}h ${Math.floor((ms % 3600000) / 60000)}m`;
74
+ }
75
+
76
+ const periodWord = (b) => (b && b.resetPeriod === 'weekly' ? 'week' : 'month');
77
+
78
+ /** Neutral delta chip "↑ 23%" vs the previous window; null when not meaningful. */
79
+ function deltaChip(doc, cur, prevVal, range) {
80
+ if (range === 'all' || prevVal == null || !(prevVal > 0)) return null;
81
+ const pct = Math.round(((cur - prevVal) / prevVal) * 100);
82
+ const chip = h(doc, 'span', 'stat-delta', `${pct >= 0 ? '↑' : '↓'} ${Math.abs(pct)}%`);
83
+ chip.title = range === 'today' ? 'vs yesterday'
84
+ : range === 'week' ? 'vs previous week' : 'vs previous month';
85
+ return chip;
86
+ }
87
+
88
+ // Numeric tokens in tile sub-lines ($50.00, 3d 4h, bare counts) get <b> so the
89
+ // figures read at a glance; surrounding prose stays plain. Unit groups like
90
+ // "3d 4h" bold as one token.
91
+ const SUB_NUM_RE = /\$[\d,.]+|\d+[a-z]+(?: \d+[a-z]+)*|\d+/g;
92
+ function subEl(doc, str) {
93
+ const el = h(doc, 'small', 'stat-sub');
94
+ let last = 0;
95
+ for (const m of str.matchAll(SUB_NUM_RE)) {
96
+ if (m.index > last) el.appendChild(doc.createTextNode(str.slice(last, m.index)));
97
+ el.appendChild(h(doc, 'b', null, m[0]));
98
+ last = m.index + m[0].length;
99
+ }
100
+ if (last < str.length) el.appendChild(doc.createTextNode(str.slice(last)));
101
+ return el;
102
+ }
103
+
104
+ function tile(doc, { iconD, label, chip, valueNodes, meter, sub, title }) {
105
+ const card = h(doc, 'section', 'card stat-tile');
106
+ if (title) card.title = title;
107
+ const lab = h(doc, 'div', 'stat-label');
108
+ lab.appendChild(icon(doc, iconD));
109
+ lab.appendChild(h(doc, 'span', null, label));
110
+ if (chip) lab.appendChild(chip);
111
+ card.appendChild(lab);
112
+ const value = h(doc, 'div', 'stat-value mono');
113
+ for (const n of valueNodes) value.appendChild(n);
114
+ card.appendChild(value);
115
+ if (meter) card.appendChild(meter);
116
+ if (sub) card.appendChild(subEl(doc, sub));
117
+ return card;
118
+ }
119
+
120
+ function meterEl(doc, cls, pct) {
121
+ const m = h(doc, 'span', cls);
122
+ const fill = h(doc, 'span', `${cls}-fill`);
123
+ fill.style.width = `${Math.max(0, Math.min(100, pct))}%`;
124
+ m.appendChild(fill);
125
+ return m;
126
+ }
127
+
128
+ /** KPI row: Spent · Time worked · Pipelines finished · PRs merged (spec §6.13). */
129
+ export function renderKpiRow(model, { doc = globalThis.document, fmt = DEFAULT_FMT } = {}) {
130
+ const { totals, prev, budget, range } = model;
131
+ const row = h(doc, 'div', 'stat-row');
132
+
133
+ // Spent. `totals.spentUsd` follows the SELECTED RANGE, while totalLimitUsd and
134
+ // msUntilReset are scoped to the budget RESET WINDOW — so metering one against the
135
+ // other, and printing "resets in…" beside it, is truthful only while the range IS
136
+ // that window. Under a mismatch ('All time', or Month with a weekly reset) the bar
137
+ // pins at 100% and the copy is nonsense, so the tile drops the meter and states the
138
+ // window's own spend explicitly instead.
139
+ const limit = budget ? budget.totalLimitUsd : null;
140
+ const windowRange = budget && budget.resetPeriod === 'weekly' ? 'week' : 'month';
141
+ const limitInRange = limit != null && range === windowRange;
142
+ const spentMeter = limitInRange
143
+ ? (() => {
144
+ const m = meterEl(doc, 'stat-meter', (totals.spentUsd / limit) * 100);
145
+ m.setAttribute('role', 'img');
146
+ m.setAttribute('aria-label', `${Math.round((totals.spentUsd / limit) * 100)}% of total limit used`);
147
+ return m;
148
+ })()
149
+ : null;
150
+ row.appendChild(tile(doc, {
151
+ iconD: ICONS.spent, label: 'Spent',
152
+ chip: prev ? deltaChip(doc, totals.spentUsd, prev.spentUsd, range) : null,
153
+ valueNodes: [doc.createTextNode(fmt.usd(totals.spentUsd))],
154
+ meter: spentMeter,
155
+ sub: limit == null
156
+ ? 'No total limit set'
157
+ : limitInRange
158
+ ? `of ${fmt.usd(limit)} · resets in ${fmtIn(budget.msUntilReset)}`
159
+ : `this ${windowRange}: ${fmt.usd(budget.windowSpendUsd)} of ${fmt.usd(limit)}`,
160
+ title: fmt.estTitle(totals.spentUsd),
161
+ }));
162
+
163
+ // Time worked
164
+ row.appendChild(tile(doc, {
165
+ iconD: ICONS.time, label: 'Time worked',
166
+ chip: prev ? deltaChip(doc, totals.workedMs, prev.workedMs, range) : null,
167
+ valueNodes: [doc.createTextNode(fmt.duration(totals.workedMs))],
168
+ sub: `across ${totals.runs} run${totals.runs === 1 ? '' : 's'}`,
169
+ }));
170
+
171
+ // Pipelines finished
172
+ const finVal = [doc.createTextNode(`${totals.finished} `), h(doc, 'span', 'stat-frac', `/ ${totals.runs}`)];
173
+ const bits = [];
174
+ if (totals.stopped) bits.push(`${totals.stopped} stopped`);
175
+ if (totals.failed) bits.push(`${totals.failed} failed`);
176
+ if (totals.paused) bits.push(`${totals.paused} paused`);
177
+ if (totals.running) bits.push(`${totals.running} running now`);
178
+ row.appendChild(tile(doc, {
179
+ iconD: ICONS.finished, label: 'Pipelines finished',
180
+ chip: prev ? deltaChip(doc, totals.finished, prev.finished, range) : null,
181
+ valueNodes: finVal,
182
+ sub: bits.length ? bits.join(' · ') : 'no stopped or failed runs',
183
+ }));
184
+
185
+ // PRs merged
186
+ row.appendChild(tile(doc, {
187
+ iconD: ICONS.prs, label: 'PRs merged',
188
+ chip: prev ? deltaChip(doc, totals.prsMerged, prev.prsMerged, range) : null,
189
+ valueNodes: [doc.createTextNode(`${totals.prsMerged} `), h(doc, 'span', 'stat-frac', `/ ${totals.prsOpened}`)],
190
+ sub: 'opened in this period',
191
+ }));
192
+
193
+ return row;
194
+ }
195
+
196
+ /** Sidebar spend indicator (whole block navigates to #stats). */
197
+ export function renderBudgetIndicator(budget, { doc = globalThis.document, fmt = DEFAULT_FMT } = {}) {
198
+ const b = budget || {};
199
+ const btn = h(doc, 'button', 'spend-ind');
200
+ btn.type = 'button';
201
+ btn.dataset.nav = 'stats';
202
+ const ratio = b.totalLimitUsd != null ? b.windowSpendUsd / b.totalLimitUsd : 0;
203
+ if (b.blocked) btn.classList.add('over');
204
+ else if (b.totalLimitUsd != null && ratio >= BUDGET_WARN_AT) btn.classList.add('warn');
205
+ btn.title = `Estimated spend this ${periodWord(b)}: ${fmt.usd4(b.windowSpendUsd)}` +
206
+ (b.totalLimitUsd != null ? ` of ${fmt.usd(b.totalLimitUsd)}` : '') +
207
+ ` · resets ${fmtResetAt(b.windowEndMs)} — Claude Code client-side estimate (total_cost_usd), not authoritative billing`;
208
+ const rowEl = h(doc, 'span', 'spend-ind-row');
209
+ rowEl.appendChild(h(doc, 'span', 'spend-ind-label', `Spent this ${periodWord(b)}`));
210
+ rowEl.appendChild(h(doc, 'span', 'spend-ind-amt mono', fmt.usd(b.windowSpendUsd)));
211
+ btn.appendChild(rowEl);
212
+ if (b.totalLimitUsd != null) {
213
+ btn.appendChild(meterEl(doc, 'spend-ind-meter', b.blocked ? 100 : ratio * 100));
214
+ if (b.blocked) btn.appendChild(h(doc, 'small', 'spend-ind-sub', 'limit reached · new runs blocked'));
215
+ } else {
216
+ btn.appendChild(h(doc, 'small', 'spend-ind-sub', 'no total limit'));
217
+ }
218
+ return btn;
219
+ }
220
+
221
+ /** Settings budget readout: meter + one summary line. */
222
+ export function renderBudgetReadout(budget, { doc = globalThis.document, fmt = DEFAULT_FMT } = {}) {
223
+ const b = budget || {};
224
+ const wrap = h(doc, 'div', 'budget-readout');
225
+ if (b.totalLimitUsd != null) {
226
+ wrap.appendChild(meterEl(doc, 'spend-ind-meter',
227
+ b.blocked ? 100 : (b.windowSpendUsd / b.totalLimitUsd) * 100));
228
+ }
229
+ const line = h(doc, 'div', 'budget-readout-line');
230
+ line.appendChild(doc.createTextNode('Spent '));
231
+ line.appendChild(h(doc, 'b', 'mono', fmt.usd(b.windowSpendUsd)));
232
+ if (b.totalLimitUsd != null) {
233
+ line.appendChild(doc.createTextNode(' of '));
234
+ line.appendChild(h(doc, 'b', 'mono', fmt.usd(b.totalLimitUsd)));
235
+ }
236
+ line.appendChild(doc.createTextNode(
237
+ ` this ${periodWord(b)} · resets in ${fmtIn(b.msUntilReset)} (${fmtResetAt(b.windowEndMs)})`));
238
+ wrap.appendChild(line);
239
+ return wrap;
240
+ }
241
+
242
+ /** Cost-pause banner for run/history cards. rec = {pauseReason, pipelineId,
243
+ * totalCostUsd}; opts.budget supplies limits + window figures. */
244
+ export function renderCostPauseBanner(rec, { doc = globalThis.document, fmt = DEFAULT_FMT, budget = null } = {}) {
245
+ const b = budget || {};
246
+ const kind = rec.pauseReason === 'cost_total' ? 'cb-total' : 'cb-pipeline';
247
+ const el = h(doc, 'div', `cost-banner ${kind}`);
248
+ const text = h(doc, 'div', 'cb-text');
249
+ const actions = h(doc, 'div', 'cb-actions');
250
+ const settingsBtn = h(doc, 'button', 'btn btn-mini cb-settings', 'Open Settings');
251
+ settingsBtn.type = 'button';
252
+
253
+ if (kind === 'cb-pipeline') {
254
+ el.appendChild(h(doc, 'b', null, 'Paused — pipeline cost limit reached'));
255
+ text.appendChild(doc.createTextNode("This pipeline's estimated cost hit "));
256
+ text.appendChild(h(doc, 'span', 'mono', fmt.usd(rec.totalCostUsd || 0)));
257
+ text.appendChild(doc.createTextNode(' of its '));
258
+ text.appendChild(h(doc, 'span', 'mono', fmt.usd(b.pipelineLimitUsd || 0)));
259
+ text.appendChild(doc.createTextNode(
260
+ ' limit. Raise or clear the limit in Settings, or continue without a cap.'));
261
+ el.appendChild(text);
262
+ const override = h(doc, 'button', 'btn btn-primary btn-mini cb-override',
263
+ 'Continue without cap (this pipeline)');
264
+ override.type = 'button';
265
+ override.dataset.pipelineId = rec.pipelineId || '';
266
+ actions.appendChild(settingsBtn);
267
+ actions.appendChild(override);
268
+ } else {
269
+ el.appendChild(h(doc, 'b', null, 'Paused — total budget reached'));
270
+ text.appendChild(doc.createTextNode('Estimated spend is '));
271
+ text.appendChild(h(doc, 'span', 'mono', fmt.usd(b.windowSpendUsd || 0)));
272
+ text.appendChild(doc.createTextNode(' of the '));
273
+ text.appendChild(h(doc, 'span', 'mono', fmt.usd(b.totalLimitUsd || 0)));
274
+ text.appendChild(doc.createTextNode(
275
+ ` total limit this ${periodWord(b)}. Resumes are blocked until the budget resets ` +
276
+ `${fmtResetAt(b.windowEndMs)}, or until you raise the total limit in Settings.`));
277
+ el.appendChild(text);
278
+ actions.appendChild(settingsBtn);
279
+ }
280
+ el.appendChild(actions);
281
+ return el;
282
+ }
283
+
284
+ // ---------------------------------------------------------------------------
285
+ // Charts. Hand-rolled inline SVG: fixed logical viewBox 0 0 560 240 scaled by
286
+ // CSS; marks are thin columns with 4px rounded data-ends; grid is hairline
287
+ // solid var(--line); all chart text wears text tokens, never series colors.
288
+
289
+ const CW = 560, CH = 240, L = 44, R = 12, T = 18, B = 26;
290
+ const PW = CW - L - R, PH = CH - T - B;
291
+
292
+ /** Nice axis max + tick step for a data max (1/2/2.5/5 × 10^n ladder).
293
+ * integer=true (runs chart) keeps ticks on whole numbers — a sub-1 or ×2.5
294
+ * step on tiny count maxima would render duplicate rounded labels ("0, 1, 1"
295
+ * for a 1-run day) or off-grid labels ("3" at the 2.5 line). */
296
+ function niceScale(maxVal, integer = false) {
297
+ const m = maxVal > 0 ? maxVal : 1;
298
+ const raw = m / 3; // aim for ~3-4 ticks
299
+ const pow = 10 ** Math.floor(Math.log10(raw));
300
+ const ladder = integer ? [1, 2, 5, 10] : [1, 2, 2.5, 5, 10];
301
+ let step = ladder.map((k) => k * pow).find((k) => k >= raw) || pow * 10;
302
+ if (integer) step = Math.max(1, Math.round(step));
303
+ return { step, top: Math.ceil(m / step) * step };
304
+ }
305
+
306
+ /** Column path with a rounded TOP only (square baseline). r clamps to h/2. */
307
+ function roundedTopBar(x, y, w, hgt, r = 4) {
308
+ const rr = Math.max(0, Math.min(r, hgt / 2, w / 2));
309
+ return `M${x},${y + hgt} L${x},${y + rr} Q${x},${y} ${x + rr},${y} L${x + w - rr},${y} ` +
310
+ `Q${x + w},${y} ${x + w},${y + rr} L${x + w},${y + hgt} Z`;
311
+ }
312
+
313
+ function bucketLabel(ms, bucket) {
314
+ const d = new Date(ms);
315
+ if (bucket === 'month') return MO[d.getMonth()];
316
+ if (bucket === 'hour') return `${String(d.getHours()).padStart(2, '0')}:00`;
317
+ return d.getDay() === 1 || d.getDate() === 1 ? `${WD[d.getDay()]} ${d.getDate()}` : String(d.getDate());
318
+ }
319
+
320
+ function tipDate(ms, bucket) {
321
+ const d = new Date(ms);
322
+ if (bucket === 'hour') {
323
+ return `${WD[d.getDay()]} ${MO[d.getMonth()]} ${d.getDate()}, ${String(d.getHours()).padStart(2, '0')}:00`;
324
+ }
325
+ return bucket === 'month'
326
+ ? `${MO[d.getMonth()]} ${d.getFullYear()}`
327
+ : `${WD[d.getDay()]} ${MO[d.getMonth()]} ${d.getDate()}`;
328
+ }
329
+
330
+ function compactUsd(fmt, v) {
331
+ if (v >= 1000) return `$${(v / 1000).toFixed(1)}k`;
332
+ if (v >= 10) return `$${Math.round(v)}`;
333
+ return fmt.usd(v);
334
+ }
335
+
336
+ /** Shared chart scaffolding: svg + grid + x labels + hit rects + sr table. */
337
+ function chartScaffold(doc, spec, { yMax, yFmt, ariaLabel, srHead, srRow, tip, drawBucket, integerScale = false }) {
338
+ const { series, bucket } = spec;
339
+ const fig = h(doc, 'figure', 'chart-fig');
340
+ const svg = s(doc, 'svg', { class: 'chart-svg', viewBox: `0 0 ${CW} ${CH}`, 'aria-label': ariaLabel });
341
+ const { step, top } = niceScale(yMax, integerScale);
342
+ const yOf = (v) => T + PH - (v / top) * PH;
343
+ // gridlines + y ticks
344
+ for (let v = 0; v <= top + 1e-9; v += step) {
345
+ const y = yOf(v);
346
+ svg.appendChild(s(doc, 'line', { x1: L, y1: y, x2: CW - R, y2: y,
347
+ stroke: v === 0 ? 'var(--line-2)' : 'var(--line)', 'stroke-width': 1 }));
348
+ const t = s(doc, 'text', { class: 'ch-ytick', x: L - 6, y: y + 3.5,
349
+ 'text-anchor': 'end', 'font-size': 10.5, fill: 'var(--ink-3)' });
350
+ t.textContent = yFmt(v);
351
+ svg.appendChild(t);
352
+ }
353
+ const bandW = PW / series.length;
354
+ const labelEvery = series.length <= 10 ? 1 : Math.ceil(series.length / 8);
355
+ series.forEach((pt, i) => {
356
+ const bx = L + i * bandW;
357
+ const isCurrent = pt.bucketStartMs === spec.currentBucketStartMs;
358
+ drawBucket({ svg, pt, i, bx, bandW, yOf, isCurrent });
359
+ if (i % labelEvery === 0 || i === series.length - 1) {
360
+ const t = s(doc, 'text', { x: bx + bandW / 2, y: CH - 8, 'text-anchor': 'middle',
361
+ 'font-size': 10.5, fill: 'var(--ink-3)' });
362
+ t.textContent = bucketLabel(pt.bucketStartMs, bucket);
363
+ svg.appendChild(t);
364
+ }
365
+ // full-height invisible hit target (tooltip + keyboard focus)
366
+ const hit = s(doc, 'rect', { class: 'ch-hit', x: bx, y: T, width: bandW, height: PH,
367
+ tabindex: 0, role: 'img', 'aria-label': tip(pt).replace(/\n/g, ', ') });
368
+ hit.dataset.tip = `${tipDate(pt.bucketStartMs, bucket)}\n${tip(pt)}`;
369
+ svg.appendChild(hit);
370
+ });
371
+ fig.appendChild(svg);
372
+ // screen-reader table twin
373
+ const table = h(doc, 'table', 'sr-only');
374
+ const cap = h(doc, 'caption', null, `${ariaLabel}`);
375
+ table.appendChild(cap);
376
+ const thead = h(doc, 'thead');
377
+ const hr = h(doc, 'tr');
378
+ for (const c of srHead) hr.appendChild(h(doc, 'th', null, c));
379
+ thead.appendChild(hr);
380
+ table.appendChild(thead);
381
+ const tbody = h(doc, 'tbody');
382
+ for (const pt of series) {
383
+ const tr = h(doc, 'tr');
384
+ for (const c of srRow(pt)) tr.appendChild(h(doc, 'td', null, c));
385
+ tbody.appendChild(tr);
386
+ }
387
+ table.appendChild(tbody);
388
+ fig.appendChild(table);
389
+ return fig;
390
+ }
391
+
392
+ function chartCard(doc, title, rangeLabel) {
393
+ const card = h(doc, 'section', 'card chart-card');
394
+ const head = h(doc, 'div', 'card-head');
395
+ head.appendChild(h(doc, 'h2', null, title));
396
+ if (rangeLabel) head.appendChild(h(doc, 'small', 'hint', rangeLabel));
397
+ card.appendChild(head);
398
+ return card;
399
+ }
400
+
401
+ const unitWord = (bucket) => (bucket === 'month' ? 'month' : bucket === 'hour' ? 'hour' : 'day');
402
+
403
+ /** Spend column chart: uniform blue columns; per-bucket values live in the tooltip + sr table. */
404
+ export function renderSpendChart(spec, { doc = globalThis.document, fmt = DEFAULT_FMT } = {}) {
405
+ const card = chartCard(doc, `Spend per ${unitWord(spec.bucket)}`, spec.rangeLabel);
406
+ const total = spec.series.reduce((a, p) => a + (p.spentUsd || 0), 0);
407
+ const fig = chartScaffold(doc, spec, {
408
+ yMax: Math.max(...spec.series.map((p) => p.spentUsd || 0), 0),
409
+ yFmt: (v) => compactUsd(fmt, v),
410
+ ariaLabel: `Spend per ${unitWord(spec.bucket)}, ${spec.rangeLabel}, total ${fmt.usd(total)}`,
411
+ srHead: ['Bucket', 'Spend'],
412
+ srRow: (pt) => [tipDate(pt.bucketStartMs, spec.bucket), fmt.usd(pt.spentUsd || 0)],
413
+ tip: (pt) => fmt.usd(pt.spentUsd || 0),
414
+ drawBucket: ({ svg, pt, bx, bandW, yOf }) => {
415
+ const v = pt.spentUsd || 0;
416
+ if (v <= 0) return;
417
+ const w = Math.min(24, bandW - 4);
418
+ const x = bx + (bandW - w) / 2;
419
+ const y = yOf(v);
420
+ svg.appendChild(s(doc, 'path', { d: roundedTopBar(x, y, w, T + PH - y),
421
+ fill: 'var(--blue)' }));
422
+ },
423
+ });
424
+ card.appendChild(fig);
425
+ return card;
426
+ }
427
+
428
+ const OUTCOMES = [
429
+ { key: 'finished', label: 'Finished', color: 'var(--green-ink)' },
430
+ { key: 'stopped', label: 'Stopped', color: 'var(--amber)' },
431
+ { key: 'failed', label: 'Failed', color: 'var(--red-ink)' },
432
+ ];
433
+
434
+ /** Runs stacked column chart by outcome; legend carries the counts. */
435
+ export function renderRunsChart(spec, { doc = globalThis.document, fmt = DEFAULT_FMT } = {}) {
436
+ const card = chartCard(doc, `Runs per ${unitWord(spec.bucket)}`, spec.rangeLabel);
437
+ const sums = Object.fromEntries(OUTCOMES.map((o) => [o.key,
438
+ spec.series.reduce((a, p) => a + (p[o.key] || 0), 0)]));
439
+ const legend = h(doc, 'div', 'chart-legend');
440
+ for (const o of OUTCOMES) {
441
+ const item = h(doc, 'span', 'lg-item');
442
+ const sw = h(doc, 'span', 'lg-swatch');
443
+ sw.style.background = o.color;
444
+ item.appendChild(sw);
445
+ item.appendChild(doc.createTextNode(`${o.label} `));
446
+ item.appendChild(h(doc, 'b', 'mono', String(sums[o.key])));
447
+ legend.appendChild(item);
448
+ }
449
+ card.appendChild(legend);
450
+ const totalRuns = sums.finished + sums.stopped + sums.failed;
451
+ const fig = chartScaffold(doc, spec, {
452
+ yMax: Math.max(...spec.series.map((p) => (p.finished || 0) + (p.stopped || 0) + (p.failed || 0)), 0),
453
+ integerScale: true,
454
+ yFmt: (v) => String(Math.round(v)),
455
+ ariaLabel: `Runs per ${unitWord(spec.bucket)} by outcome, ${spec.rangeLabel}, ${totalRuns} runs`,
456
+ srHead: ['Bucket', 'Finished', 'Stopped', 'Failed'],
457
+ srRow: (pt) => [tipDate(pt.bucketStartMs, spec.bucket),
458
+ String(pt.finished || 0), String(pt.stopped || 0), String(pt.failed || 0)],
459
+ tip: (pt) => `${pt.finished || 0} finished\n${pt.stopped || 0} stopped\n${pt.failed || 0} failed`,
460
+ drawBucket: ({ svg, pt, bx, bandW, yOf, isCurrent }) => {
461
+ if (isCurrent) {
462
+ svg.appendChild(s(doc, 'rect', { class: 'ch-currentband', x: bx, y: T,
463
+ width: bandW, height: PH, fill: 'var(--field)' }));
464
+ }
465
+ const stack = OUTCOMES.map((o) => ({ ...o, v: pt[o.key] || 0 })).filter((o) => o.v > 0);
466
+ if (!stack.length) return;
467
+ const w = Math.min(24, bandW - 4);
468
+ const x = bx + (bandW - w) / 2;
469
+ let yCursor = T + PH;
470
+ // heights from the shared y scale: yOf(v) maps value->y, so a segment of
471
+ // value v has pixel height (T + PH - yOf(v)).
472
+ stack.forEach((segSpec, idx) => {
473
+ const hPx = T + PH - yOf(segSpec.v);
474
+ const isTop = idx === stack.length - 1;
475
+ const gap = isTop ? 0 : 2; // 2px surface gap between segments
476
+ const y = yCursor - hPx;
477
+ if (isTop) {
478
+ const p = s(doc, 'path', { class: 'ch-seg',
479
+ d: roundedTopBar(x, y, w, hPx), fill: segSpec.color });
480
+ svg.appendChild(p);
481
+ } else {
482
+ svg.appendChild(s(doc, 'rect', { class: 'ch-seg', x, y: y + gap,
483
+ width: w, height: Math.max(0, hPx - gap), fill: segSpec.color }));
484
+ }
485
+ yCursor = y;
486
+ });
487
+ },
488
+ });
489
+ card.appendChild(fig);
490
+ return card;
491
+ }
492
+
493
+ /** Full Statistics body: KPI row + the two chart cards (or empty notes). */
494
+ export function renderStatsBody(model, opts = {}) {
495
+ const { doc = globalThis.document } = opts;
496
+ const wrap = h(doc, 'div', null);
497
+ wrap.appendChild(renderKpiRow(model, opts));
498
+ const grid = h(doc, 'div', 'charts-grid');
499
+ const rangeLabel = model.range === 'all'
500
+ ? 'last 12 months'
501
+ : model.range === 'today'
502
+ ? tipDate(model.windowStartMs, 'day')
503
+ : `${tipDate(model.windowStartMs, 'day')} – ${tipDate(model.windowEndMs - 1, 'day')}`;
504
+ const currentBucketStartMs = model.series.length
505
+ ? model.series[model.series.length - 1].bucketStartMs : 0;
506
+ if (!model.totals.runs && !model.series.some((p) => p.spentUsd > 0)) {
507
+ const emptyText = model.range === 'all'
508
+ ? 'No pipelines yet — run one from New pipeline.'
509
+ : 'No runs in this period.';
510
+ for (const title of ['Spend', 'Runs']) {
511
+ const card = chartCard(doc, `${title} per ${unitWord(model.bucket)}`, rangeLabel);
512
+ card.appendChild(h(doc, 'div', 'chart-empty hint', emptyText));
513
+ grid.appendChild(card);
514
+ }
515
+ } else {
516
+ grid.appendChild(renderSpendChart({ series: model.series, bucket: model.bucket,
517
+ currentBucketStartMs, rangeLabel }, opts));
518
+ grid.appendChild(renderRunsChart({ series: model.series, bucket: model.bucket,
519
+ currentBucketStartMs, rangeLabel }, opts));
520
+ }
521
+ wrap.appendChild(grid);
522
+ return wrap;
523
+ }