@worca/app 1.2.0 → 1.3.0-rc.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/README.md +42 -0
  2. package/agents/memoryDefragmenter.meta.json +24 -0
  3. package/agents/worca-cc-code-reviewer.md +6 -1
  4. package/agents/worca-cc-implementer.md +6 -1
  5. package/agents/worca-cc-memory-defragmenter.md +32 -0
  6. package/agents/worca-cc-planner.md +5 -1
  7. package/package.json +5 -2
  8. package/src/cli/render.mjs +36 -0
  9. package/src/cli/worca-cc.mjs +137 -8
  10. package/src/core/agent-registry.mjs +12 -34
  11. package/src/core/artifacts.mjs +132 -8
  12. package/src/core/ask/catalog.mjs +32 -7
  13. package/src/core/ask/comment-deps.mjs +5 -2
  14. package/src/core/ask/events.mjs +65 -2
  15. package/src/core/ask/limits.mjs +9 -0
  16. package/src/core/ask/mcp-stdio.mjs +10 -0
  17. package/src/core/ask/memory-deps.mjs +107 -0
  18. package/src/core/ask/metrics-deps.mjs +124 -0
  19. package/src/core/ask/metrics-proposal.mjs +175 -0
  20. package/src/core/ask/prompt.mjs +53 -10
  21. package/src/core/ask/proposal.mjs +49 -2
  22. package/src/core/ask/spawn.mjs +21 -4
  23. package/src/core/ask/store.mjs +14 -5
  24. package/src/core/ask/tool-deps.mjs +26 -2
  25. package/src/core/ask/tools.mjs +439 -6
  26. package/src/core/ask/turn.mjs +163 -4
  27. package/src/core/ask/workflow-deps.mjs +226 -0
  28. package/src/core/auto/classify.mjs +352 -0
  29. package/src/core/auto/fingerprint.mjs +141 -0
  30. package/src/core/auto/match.mjs +30 -0
  31. package/src/core/auto/model.mjs +23 -0
  32. package/src/core/auto/proposal.mjs +132 -0
  33. package/src/core/auto/recipes.mjs +75 -0
  34. package/src/core/auto/repo-look.mjs +46 -0
  35. package/src/core/claude-runner.mjs +132 -11
  36. package/src/core/config.mjs +120 -3
  37. package/src/core/db.mjs +44 -1
  38. package/src/core/diff-comments.mjs +55 -9
  39. package/src/core/frontmatter.mjs +75 -0
  40. package/src/core/git-info.mjs +233 -26
  41. package/src/core/graph/builtin-workflows.mjs +50 -0
  42. package/src/core/graph/executor.mjs +11 -3
  43. package/src/core/index-html.mjs +17 -0
  44. package/src/core/memory-store.mjs +441 -0
  45. package/src/core/memory-sync.mjs +300 -0
  46. package/src/core/metrics/ledger.mjs +47 -0
  47. package/src/core/metrics/lock.mjs +117 -0
  48. package/src/core/metrics/read.mjs +303 -0
  49. package/src/core/metrics/record.mjs +389 -0
  50. package/src/core/metrics/sync.mjs +1100 -0
  51. package/src/core/onboarding.mjs +99 -0
  52. package/src/core/orchestrator.mjs +394 -7
  53. package/src/core/phases.mjs +16 -3
  54. package/src/core/pipeline-delete.mjs +1 -1
  55. package/src/core/plugin-store.mjs +2 -10
  56. package/src/core/preflight.mjs +2 -3
  57. package/src/core/projects.mjs +16 -1
  58. package/src/core/run-harness.mjs +458 -32
  59. package/src/core/run-report.mjs +896 -0
  60. package/src/core/settings.mjs +162 -0
  61. package/src/core/sources.mjs +4 -1
  62. package/src/core/store.mjs +5 -0
  63. package/src/core/workflow-export.mjs +2 -0
  64. package/src/core/workflow-share.mjs +1 -0
  65. package/src/core/workflows.mjs +43 -23
  66. package/src/core/workspaces.mjs +37 -8
  67. package/src/shared/graph/agent-meta.mjs +5 -2
  68. package/src/shared/graph/assemble.mjs +455 -0
  69. package/src/shared/graph/flow-layout.mjs +249 -0
  70. package/src/shared/graph/geometry.mjs +48 -28
  71. package/src/shared/graph/isomorphic.mjs +101 -0
  72. package/src/shared/report-reasons.mjs +58 -0
  73. package/src/shared/team-metrics/aggregate.mjs +341 -0
  74. package/src/shared/team-metrics/workspace-match.mjs +13 -0
  75. package/ui/public/about-links.mjs +21 -0
  76. package/ui/public/app.js +3715 -479
  77. package/ui/public/artifact-view.mjs +135 -0
  78. package/ui/public/ask-model.mjs +18 -1
  79. package/ui/public/ask-panel.mjs +1359 -214
  80. package/ui/public/ask-run-card.mjs +209 -0
  81. package/ui/public/assets/worca-logo-mask.png +0 -0
  82. package/ui/public/assets/worca-mark-mask.png +0 -0
  83. package/ui/public/auto-build.mjs +95 -0
  84. package/ui/public/auto-proposal.mjs +174 -0
  85. package/ui/public/comment-thread.mjs +55 -0
  86. package/ui/public/getting-started.mjs +261 -0
  87. package/ui/public/graph/composer.mjs +41 -5
  88. package/ui/public/graph/inspector.mjs +3 -1
  89. package/ui/public/graph/model.mjs +1 -0
  90. package/ui/public/graph/run-hosts.mjs +73 -12
  91. package/ui/public/graph/view.mjs +218 -50
  92. package/ui/public/guide-spot.mjs +215 -0
  93. package/ui/public/index.html +423 -25
  94. package/ui/public/memory-view.mjs +192 -0
  95. package/ui/public/node-tunables.mjs +201 -0
  96. package/ui/public/report-run.mjs +75 -0
  97. package/ui/public/results-view.mjs +25 -0
  98. package/ui/public/source-pane.mjs +16 -2
  99. package/ui/public/stats-view.mjs +2 -2
  100. package/ui/public/style.css +1450 -303
  101. package/ui/public/team-metrics-surfaces.mjs +452 -0
  102. package/ui/public/team-metrics-view.mjs +533 -0
  103. package/ui/public/thinking-orb.mjs +46 -8
  104. package/ui/server.mjs +1282 -193
