@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,452 @@
1
+ // ui/public/team-metrics-surfaces.mjs
2
+ // Pure DOM renderers for the team-metrics switches outside the page (§4.11): Projects cell,
3
+ // Enable dialog body, metrics-home picker (wizard step + Change sheet), workspace card row.
4
+ // Like stats-view.mjs: no fetch, no listeners — app.js delegates events.
5
+
6
+ const MO = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
7
+ export const DISABLE_HINT = 'Disable for the whole team: git push origin --delete worca-metrics';
8
+
9
+ function h(doc, tag, cls, text) {
10
+ const n = doc.createElement(tag);
11
+ if (cls) n.className = cls;
12
+ if (text != null) n.textContent = text;
13
+ return n;
14
+ }
15
+ const dayLabel = (iso) => { const d = new Date(iso); return Number.isFinite(d.getTime()) ? `${MO[d.getUTCMonth()]} ${d.getUTCDate()}` : ''; };
16
+ const dot = (doc, tone) => { const d = h(doc, 'span', `dot tm-dot ${tone}`); d.setAttribute('aria-hidden', 'true'); return d; };
17
+ const code = (doc, text) => h(doc, 'code', 'mono', text);
18
+ function btn(doc, cls, text) { const b = h(doc, 'button', `btn-ghost btn-mini ${cls}`, text); b.type = 'button'; return b; }
19
+
20
+ /** Classify a project status (from /api/team-metrics/scopes → projects[]) into a cell variant. */
21
+ export function projectTmState(s) {
22
+ if (s.hasOrigin === false) return { kind: 'no-origin' };
23
+ if (!s.enabled) return { kind: 'off' };
24
+ if (s.delegateTo && s.delegateState === 'invalid') return { kind: 'delegate-invalid' };
25
+ // Enabled, but the sink could not be resolved (e.g. CONFIG_UNKNOWN — the branch exists but has
26
+ // never been fetched). Runs are being skipped, so the cell must not read plain "On".
27
+ if (s.blocked) return { kind: 'blocked' };
28
+ if (s.lastError && s.pending > 0) return { kind: 'rejected' }; // nothing pending → nothing to retry
29
+ if (s.pending > 0) return { kind: 'pending' };
30
+ if (s.delegateTo) return { kind: 'delegated' };
31
+ return { kind: 'on' };
32
+ }
33
+
34
+ function recordSwitch(doc, s) {
35
+ const label = h(doc, 'label', 'switch-row tm-record-row');
36
+ const cb = h(doc, 'input', 'sw-input tm-record');
37
+ cb.type = 'checkbox';
38
+ cb.checked = s.record !== false;
39
+ cb.dataset.key = s.key;
40
+ // Personal, per-machine opt-out — worded as a share choice so it reads as
41
+ // distinct from the team-level setup button of the Off state.
42
+ cb.setAttribute('aria-label', `Include my runs for ${s.name}`);
43
+ label.append(cb, h(doc, 'span', 'switch switch-sm'), h(doc, 'span', 'txt', 'Include my runs'));
44
+ return label;
45
+ }
46
+
47
+ /**
48
+ * The Projects-list cell is a fixed two-row block: the "Team metrics" title over a
49
+ * status line on the left, and ONE control column on the right that spans both
50
+ * rows (see .tm-cell in style.css). The control never wraps under the status,
51
+ * and the title makes clear what the control acts on.
52
+ */
53
+ export function renderProjectTmCell(s, { doc = globalThis.document } = {}) {
54
+ const cell = h(doc, 'div', 'tm-cell');
55
+ cell.dataset.key = s.key;
56
+ cell.append(h(doc, 'span', 'tm-label', 'Team metrics'));
57
+ const line = h(doc, 'div', 'tm-line');
58
+ const status = h(doc, 'span', 'tm-status');
59
+ const { kind } = projectTmState(s);
60
+ const actions = h(doc, 'div', 'tm-actions');
61
+ const runs = s.runs == null ? '' : ` · ${s.runs} run${s.runs === 1 ? '' : 's'}`;
62
+ const excluded = s.record === false; // personal opt-out (the "Include my runs" switch)
63
+ switch (kind) {
64
+ case 'no-origin':
65
+ status.classList.add('muted');
66
+ status.textContent = s.noGit ? 'Not available · not a git repository' : 'Not available · no origin remote';
67
+ break;
68
+ case 'off':
69
+ // Team-level setup (creates the shared branch on origin): a button that names
70
+ // the feature, never a switch — turning it off again is not a one-click action.
71
+ status.classList.add('muted'); status.textContent = 'Off · runs stay on this machine';
72
+ actions.append(btn(doc, 'tm-enable', 'Set up team metrics…'));
73
+ break;
74
+ case 'delegate-invalid':
75
+ line.append(dot(doc, 'red'));
76
+ // The resolver's code distinguishes chain / unknown from dangling; dangling keeps the mockup copy.
77
+ status.append(`Delegate invalid · points at `, code(doc, s.delegateTo),
78
+ s.delegateCode === 'DELEGATE_CHAIN' ? ', which itself delegates (no chains)'
79
+ : s.delegateCode === 'DELEGATE_UNKNOWN' ? ', which is not a project in Worca on this machine'
80
+ : ', which no longer records');
81
+ actions.append(btn(doc, 'tm-change', 'Change…'));
82
+ break;
83
+ case 'blocked':
84
+ line.append(dot(doc, 'amber'));
85
+ status.textContent = s.delegateCode === 'CONFIG_UNKNOWN'
86
+ ? 'On · branch not read yet · runs are not being recorded'
87
+ : `On · ${s.delegateDetail || 'the metrics branch could not be resolved'}`;
88
+ break;
89
+ case 'rejected': {
90
+ line.append(dot(doc, 'red'));
91
+ status.textContent = s.lastErrorCode === 'PUSH_REJECTED' && s.lastErrorHint ? 'Push rejected · branch protection' : 'Push failed';
92
+ actions.append(btn(doc, 'tm-push', 'Retry'));
93
+ break;
94
+ }
95
+ case 'pending':
96
+ line.append(dot(doc, 'amber'));
97
+ status.textContent = `On · since ${dayLabel(s.enabledAt)}`;
98
+ break;
99
+ case 'delegated':
100
+ // "Include my runs" off is a personal opt-out: the branch stays on for the team, so
101
+ // the status says so instead of a green "On" that would read as "my runs are in".
102
+ line.append(dot(doc, excluded ? 'grey' : 'green'));
103
+ // Kept short: the status shares ~220px with the switch (see .tm-status), so the
104
+ // opted-out copy leads with what matters and drops the run count.
105
+ if (excluded) status.append('On for the team · in ', code(doc, s.delegateTo), ' · yours excluded');
106
+ else status.append('On · recorded in ', code(doc, s.delegateTo), runs);
107
+ actions.append(recordSwitch(doc, s));
108
+ break;
109
+ default:
110
+ line.append(dot(doc, excluded ? 'grey' : 'green'));
111
+ status.textContent = excluded
112
+ ? 'On for the team · yours excluded'
113
+ : `On · since ${dayLabel(s.enabledAt)}${runs ? `${runs} recorded` : ''}`; // "· 1 run recorded"
114
+ actions.append(recordSwitch(doc, s));
115
+ }
116
+ // `enabled` alone is not enough: the fixture for the 'Not available · no origin remote' cell is
117
+ // { enabled: true, hasOrigin: false }, and telling that user how to delete the team's branch is
118
+ // both wrong and alarming. Only a cell that really points at a live branch gets the hint.
119
+ if (s.enabled && s.hasOrigin !== false) status.title = DISABLE_HINT;
120
+ line.append(status);
121
+ if (kind === 'pending') {
122
+ line.append(h(doc, 'span', 'badge amber', `${s.pending} pending push`));
123
+ actions.append(btn(doc, 'tm-push', 'Push now'));
124
+ }
125
+ cell.append(line);
126
+ if (kind === 'rejected') {
127
+ const hint = h(doc, 'small', 'hint mono tm-hint');
128
+ hint.append(String(s.lastError || '').trim().split('\n')[0]);
129
+ if (s.lastErrorHint) hint.append(' · ', s.lastErrorHint);
130
+ cell.append(hint);
131
+ }
132
+ if (kind === 'delegate-invalid') cell.append(h(doc, 'small', 'hint mono tm-hint', 'runs are not being recorded · pick a new target'));
133
+ // This project is a workspace's metrics home: its switch also decides whether THIS
134
+ // machine's runs of that workspace are recorded (docs/team-metrics.md, "Include my runs").
135
+ if (s.enabled && Array.isArray(s.homeFor) && s.homeFor.length) {
136
+ const names = s.homeFor.join(', ');
137
+ const hint = h(doc, 'small', 'hint tm-hint tm-home-for');
138
+ hint.append(`Metrics home for ${names} · "Include my runs" covers its workspace runs too`);
139
+ hint.title = `Workspace runs of ${names} are written to this project's branch. Turning "Include my runs" off here also stops your workspace runs from being recorded.`;
140
+ cell.append(hint);
141
+ }
142
+ if (actions.childNodes.length) cell.append(actions);
143
+ return cell;
144
+ }
145
+
146
+ function radioCard(doc, { name, value, checked, title, body, disabled = false }) {
147
+ const card = h(doc, 'label', `radio-card${checked ? ' on' : ''}${disabled ? ' disabled' : ''}`);
148
+ const input = h(doc, 'input');
149
+ input.type = 'radio'; input.name = name; input.value = value; input.checked = !!checked; input.disabled = disabled;
150
+ const text = h(doc, 'span', 'radio-card-text');
151
+ text.append(h(doc, 'b', null, title));
152
+ const p = h(doc, 'small', 'hint');
153
+ for (const part of body) p.append(typeof part === 'string' ? part : code(doc, part.code));
154
+ text.append(p);
155
+ card.append(input, text);
156
+ return card;
157
+ }
158
+
159
+ /** Enable dialog body (boards 5 + 6). mode: 'here' | 'delegate'. */
160
+ export function renderEnableDialogBody({ project, origin, candidates = [], mode = 'here', attribution = 'git-user', change = false }, { doc = globalThis.document } = {}) {
161
+ const root = h(doc, 'div', 'tm-enable-body');
162
+ const sub = h(doc, 'div', 'sub');
163
+ sub.append('for ', h(doc, 'b', null, project.name), ' · origin ', code(doc, origin || '—'));
164
+ root.append(sub);
165
+
166
+ const where = h(doc, 'div', 'field');
167
+ where.append(h(doc, 'label', null, 'Where to record'));
168
+ // change:true is the "Change…" action on an invalid delegation marker. "Here" would POST
169
+ // {mode:'here'} against a branch that already exists, which enable answers with 'joined' — the
170
+ // dialog would close as if it had worked while the delegation stayed broken.
171
+ where.append(radioCard(doc, { name: 'tm-where', value: 'here', checked: mode === 'here' && !change, disabled: change, title: 'Here, on this repository',
172
+ body: ['Creates an orphan branch ', { code: 'worca-metrics' }, ' on origin holding only ', { code: '.worca-metrics/' }, '. Every finished run — done, failed or stopped — is pushed there as one file, by every teammate whose Worca sees the branch.'] }));
173
+ const delegateCard = radioCard(doc, { name: 'tm-where', value: 'delegate', checked: mode === 'delegate', disabled: candidates.length === 0,
174
+ title: 'In another project that already records',
175
+ body: ["A tiny marker branch on this origin points teammates at the target. This project's single-project runs land on the target's branch, still labelled with this project."] });
176
+ where.append(delegateCard);
177
+ if (mode === 'delegate') {
178
+ const sel = h(doc, 'select', 'select tm-delegate-target');
179
+ sel.setAttribute('aria-label', 'Target project');
180
+ for (const c of candidates) { const o = h(doc, 'option', null, c.label); o.value = c.slug; sel.append(o); }
181
+ const wrap = h(doc, 'div', 'select-wrap tm-delegate-wrap');
182
+ wrap.append(sel);
183
+ delegateCard.append(wrap);
184
+ delegateCard.append(h(doc, 'small', 'hint', "Only projects that record locally are listed. Attribution follows the target's policy (git user name)."));
185
+ }
186
+ if (change) where.append(h(doc, 'small', 'hint', 'This project already has a marker branch — pick a new target.'));
187
+ if (!candidates.length) where.append(h(doc, 'small', 'hint', 'No project records locally yet — record here first, then point other projects at it.'));
188
+ root.append(where);
189
+
190
+ if (mode === 'here') {
191
+ const attr = h(doc, 'div', 'field');
192
+ attr.append(h(doc, 'label', null, 'Attribution'));
193
+ attr.append(radioCard(doc, { name: 'tm-attribution', value: 'git-user', checked: attribution !== 'none', title: 'Record the git user name',
194
+ body: ['Each record carries who ran it, so spend can be sliced per person. Visible to anyone with read access to the repository.'] }));
195
+ attr.append(radioCard(doc, { name: 'tm-attribution', value: 'none', checked: attribution === 'none', title: 'No attribution',
196
+ body: ['Records carry no person. Breakdowns by actor are unavailable for this project.'] }));
197
+ attr.append(h(doc, 'small', 'hint', 'A team decision, made once. Changing it later means committing to the branch by hand.'));
198
+ root.append(attr);
199
+ }
200
+ const warn = h(doc, 'div', 'hint tm-warn');
201
+ warn.append('If your repository protects branches by wildcard, exempt ', code(doc, 'worca-metrics'), ' first — Worca pushes to it directly, without a pull request.');
202
+ root.append(warn);
203
+ return root;
204
+ }
205
+
206
+ /** Metrics-home picker (board 7 list; reused in the card's Change sheet). */
207
+ export function renderMetricsHomePicker(members, { selectedPath, doc = globalThis.document } = {}) {
208
+ const list = h(doc, 'div', 'wiz-list tm-home-list');
209
+ const recording = members.filter((m) => m.hasOrigin && m.recordsLocally);
210
+ const selected = selectedPath !== undefined ? selectedPath : (recording.length === 1 ? recording[0].path : null);
211
+ for (const m of members) {
212
+ const row = h(doc, 'div', `wiz-row${m.hasOrigin ? '' : ' off'}`);
213
+ row.dataset.path = m.path;
214
+ row.dataset.key = m.key || '';
215
+ const name = h(doc, 'span', 'wiz-row-name mono', m.slug);
216
+ const status = h(doc, 'span', 'wiz-row-status');
217
+ if (!m.hasOrigin) {
218
+ status.textContent = 'No origin remote · cannot host metrics';
219
+ row.append(name, status);
220
+ } else if (m.recordsLocally) {
221
+ const label = h(doc, 'label', 'wiz-row-pick');
222
+ const r = h(doc, 'input');
223
+ r.type = 'radio'; r.name = 'tm-home'; r.value = m.path; r.checked = selected === m.path;
224
+ label.append(r, name);
225
+ // Two lines in a fixed-width column so every row's status starts at the same x: the
226
+ // sheet is 600px wide and the slug on the left must stay whole.
227
+ const lines = h(doc, 'span', 'wiz-row-status-lines');
228
+ lines.append(h(doc, 'span', 'wiz-row-status-main', `On · since ${dayLabel(m.enabledAt)}`));
229
+ if (m.workspaceRuns != null) lines.append(h(doc, 'span', 'wiz-row-status-sub', `${m.workspaceRuns} workspace run${m.workspaceRuns === 1 ? '' : 's'} recorded`));
230
+ status.append(dot(doc, 'green'), lines);
231
+ row.append(label, status);
232
+ } else if (m.enabled && m.delegateTo) {
233
+ status.append(dot(doc, 'grey'), `Delegates to ${m.delegateTo} · cannot host metrics`);
234
+ row.classList.add('off');
235
+ row.append(name, status);
236
+ } else {
237
+ status.append(dot(doc, 'grey'), 'Not enabled');
238
+ row.append(name, status, btn(doc, 'tm-enable-now', 'Enable now…'));
239
+ }
240
+ list.append(row);
241
+ }
242
+ return list;
243
+ }
244
+
245
+ /** Rows shown before "Show N more" — a 50-project workspace must not become a 50-row card. */
246
+ export const WS_MEMBERS_COLLAPSED = 6;
247
+ // Home first, then members that need attention, then the ones already routed; stable otherwise.
248
+ const MEMBER_RANK = { home: 0, 'not-recording': 1, 'records-elsewhere': 1, routed: 2 };
249
+ const sortMembers = (members) => members.map((x, i) => [x, i]).sort((a, b) => ((MEMBER_RANK[a[0].state] ?? 1) - (MEMBER_RANK[b[0].state] ?? 1)) || (a[1] - b[1])).map(([x]) => x);
250
+ const needsAttention = (x) => x.state !== 'home' && x.state !== 'routed';
251
+ // Routing only touches members with no worca-metrics branch: one that already records on its
252
+ // own branch, delegates elsewhere or has no origin is skipped, so the button would do nothing.
253
+ const routable = (x) => x.state === 'not-recording' && x.reason !== 'no origin remote';
254
+
255
+ const SVG = 'http://www.w3.org/2000/svg';
256
+ /** The Team metrics sidebar icon, inline: marks the metrics home without labelling the project "home". */
257
+ export function tmIcon(doc) {
258
+ const svg = doc.createElementNS(SVG, 'svg');
259
+ svg.setAttribute('class', 'tm-icon'); svg.setAttribute('viewBox', '0 0 24 24'); svg.setAttribute('fill', 'none'); svg.setAttribute('aria-hidden', 'true');
260
+ svg.setAttribute('stroke', 'currentColor'); svg.setAttribute('stroke-width', '1.9'); svg.setAttribute('stroke-linecap', 'round'); svg.setAttribute('stroke-linejoin', 'round');
261
+ for (const d of ['M4 19h16', 'M6 15l4-5 4 3 4-6']) { const path = doc.createElementNS(SVG, 'path'); path.setAttribute('d', d); svg.append(path); }
262
+ for (const [cx, cy] of [[6, 15], [10, 10], [14, 13], [18, 7]]) { const c = doc.createElementNS(SVG, 'circle'); c.setAttribute('cx', cx); c.setAttribute('cy', cy); c.setAttribute('r', '1.2'); svg.append(c); }
263
+ return svg;
264
+ }
265
+ const HOME_TITLE = 'Metrics home: workspace runs are recorded on this project\'s worca-metrics branch';
266
+ /** The icon with its tooltip on a wrapper (an SVG <title> would leak into textContent). */
267
+ function homeMark(doc) {
268
+ const m = h(doc, 'span', 'tm-home-mark');
269
+ m.title = HOME_TITLE; m.setAttribute('role', 'img'); m.setAttribute('aria-label', 'Metrics home');
270
+ m.append(tmIcon(doc));
271
+ return m;
272
+ }
273
+
274
+ /**
275
+ * Header summary (collapsed card): "3 projects · ⌇ acme/gateway · 12 workspace runs · 2 not recording".
276
+ * Answers what people scan for — how big, where runs go, is anything wrong — on one line.
277
+ */
278
+ export function renderWsSummary(ws, { doc = globalThis.document, pending = false } = {}) {
279
+ const frag = doc.createDocumentFragment();
280
+ const n = (ws.projectPaths || ws.members || []).length;
281
+ frag.append(`${n} project${n === 1 ? '' : 's'}`);
282
+ // The scopes call is still out: say so instead of "no metrics home", which reads as final.
283
+ if (pending) { frag.append(' · ', h(doc, 'span', 'ws-sum-pending', 'checking metrics…')); return frag; }
284
+ const home = ws.home || { state: 'unset' };
285
+ if (home.state === 'unset') {
286
+ frag.append(' · ', h(doc, 'span', 'muted', 'no metrics home'));
287
+ } else {
288
+ frag.append(' · ', homeMark(doc), h(doc, 'span', 'mono', home.slug));
289
+ if (home.state !== 'ok') frag.append(' · ', h(doc, 'span', 'ws-sum-bad', home.detail || 'stale'));
290
+ else if (home.runs != null) frag.append(` · ${home.runs} workspace run${home.runs === 1 ? '' : 's'}`); // project-level runs live elsewhere
291
+ }
292
+ const silent = (ws.members || []).filter((x) => x.state !== 'home' && !x.recordsOn).length;
293
+ if (silent) frag.append(' · ', h(doc, 'span', 'ws-sum-warn', `${silent} not recording`));
294
+ return frag;
295
+ }
296
+
297
+ /** One table cell: what this member's metrics branch is, or that there is none. */
298
+ function branchCell(doc, x, home) {
299
+ const td = h(doc, 'td', 'ws-col-branch');
300
+ if (!x.recordsOn) { td.append(h(doc, 'span', 'muted', x.reason === 'no origin remote' ? 'no origin remote' : 'not set')); return td; }
301
+ if (x.recordsOn.toLowerCase() === (x.slug || '').toLowerCase()) td.append(code(doc, 'origin/worca-metrics'));
302
+ else td.append(code(doc, 'worca-metrics'), ' on ', code(doc, x.recordsOn));
303
+ return td;
304
+ }
305
+ /** "Workspace runs" column: the count on the home row, "–" everywhere else. */
306
+ function runsCell(doc, x, home) {
307
+ const td = h(doc, 'td', 'ws-col-runs');
308
+ if (x.state === 'home' && home.runs != null) td.textContent = String(home.runs);
309
+ else td.append(h(doc, 'span', 'muted', '–'));
310
+ return td;
311
+ }
312
+ /** ✓ / ✗ glyph for a metrics line. */
313
+ function mark(doc, tone) {
314
+ const svg = doc.createElementNS(SVG, 'svg');
315
+ svg.setAttribute('class', `ws-mark ${tone}`); svg.setAttribute('viewBox', '0 0 24 24'); svg.setAttribute('fill', 'none'); svg.setAttribute('aria-hidden', 'true');
316
+ svg.setAttribute('stroke', 'currentColor'); svg.setAttribute('stroke-width', '2.4'); svg.setAttribute('stroke-linecap', 'round'); svg.setAttribute('stroke-linejoin', 'round');
317
+ for (const d of (tone === 'bad' ? ['M6 6l12 12', 'M18 6L6 18'] : ['M5 12.5l4.5 4.5L19 7'])) { const path = doc.createElementNS(SVG, 'path'); path.setAttribute('d', d); svg.append(path); }
318
+ return svg;
319
+ }
320
+ const metricLine = (doc, tone, text) => { const l = h(doc, 'span', `ws-metric ${tone}`); l.append(mark(doc, tone), text); return l; };
321
+ /**
322
+ * "Metrics status": which metrics this project has — "–" (no branch), "Project metrics",
323
+ * "Workspace metrics", or both with workspace first. Routing is a consolidation detail (the
324
+ * branch column says where), not a status.
325
+ */
326
+ function statusCell(doc, x, home) {
327
+ const td = h(doc, 'td', 'ws-col-status');
328
+ if (x.state === 'home') {
329
+ if (home.state === 'ok') td.append(metricLine(doc, 'ok', 'Workspace metrics'), metricLine(doc, 'ok', 'Project metrics'));
330
+ else td.append(metricLine(doc, 'bad', `Workspace metrics · ${home.detail || 'stale'}`));
331
+ } else if (x.recordsOn) td.append(metricLine(doc, 'ok', 'Project metrics'));
332
+ else td.append(h(doc, 'span', 'muted', '–'));
333
+ return td;
334
+ }
335
+
336
+ /**
337
+ * Workspace card body (board 8, reworked): ONE projects table — project, metrics branch, status —
338
+ * with the metrics home marked by the Team metrics icon, plus the actions that act on it.
339
+ * `ws` = /api/team-metrics/scopes → workspaces[].
340
+ */
341
+ /**
342
+ * The card's metrics block while /scopes is still out: the same head and table, one row per
343
+ * member path with shimmer bars where the branch, runs and status will land. Replaced by
344
+ * renderWsMetricsRow as soon as the statuses arrive (or by the plain summary when the workspace
345
+ * has none). Decorative: the card carries aria-busy for assistive tech.
346
+ */
347
+ export function renderWsMetricsPending(ws, { doc = globalThis.document } = {}) {
348
+ const root = h(doc, 'div', 'ws-home-inner is-pending');
349
+ root.setAttribute('aria-hidden', 'true');
350
+ const paths = ws.projectPaths || [];
351
+ const head = h(doc, 'div', 'ws-tbl-head');
352
+ head.append(h(doc, 'span', 'tm-label', 'Projects'), h(doc, 'span', 'badge', String(paths.length)));
353
+ root.append(head);
354
+ const table = h(doc, 'table', 'ws-projects-tbl');
355
+ const thead = h(doc, 'thead'); const hr = h(doc, 'tr');
356
+ // Project → status (is it recording?) → branch (where) → runs (a number, on the right edge).
357
+ hr.append(h(doc, 'th', null, 'Project'), h(doc, 'th', null, 'Metrics status'), h(doc, 'th', null, 'Metrics branch'), h(doc, 'th', 'ws-col-runs', 'Workspace runs'));
358
+ thead.append(hr); table.append(thead);
359
+ const tbody = h(doc, 'tbody');
360
+ paths.slice(0, WS_MEMBERS_COLLAPSED).forEach((path) => {
361
+ const tr = h(doc, 'tr', 'ws-member is-pending');
362
+ tr.dataset.path = path;
363
+ const name = h(doc, 'td', 'ws-col-project');
364
+ name.append(h(doc, 'span', 'mono ws-member-slug', String(path).split(/[\\/]/).filter(Boolean).pop() || path));
365
+ const cell = (cls, w) => { const td = h(doc, 'td', cls); td.append(h(doc, 'span', `skel ${w}`)); return td; };
366
+ tr.append(name, cell('ws-col-status', 'w60'), cell('ws-col-branch', 'w70'), cell('ws-col-runs', 'w25'));
367
+ tbody.append(tr);
368
+ });
369
+ table.append(tbody);
370
+ root.append(table);
371
+ return root;
372
+ }
373
+
374
+ export function renderWsMetricsRow(ws, { doc = globalThis.document } = {}) {
375
+ const root = h(doc, 'div', 'ws-home-inner');
376
+ const home = ws.home || { state: 'unset' };
377
+ const members = sortMembers(ws.members || []);
378
+
379
+ const head = h(doc, 'div', 'ws-tbl-head');
380
+ head.append(h(doc, 'span', 'tm-label', 'Projects'), h(doc, 'span', 'badge', String(members.length)));
381
+ const collapsed = members.length > WS_MEMBERS_COLLAPSED;
382
+ if (collapsed) {
383
+ // The hidden rows still count: say what they add up to.
384
+ const routed = members.filter((x) => x.state === 'routed').length;
385
+ head.append(h(doc, 'span', 'ws-members-summary', `${routed} routed · ${members.filter(needsAttention).length} not routed`));
386
+ }
387
+ const actions = h(doc, 'span', 'ws-tbl-actions');
388
+ if (home.state !== 'unset' && members.some(routable)) actions.append(btn(doc, 'ws-route', 'Route all to metrics home'));
389
+ actions.append(btn(doc, 'ws-home-change', home.state === 'unset' ? 'Choose metrics home…' : 'Change metrics home…'));
390
+ head.append(actions);
391
+ root.append(head);
392
+
393
+ const table = h(doc, 'table', 'ws-projects-tbl');
394
+ const thead = h(doc, 'thead'); const hr = h(doc, 'tr');
395
+ // Project → status (is it recording?) → branch (where) → runs (a number, on the right edge).
396
+ hr.append(h(doc, 'th', null, 'Project'), h(doc, 'th', null, 'Metrics status'), h(doc, 'th', null, 'Metrics branch'), h(doc, 'th', 'ws-col-runs', 'Workspace runs'));
397
+ thead.append(hr); table.append(thead);
398
+ const tbody = h(doc, 'tbody');
399
+ members.forEach((x, i) => {
400
+ const tr = h(doc, 'tr', `ws-member ${x.state}`);
401
+ tr.dataset.path = x.path || '';
402
+ if (collapsed && i >= WS_MEMBERS_COLLAPSED) tr.hidden = true;
403
+ // No icon in this column: every project name starts at the same x. The home is told by
404
+ // its status ("Records workspace runs") and by the icon in the card's header summary.
405
+ const name = h(doc, 'td', 'ws-col-project');
406
+ name.append(h(doc, 'span', 'mono ws-member-slug', x.slug));
407
+ tr.append(name, statusCell(doc, x, home), branchCell(doc, x, home), runsCell(doc, x, home));
408
+ tbody.append(tr);
409
+ });
410
+ table.append(tbody);
411
+ root.append(table);
412
+ if (collapsed) {
413
+ const rest = members.length - WS_MEMBERS_COLLAPSED;
414
+ const more = btn(doc, 'ws-members-more', `Show ${rest} more`);
415
+ more.setAttribute('aria-expanded', 'false');
416
+ more.addEventListener('click', (e) => {
417
+ e.stopPropagation(); // the card header toggles the card on click
418
+ const open = more.getAttribute('aria-expanded') !== 'true';
419
+ more.setAttribute('aria-expanded', String(open));
420
+ more.textContent = open ? 'Show less' : `Show ${rest} more`;
421
+ [...tbody.children].forEach((tr, i) => { if (i >= WS_MEMBERS_COLLAPSED) tr.hidden = !open; });
422
+ });
423
+ root.append(more);
424
+ }
425
+
426
+ // Only the cases that need a sentence: no home, a stale home, or the home's "Include my runs" off.
427
+ if (home.state === 'unset') {
428
+ root.append(h(doc, 'small', 'hint ws-home-hint', 'Workspace runs are not recorded until a metrics home is chosen. The home is a per-machine choice; the Team metrics page reads every recording member.'));
429
+ } else if (home.state !== 'ok') {
430
+ const you = h(doc, 'small', 'hint ws-home-hint warn');
431
+ you.append(dot(doc, 'red'), ` Workspace runs are not being recorded: ${home.detail || 'the metrics home is stale'}.`);
432
+ root.append(you);
433
+ } else if (home.record === false) {
434
+ const you = h(doc, 'small', 'hint ws-home-hint warn');
435
+ you.append(dot(doc, 'amber'), ` Your workspace runs are not recorded: "Include my runs" is off on ${home.slug}.`);
436
+ root.append(you);
437
+ }
438
+ root.append(h(doc, 'div', 'ws-route-results'));
439
+ return root;
440
+ }
441
+
442
+ export function renderRouteResults({ results = [] }, { doc = globalThis.document } = {}) {
443
+ const ul = h(doc, 'ul', 'tm-route-list');
444
+ for (const r of results) {
445
+ const li = h(doc, 'li', r.result);
446
+ li.append(h(doc, 'span', 'mono', r.slug), ` · ${r.result}${r.reason ? ` · ${r.reason}` : ''}${r.error ? ` · ${r.error}` : ''}`);
447
+ if (r.stderr) li.append(h(doc, 'pre', 'hint mono tm-stderr', r.stderr.trim()));
448
+ if (r.hint) li.append(h(doc, 'small', 'hint', r.hint));
449
+ ul.append(li);
450
+ }
451
+ return ul;
452
+ }