@@ -0,0 +1,341 @@
1
+ // src/shared/team-metrics/aggregate.mjs
2
+ // Team metrics aggregation (team-metrics-design.md §4.9). PURE: no I/O, no node: imports —
3
+ // shared by the server (read.mjs) and the browser (the page re-aggregates per range/group/filter).
4
+
5
+ export const SUPPORTED_RECORD_VERSION = 1;
6
+ export const RANGES = Object.freeze(['this-month', 'last-month', 'quarter', 'year', 'all', 'custom']);
7
+ // Only dimensions where a run has exactly ONE key: a run's cost is one number for the whole
8
+ // pipeline, so it is never split across the projects it touched (`project` stays a breakdown and
9
+ // a filter — "runs that touched X" — but not a stack).
10
+ export const GROUP_BYS = Object.freeze(['workflow', 'result', 'actor']);
11
+ export const FILTER_DIMS = Object.freeze(['workflow', 'source', 'actor', 'project', 'models', 'result']);
12
+ const DAY = 86_400_000;
13
+ const WEEK = 7 * DAY;
14
+
15
+ // UTC ISO-8601 only (what record.mjs writes). Anything else — "2026-09-01 00:30" (parsed in LOCAL time,
16
+ // so server and browser would bucket it differently), "+200000-01-01…" (expanded years) — is malformed.
17
+ const ISO_UTC_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,9})?Z$/;
18
+
19
+ /** @returns {{record:object}|{malformed:true}|{unknownV:true}} */
20
+ export function parseRecordLine(line) {
21
+ let v;
22
+ try { v = JSON.parse(line); } catch { return { malformed: true }; }
23
+ if (!v || typeof v !== 'object' || Array.isArray(v) || !Number.isInteger(v.v)) return { malformed: true };
24
+ if (v.v > SUPPORTED_RECORD_VERSION) return { unknownV: true };
25
+ if (v.v < 1 || typeof v.id !== 'string' || typeof v.startedAt !== 'string'
26
+ || !ISO_UTC_RE.test(v.startedAt) || !Number.isFinite(Date.parse(v.startedAt))) return { malformed: true };
27
+ return { record: v };
28
+ }
29
+
30
+ const MAX_CUSTOM_DAYS = 3660; // ~10 years: bounds the weekly series
31
+ const MAX_WEEKS = Math.ceil(MAX_CUSTOM_DAYS / 7) + 2;
32
+
33
+ function parseDay(s) {
34
+ if (typeof s !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(s)) return null;
35
+ const ms = Date.parse(`${s}T00:00:00Z`);
36
+ // Reject roll-overs such as 2026-02-31 (Date.parse would silently give Mar 3).
37
+ return Number.isFinite(ms) && new Date(ms).toISOString().startsWith(s) ? ms : null;
38
+ }
39
+
40
+ /**
41
+ * UTC window + the previous window for deltas. startMs inclusive, endMs exclusive.
42
+ * Deltas are like-for-like: while `now` is inside the current window (this month / quarter /
43
+ * year so far), the previous window is cut to the same elapsed length, so "vs prev. month" on
44
+ * Sep 16 compares Sep 1–16 with Aug 1–16, not with all of August.
45
+ */
46
+ export function resolveRange(range = 'this-month', { now = Date.now(), from = null, to = null } = {}) {
47
+ const d = new Date(now);
48
+ const y = d.getUTCFullYear();
49
+ const m = d.getUTCMonth();
50
+ const win = (startMs, endMs, prevStartMs) => {
51
+ // `>=`, not `>`: at exactly 00:00 on the 1st the window is open with 0 elapsed, so the previous
52
+ // window must be 0 long too. With `>` it fell through to the closed-window branch and compared
53
+ // "nothing so far this month" against ALL of last month for one millisecond.
54
+ const elapsed = now >= startMs && now < endMs ? now - startMs : startMs - prevStartMs;
55
+ return { range, startMs, endMs, prevStartMs, prevEndMs: Math.min(startMs, prevStartMs + elapsed) };
56
+ };
57
+ switch (range) {
58
+ case 'this-month': return win(Date.UTC(y, m, 1), Date.UTC(y, m + 1, 1), Date.UTC(y, m - 1, 1));
59
+ case 'last-month': return win(Date.UTC(y, m - 1, 1), Date.UTC(y, m, 1), Date.UTC(y, m - 2, 1));
60
+ case 'quarter': { const q = Math.floor(m / 3) * 3; return win(Date.UTC(y, q, 1), Date.UTC(y, q + 3, 1), Date.UTC(y, q - 3, 1)); }
61
+ case 'year': return win(Date.UTC(y, 0, 1), Date.UTC(y + 1, 0, 1), Date.UTC(y - 1, 0, 1));
62
+ case 'all': return { range, startMs: null, endMs: null, prevStartMs: null, prevEndMs: null };
63
+ case 'custom': {
64
+ const s = parseDay(from);
65
+ const e = parseDay(to);
66
+ if (s == null || e == null || e < s) throw new RangeError('custom range needs from and to as valid YYYY-MM-DD dates with from <= to');
67
+ if ((e - s) / DAY > MAX_CUSTOM_DAYS) throw new RangeError(`custom range is limited to ${MAX_CUSTOM_DAYS} days`);
68
+ const endMs = e + DAY;
69
+ return { range, startMs: s, endMs, prevStartMs: s - (endMs - s), prevEndMs: s };
70
+ }
71
+ default: throw new RangeError(`unknown range "${range}" (expected ${RANGES.join(' | ')})`);
72
+ }
73
+ }
74
+
75
+ /** Monday 00:00 UTC of the week containing ms. */
76
+ export function weekStartMs(ms) {
77
+ const d = new Date(ms);
78
+ return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate() - ((d.getUTCDay() + 6) % 7));
79
+ }
80
+
81
+ const startOf = (r) => Date.parse(r.startedAt);
82
+ const usdOf = (r) => (Number.isFinite(r?.cost?.usd) ? r.cost.usd : 0);
83
+ const isNum = (v) => typeof v === 'number' && Number.isFinite(v);
84
+ const sum = (a) => a.reduce((s, x) => s + x, 0);
85
+ const round2 = (v) => Math.round(v * 100) / 100;
86
+ const round1 = (v) => Math.round(v * 10) / 10;
87
+
88
+ function percentile(values, p) {
89
+ const xs = values.filter(isNum).sort((a, b) => a - b);
90
+ if (!xs.length) return null;
91
+ return xs[Math.min(xs.length - 1, Math.max(0, Math.ceil(p * xs.length) - 1))]; // nearest rank
92
+ }
93
+
94
+ const NONE = '__none__';
95
+
96
+ /** Dimension keys for filtering/breakdowns. Each returns [{key,label,sub?}] (a run may touch several projects). */
97
+ export const DIMENSIONS = {
98
+ workflow: (r) => [{ key: r.workflow?.id || r.workflow?.name || NONE, label: r.workflow?.name || r.workflow?.id || 'Unknown workflow' }],
99
+ // A hand-written or future record may carry no `result`: without the guard it became the stack
100
+ // key "undefined" with an undefined label.
101
+ result: (r) => [{ key: typeof r.result === 'string' && r.result ? r.result : NONE, label: typeof r.result === 'string' && r.result ? r.result : 'Unknown result' }],
102
+ actor: (r) => [{ key: r.actor || NONE, label: r.actor || 'No actor' }],
103
+ source: (r) => (r.source
104
+ ? [{ key: `${r.source.type}:${r.source.ref ?? r.source.url ?? ''}`, label: [r.source.ref, r.source.title].filter(Boolean).join(' ') || r.source.url || r.source.type, sub: r.source.type }]
105
+ : [{ key: NONE, label: 'No ticket', sub: 'ad-hoc prompts' }]),
106
+ models: (r) => {
107
+ const ms = Array.isArray(r.agents?.models) ? [...r.agents.models].sort() : [];
108
+ return [{ key: ms.join(' + ') || NONE, label: ms.join(' + ') || 'Unknown models' }];
109
+ },
110
+ project: (r) => {
111
+ if (r.target?.kind === 'workspace') {
112
+ const t = Array.isArray(r.target.touched) ? r.target.touched : [];
113
+ // `files`: this member's own changed-file count (target.touchedFiles, additive); null on a
114
+ // record that predates it — the run's total is NOT this project's, so the row says "–".
115
+ const tf = r.target.touchedFiles && typeof r.target.touchedFiles === 'object' ? r.target.touchedFiles : null;
116
+ return t.length
117
+ ? t.map((p) => ({ key: p, label: p, files: tf && Number.isInteger(tf[p]) ? tf[p] : null }))
118
+ : [{ key: NONE, label: 'No project touched', files: null }];
119
+ }
120
+ return [{ key: r.target?.project || NONE, label: r.target?.project || 'Unknown project' }];
121
+ },
122
+ };
123
+
124
+ export function applyFilter(records, filter = {}) {
125
+ const active = Object.entries(filter || {}).filter(([dim, key]) => FILTER_DIMS.includes(dim) && key != null && key !== '');
126
+ if (!active.length) return records;
127
+ return records.filter((r) => active.every(([dim, key]) => DIMENSIONS[dim](r).some((k) => k.key === key)));
128
+ }
129
+
130
+ function computeKpis(rs, now) {
131
+ const n = rs.length;
132
+ const count = (res) => rs.filter((r) => r.result === res).length;
133
+ const usd = sum(rs.map(usdOf));
134
+ const paired = rs.filter((r) => isNum(r.wallMs) && isNum(r.activeMs));
135
+ const wallSum = sum(paired.map((r) => r.wallMs));
136
+ const withPr = rs.filter((r) => r.pr && (r.pr.url || r.pr.number != null));
137
+ const reviews = rs.map((r) => r.cycles?.review).filter(isNum);
138
+ const d = new Date(now);
139
+ const monthStart = Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1);
140
+ return {
141
+ spendUsd: round2(usd),
142
+ spendThisMonthUsd: round2(sum(rs.filter((r) => startOf(r) >= monthStart).map(usdOf))),
143
+ runs: n,
144
+ done: count('done'), failed: count('failed'), stopped: count('stopped'),
145
+ successRate: n ? count('done') / n : null,
146
+ costPerRunUsd: n ? round2(usd / n) : null,
147
+ costPerRunMedianUsd: percentile(rs.map(usdOf), 0.5),
148
+ costPerRunP90Usd: percentile(rs.map(usdOf), 0.9),
149
+ runsWithPr: withPr.length,
150
+ costPerRunWithPrUsd: withPr.length ? round2(sum(withPr.map(usdOf)) / withPr.length) : null,
151
+ durationMedianMs: percentile(rs.map((r) => r.wallMs), 0.5),
152
+ machineMs: sum(rs.map((r) => r.activeMs).filter(isNum)),
153
+ autonomy: wallSum > 0 ? sum(paired.map((r) => r.activeMs)) / wallSum : null,
154
+ interventionsPerRun: n ? round1(sum(rs.map((r) => (r.interventions?.questions | 0) + (r.interventions?.pauses | 0))) / n) : null,
155
+ reviewCyclesMean: reviews.length ? round1(sum(reviews) / reviews.length) : null,
156
+ convergeInOneRate: reviews.length ? reviews.filter((c) => c <= 1).length / reviews.length : null,
157
+ filesChanged: sum(rs.map((r) => r.git?.filesChanged).filter(isNum)),
158
+ };
159
+ }
160
+
161
+ const pct = (cur, prev) => (isNum(cur) && isNum(prev) && prev > 0 ? (cur - prev) / prev : null);
162
+
163
+ function deltas(k, p) {
164
+ return {
165
+ spendPct: pct(k.spendUsd, p.spendUsd),
166
+ runsPct: pct(k.runs, p.runs),
167
+ costPerRunPct: pct(k.costPerRunUsd, p.costPerRunUsd),
168
+ durationPct: pct(k.durationMedianMs, p.durationMedianMs),
169
+ autonomyPts: isNum(k.autonomy) && isNum(p.autonomy) ? Math.round((k.autonomy - p.autonomy) * 100) : null,
170
+ reviewCycles: isNum(k.reviewCyclesMean) && isNum(p.reviewCyclesMean) ? round1(k.reviewCyclesMean - p.reviewCyclesMean) : null,
171
+ };
172
+ }
173
+
174
+ function breakdown(rs, dim) {
175
+ const rows = new Map();
176
+ for (const r of rs) {
177
+ for (const k of DIMENSIONS[dim](r)) {
178
+ const row = rows.get(k.key) || { key: k.key, label: k.label, sub: k.sub ?? null, runs: 0, usd: 0, done: 0, reviews: [], filesChanged: 0, filesUnknown: 0 };
179
+ row.runs += 1;
180
+ row.usd += usdOf(r); // "a run touching two projects counts in both"
181
+ if (r.result === 'done') row.done += 1;
182
+ if (isNum(r.cycles?.review)) row.reviews.push(r.cycles.review);
183
+ // A key that carries its own count (a touched member) uses it; every other dimension is
184
+ // whole-run, so the run's total applies. A member without a count is counted as unknown.
185
+ const files = 'files' in k ? k.files : (isNum(r.git?.filesChanged) ? r.git.filesChanged : null);
186
+ if (isNum(files)) row.filesChanged += files; else row.filesUnknown += 1;
187
+ rows.set(k.key, row);
188
+ }
189
+ }
190
+ // `share` is normalised against THIS column's own sum, not against the run total. For every
191
+ // single-key dimension the two are equal; for `project` on a workspace scope a run touching two
192
+ // projects deliberately counts its full spend in BOTH rows (decision 19, the mockup subtitle),
193
+ // so dividing by the run total made the bars sum to 200%. The table answers "what did the runs
194
+ // that touched this project cost"; no chart splits a run's cost across projects (GROUP_BYS).
195
+ const colTotal = [...rows.values()].reduce((a, r) => a + r.usd, 0);
196
+ // `runShare`: the fraction of the runs in range that touched this key — the per-project bar
197
+ // (spend is not a per-project fact, so the project table shows this instead of a spend share).
198
+ const runTotal = rs.length;
199
+ return [...rows.values()]
200
+ .map((row) => ({
201
+ key: row.key, label: row.label, sub: row.sub, runs: row.runs, usd: round2(row.usd),
202
+ runShare: runTotal > 0 ? row.runs / runTotal : 0,
203
+ perRunUsd: row.runs ? round2(row.usd / row.runs) : null,
204
+ successRate: row.runs ? row.done / row.runs : null,
205
+ cyclesMean: row.reviews.length ? round1(sum(row.reviews) / row.reviews.length) : null,
206
+ share: colTotal > 0 ? row.usd / colTotal : 0,
207
+ // null when no run in the row carries a count: the cell shows "–", never a wrong number.
208
+ filesChanged: row.filesUnknown === row.runs ? null : row.filesChanged,
209
+ filesUnknown: row.filesUnknown,
210
+ isNone: row.key === NONE,
211
+ }))
212
+ // Projects order by what the table shows (runs touched, then files); every other dimension by spend.
213
+ .sort((a, b) => (a.isNone - b.isNone) || (dim === 'project'
214
+ ? (b.runs - a.runs) || ((b.filesChanged ?? -1) - (a.filesChanged ?? -1)) || a.label.localeCompare(b.label)
215
+ : (b.usd - a.usd) || (b.runs - a.runs)));
216
+ }
217
+
218
+ function weekSpan(rs, win, now) {
219
+ // reduce, not Math.min(...spread): a spread over ~100k+ records overflows the call stack.
220
+ let minStart = Infinity;
221
+ let maxStart = -Infinity;
222
+ for (const r of rs) { const t = startOf(r); if (t < minStart) minStart = t; if (t > maxStart) maxStart = t; }
223
+ const first = win.startMs ?? (rs.length ? minStart : null);
224
+ if (first == null) return [];
225
+ // Series stop at `now`, but never before the newest record in range (a teammate's clock may run
226
+ // ahead) — by at most 31 days: records come from a branch anyone with push access can write,
227
+ // and one record dated year 20000 must not produce ~900k weeks (a 100 MB response / Map overflow).
228
+ const cap = Math.max(now, rs.length ? Math.min(maxStart, now + 31 * DAY) : -Infinity);
229
+ const last = Math.min(win.endMs != null ? win.endMs - 1 : cap, cap);
230
+ const to = weekStartMs(Math.max(first, last));
231
+ const from = Math.max(weekStartMs(first), to - (MAX_WEEKS - 1) * WEEK); // at most ~10 years of columns
232
+ const weeks = [];
233
+ for (let w = from; w <= to; w += WEEK) weeks.push(w);
234
+ return weeks;
235
+ }
236
+
237
+ function series(rs, win, groupBy, now) {
238
+ const weeks = weekSpan(rs, win, now);
239
+ const idx = new Map(weeks.map((w, i) => [w, i]));
240
+ // Null-prototype maps: keys are record-supplied strings ("constructor", "__proto__" must not collide).
241
+ const spend = weeks.map((w) => ({ weekStartMs: w, totalUsd: 0, stacks: Object.create(null) }));
242
+ const runs = weeks.map((w) => ({ weekStartMs: w, done: 0, failed: 0, stopped: 0 }));
243
+ const labels = Object.create(null);
244
+ const totals = Object.create(null);
245
+ for (const r of rs) {
246
+ const i = idx.get(weekStartMs(startOf(r)));
247
+ if (i == null) continue;
248
+ if (r.result === 'done' || r.result === 'failed' || r.result === 'stopped') runs[i][r.result] += 1;
249
+ spend[i].totalUsd += usdOf(r);
250
+ for (const k of DIMENSIONS[groupBy](r)) {
251
+ const v = usdOf(r); // one key per run for every GROUP_BYS dimension
252
+ spend[i].stacks[k.key] = (spend[i].stacks[k.key] || 0) + v;
253
+ totals[k.key] = (totals[k.key] || 0) + v;
254
+ labels[k.key] = k.label;
255
+ }
256
+ }
257
+ for (const pt of spend) {
258
+ pt.totalUsd = round2(pt.totalUsd);
259
+ // Back to a plain object (Object.fromEntries defines own data properties, so "__proto__" survives)
260
+ // — callers and deepStrictEqual expect ordinary objects.
261
+ pt.stacks = Object.fromEntries(Object.entries(pt.stacks).map(([key, v]) => [key, round2(v)]));
262
+ }
263
+ const stackKeys = Object.keys(totals).sort((a, b) => totals[b] - totals[a])
264
+ .map((key) => ({ key, label: labels[key], totalUsd: round2(totals[key]) }));
265
+ return { spend, runs, stackKeys };
266
+ }
267
+
268
+ /** Records come from a branch anyone with push access can write: only http(s) links survive. */
269
+ export function safeHttpUrl(u) {
270
+ if (typeof u !== 'string' || !u) return null;
271
+ try { const x = new URL(u); return x.protocol === 'https:' || x.protocol === 'http:' ? x.href : null; } catch { return null; }
272
+ }
273
+
274
+ function toRunRow(r) {
275
+ return {
276
+ id: r.id, title: r.title ?? '(untitled)', startedAt: r.startedAt,
277
+ workflow: r.workflow?.name ?? r.workflow?.id ?? null, result: r.result,
278
+ usd: usdOf(r), wallMs: isNum(r.wallMs) ? r.wallMs : null, activeMs: isNum(r.activeMs) ? r.activeMs : null,
279
+ reviewCycles: isNum(r.cycles?.review) ? r.cycles.review : null,
280
+ pr: r.pr && (r.pr.url || r.pr.number != null) ? { number: Number.isInteger(r.pr.number) ? r.pr.number : null, url: safeHttpUrl(r.pr.url) } : null,
281
+ actor: r.actor ?? null,
282
+ source: r.source ? [r.source.ref, r.source.title].filter(Boolean).join(' ') : null,
283
+ projects: r.target?.kind === 'workspace'
284
+ ? (Array.isArray(r.target.touched) ? r.target.touched.filter((p) => typeof p === 'string') : [])
285
+ : [r.target?.project].filter((p) => typeof p === 'string' && p),
286
+ };
287
+ }
288
+
289
+ /**
290
+ * @param {object[]} records v1 records (already parsed; unknown v / malformed already dropped)
291
+ * @param {{range?:string, from?:string|null, to?:string|null, groupBy?:string, filter?:object, now?:number}} [opts]
292
+ */
293
+ export function aggregate(records, { range = 'this-month', from = null, to = null, groupBy = 'workflow', filter = {}, now = Date.now() } = {}) {
294
+ if (!GROUP_BYS.includes(groupBy)) throw new RangeError(`unknown groupBy "${groupBy}" (expected ${GROUP_BYS.join(' | ')})`);
295
+ const win = resolveRange(range, { now, from, to });
296
+ const usable = (records || []).filter((r) => r && Number.isFinite(startOf(r)));
297
+ const filtered = applyFilter(usable, filter);
298
+ const inWin = (s, e) => (r) => (s == null || startOf(r) >= s) && (e == null || startOf(r) < e);
299
+ const inRange = filtered.filter(inWin(win.startMs, win.endMs));
300
+ const kpis = computeKpis(inRange, now);
301
+ const prevKpis = win.prevStartMs == null ? null : computeKpis(filtered.filter(inWin(win.prevStartMs, win.prevEndMs)), now);
302
+ const hasActor = inRange.some((r) => r.actor);
303
+ const hasWorkspace = inRange.some((r) => r.target?.kind === 'workspace');
304
+ return {
305
+ range: win, groupBy, filter: { ...filter },
306
+ kpis, prev: prevKpis, deltas: prevKpis ? deltas(kpis, prevKpis) : null,
307
+ series: series(inRange, win, groupBy, now),
308
+ breakdowns: {
309
+ workflow: breakdown(inRange, 'workflow'),
310
+ source: breakdown(inRange, 'source'),
311
+ actor: hasActor ? breakdown(inRange, 'actor') : null,
312
+ project: hasWorkspace ? breakdown(inRange, 'project') : null,
313
+ models: breakdown(inRange, 'models'),
314
+ },
315
+ runs: [...inRange].sort((a, b) => startOf(b) - startOf(a)).map(toRunRow),
316
+ totalRecords: usable.length,
317
+ };
318
+ }
319
+
320
+ export const CSV_COLUMNS = Object.freeze(['startedAt', 'title', 'workflow', 'result', 'costUsd', 'wallMs', 'activeMs', 'reviewCycles', 'prNumber', 'prUrl', 'actor', 'source', 'projects', 'id']);
321
+
322
+ function csvCell(v) {
323
+ if (v == null) return '';
324
+ let s = String(v);
325
+ if (/^[=+\-@\t\r]/.test(s)) s = `'${s}`; // spreadsheet formula injection guard
326
+ return /[",\r\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
327
+ }
328
+
329
+ /** CSV of aggregate().runs rows (the filtered run table). */
330
+ export function toCsv(runRows) {
331
+ const lines = [CSV_COLUMNS.join(',')];
332
+ for (const r of runRows) {
333
+ lines.push([
334
+ r.startedAt, r.title, r.workflow, r.result, r.usd, r.wallMs, r.activeMs, r.reviewCycles,
335
+ r.pr?.number, r.pr?.url, r.actor, r.source, (r.projects || []).join(' '), r.id,
336
+ ].map(csvCell).join(','));
337
+ }
338
+ // BOM: Excel otherwise reads the file as the local code page and mangles every non-ASCII
339
+ // actor/title ("Siniša Đukić"). Sheets and LibreOffice ignore it.
340
+ return '' + lines.join('\r\n') + '\r\n';
341
+ }
@@ -0,0 +1,13 @@
1
+ // src/shared/team-metrics/workspace-match.mjs
2
+ // Does a workspace run record belong to workspace `ws`? Records written since
3
+ // `target.workspaceId` exists match on the stable id; older records (and hand-written
4
+ // ones) carry only the display name, so they fall back to a case-insensitive name
5
+ // match. Matching on the name alone orphaned a workspace's whole history on rename and
6
+ // pooled two same-named workspaces of different teammates.
7
+ export function matchesWorkspace(target, ws) {
8
+ if (!target || target.kind !== 'workspace' || !ws) return false;
9
+ const id = typeof target.workspaceId === 'string' && target.workspaceId ? target.workspaceId : null;
10
+ if (id && typeof ws.id === 'string' && ws.id) return id === ws.id;
11
+ const name = typeof ws.name === 'string' ? ws.name.toLowerCase() : '';
12
+ return !!name && String(target.workspace || '').toLowerCase() === name;
13
+ }
@@ -0,0 +1,21 @@
1
+ // ui/public/about-links.mjs
2
+ // The About card's outbound links. Split out of app.js's paintAbout so a test can
3
+ // drive it against the real #about-card markup without booting the whole app —
4
+ // app.js has no exports at all. Pure DOM in, pure DOM out; no fetch, no listeners.
5
+
6
+ /**
7
+ * Point the two feedback anchors at `info.bugsUrl` (package.json bugs.url, served
8
+ * as `app.bugsUrl` by GET /api/settings). A missing or empty bugsUrl leaves the
9
+ * static hrefs in index.html alone, so the links work before /api/settings lands.
10
+ *
11
+ * @param {ParentNode} root any node containing #aboutBugLink / #aboutIdeaLink
12
+ * @param {{bugsUrl?:string}} info
13
+ */
14
+ export function paintAboutInto(root, info = {}) {
15
+ if (!root || !info || typeof info.bugsUrl !== 'string' || !info.bugsUrl) return;
16
+ const base = info.bugsUrl.replace(/\/+$/, '');
17
+ const bug = root.querySelector('#aboutBugLink');
18
+ const idea = root.querySelector('#aboutIdeaLink');
19
+ if (bug) bug.href = `${base}/new?labels=bug`;
20
+ if (idea) idea.href = `${base}/new?labels=enhancement`;
21
+ }