@worca/app 1.3.0 → 1.4.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (187) hide show
  1. package/README.md +85 -6
  2. package/agents/clarify.meta.json +1 -0
  3. package/agents/memoryDefragmenter.meta.json +2 -1
  4. package/agents/reviewer.meta.json +60 -0
  5. package/agents/worca-cc-code-reviewer.md +33 -0
  6. package/agents/worca-cc-memory-defragmenter.md +5 -3
  7. package/agents/workspaceScanner.meta.json +1 -0
  8. package/package.json +14 -10
  9. package/scripts/git-diff.mjs +25 -0
  10. package/scripts/gitDiff.meta.json +18 -0
  11. package/scripts/js-inline.mjs +11 -0
  12. package/scripts/js.meta.json +22 -0
  13. package/scripts/py-inline.py +27 -0
  14. package/scripts/py.meta.json +22 -0
  15. package/scripts/shell.meta.json +24 -0
  16. package/skills/worca/SKILL.md +3 -2
  17. package/src/cli/models.mjs +247 -0
  18. package/src/cli/render.mjs +72 -4
  19. package/src/cli/schedule.mjs +494 -0
  20. package/src/cli/worca-cc.mjs +1001 -22
  21. package/src/core/agent-registry.mjs +75 -23
  22. package/src/core/agent-store.mjs +51 -2
  23. package/src/core/artifacts.mjs +73 -10
  24. package/src/core/ask/events.mjs +119 -1
  25. package/src/core/ask/limits.mjs +32 -4
  26. package/src/core/ask/mcp-stdio.mjs +12 -0
  27. package/src/core/ask/model-deps.mjs +126 -0
  28. package/src/core/ask/model-proposal.mjs +370 -0
  29. package/src/core/ask/models.mjs +12 -0
  30. package/src/core/ask/policy-deps.mjs +124 -0
  31. package/src/core/ask/policy-proposal.mjs +363 -0
  32. package/src/core/ask/prompt.mjs +74 -9
  33. package/src/core/ask/proposal.mjs +54 -5
  34. package/src/core/ask/schedule-deps.mjs +83 -0
  35. package/src/core/ask/schedule-spec.mjs +310 -0
  36. package/src/core/ask/script-deps.mjs +357 -0
  37. package/src/core/ask/source-deps.mjs +52 -0
  38. package/src/core/ask/source-spec.mjs +157 -0
  39. package/src/core/ask/spawn.mjs +1 -0
  40. package/src/core/ask/store.mjs +6 -3
  41. package/src/core/ask/tool-deps.mjs +4 -0
  42. package/src/core/ask/tools.mjs +657 -37
  43. package/src/core/ask/turn.mjs +109 -2
  44. package/src/core/ask-files.mjs +406 -0
  45. package/src/core/ask-forms.mjs +195 -0
  46. package/src/core/ask-projection.mjs +72 -0
  47. package/src/core/bridge/errors.mjs +84 -0
  48. package/src/core/bridge/provider-ops.mjs +281 -0
  49. package/src/core/bridge/providers/copilot.mjs +269 -0
  50. package/src/core/bridge/providers/endpoint.mjs +257 -0
  51. package/src/core/bridge/registry.mjs +88 -0
  52. package/src/core/bridge/semaphore.mjs +73 -0
  53. package/src/core/bridge/server.mjs +184 -0
  54. package/src/core/bridge/telemetry.mjs +53 -0
  55. package/src/core/bridge/translate/request.mjs +252 -0
  56. package/src/core/bridge/translate/response.mjs +82 -0
  57. package/src/core/bridge/translate/stream.mjs +242 -0
  58. package/src/core/bridge/upstream.mjs +209 -0
  59. package/src/core/chat/command-router.mjs +58 -4
  60. package/src/core/chat/notifier.mjs +14 -1
  61. package/src/core/chat/renderers.mjs +35 -0
  62. package/src/core/claude-runner.mjs +126 -19
  63. package/src/core/config.mjs +212 -31
  64. package/src/core/cost-budget.mjs +3 -2
  65. package/src/core/db.mjs +169 -15
  66. package/src/core/failure-policy.mjs +10 -0
  67. package/src/core/fs-browse.mjs +16 -4
  68. package/src/core/git-info.mjs +22 -0
  69. package/src/core/graph/builtin-workflows.mjs +3 -1
  70. package/src/core/graph/exec-io.mjs +71 -0
  71. package/src/core/graph/executor.mjs +139 -70
  72. package/src/core/graph/human-evidence.mjs +131 -0
  73. package/src/core/graph/python-probe.mjs +172 -0
  74. package/src/core/graph/registry-ports.mjs +10 -6
  75. package/src/core/graph/scheduler.mjs +39 -24
  76. package/src/core/graph/script-child.mjs +81 -0
  77. package/src/core/graph/script-runner.mjs +597 -0
  78. package/src/core/graph/worca_script.py +207 -0
  79. package/src/core/guardrail-store.mjs +16 -0
  80. package/src/core/human-backfill.mjs +108 -0
  81. package/src/core/human-rate.mjs +17 -0
  82. package/src/core/index-html.mjs +6 -2
  83. package/src/core/memory-defrag-model.mjs +112 -0
  84. package/src/core/memory-store.mjs +70 -18
  85. package/src/core/memory-sync.mjs +22 -11
  86. package/src/core/metrics/read.mjs +4 -1
  87. package/src/core/metrics/record.mjs +50 -2
  88. package/src/core/metrics/sync.mjs +6 -4
  89. package/src/core/model-env.mjs +149 -0
  90. package/src/core/model-test.mjs +14 -1
  91. package/src/core/notifications.mjs +128 -0
  92. package/src/core/onboarding.mjs +8 -2
  93. package/src/core/orchestrator.mjs +357 -26
  94. package/src/core/phases.mjs +95 -6
  95. package/src/core/plugin-api.mjs +24 -7
  96. package/src/core/plugin-manifest.mjs +184 -18
  97. package/src/core/plugin-models.mjs +1 -0
  98. package/src/core/plugin-script-cases.mjs +118 -0
  99. package/src/core/plugin-store.mjs +163 -17
  100. package/src/core/plugin-workflows.mjs +71 -17
  101. package/src/core/policy/cache.mjs +116 -0
  102. package/src/core/policy/effective.mjs +175 -0
  103. package/src/core/policy/gate.mjs +91 -0
  104. package/src/core/policy/local.mjs +145 -0
  105. package/src/core/policy/registry.mjs +330 -0
  106. package/src/core/policy/scope.mjs +61 -0
  107. package/src/core/policy/state.mjs +79 -0
  108. package/src/core/policy/sync.mjs +513 -0
  109. package/src/core/protocol.mjs +43 -0
  110. package/src/core/run-harness.mjs +421 -72
  111. package/src/core/scheduler.mjs +980 -0
  112. package/src/core/script-bench.mjs +628 -0
  113. package/src/core/script-registry.mjs +116 -0
  114. package/src/core/script-store.mjs +563 -0
  115. package/src/core/settings.mjs +489 -14
  116. package/src/core/stats.mjs +33 -2
  117. package/src/core/workflow-export.mjs +94 -3
  118. package/src/core/workflow-share.mjs +67 -18
  119. package/src/core/workflows.mjs +47 -11
  120. package/src/core/workspaces.mjs +18 -12
  121. package/src/shared/forms/answer.mjs +164 -0
  122. package/src/shared/forms/catalog.mjs +91 -0
  123. package/src/shared/forms/form-def.mjs +290 -0
  124. package/src/shared/forms/layout.mjs +67 -0
  125. package/src/shared/forms/paths.mjs +47 -0
  126. package/src/shared/forms/project.mjs +309 -0
  127. package/src/shared/forms/schema.mjs +205 -0
  128. package/src/shared/graph/agent-meta.mjs +55 -5
  129. package/src/shared/graph/constants.mjs +14 -2
  130. package/src/shared/graph/flow-layout.mjs +2 -1
  131. package/src/shared/graph/isomorphic.mjs +5 -3
  132. package/src/shared/graph/manifest.mjs +22 -13
  133. package/src/shared/graph/ports.mjs +45 -19
  134. package/src/shared/graph/script-cases.mjs +257 -0
  135. package/src/shared/graph/script-icons.mjs +46 -0
  136. package/src/shared/graph/script-infer.mjs +259 -0
  137. package/src/shared/graph/script-meta.mjs +408 -0
  138. package/src/shared/graph/script-templates.mjs +201 -0
  139. package/src/shared/graph/template.mjs +4 -4
  140. package/src/shared/graph/validate.mjs +89 -16
  141. package/src/shared/human-estimate.mjs +100 -0
  142. package/src/shared/schedule/recurrence.mjs +353 -0
  143. package/src/shared/team-metrics/aggregate.mjs +51 -11
  144. package/{scripts → tools}/install.mjs +3 -3
  145. package/ui/public/app.js +4390 -683
  146. package/ui/public/artifact-picker.mjs +189 -0
  147. package/ui/public/ask/dom.mjs +121 -0
  148. package/ui/public/ask/form-preview.mjs +55 -0
  149. package/ui/public/ask/form-renderer.mjs +250 -0
  150. package/ui/public/ask/registry.mjs +53 -0
  151. package/ui/public/ask/widgets-display.mjs +370 -0
  152. package/ui/public/ask/widgets-input.mjs +624 -0
  153. package/ui/public/ask/widgets-layout.mjs +90 -0
  154. package/ui/public/ask-panel.mjs +401 -27
  155. package/ui/public/ask-run-card.mjs +1 -1
  156. package/ui/public/bridge-view.mjs +694 -0
  157. package/ui/public/chat-settings-view.mjs +24 -0
  158. package/ui/public/code-editor.mjs +181 -0
  159. package/ui/public/getting-started.mjs +34 -7
  160. package/ui/public/graph/composer.mjs +138 -10
  161. package/ui/public/graph/inspector.mjs +61 -58
  162. package/ui/public/graph/palette.mjs +27 -9
  163. package/ui/public/graph/run-decor.mjs +34 -17
  164. package/ui/public/graph/run-hosts.mjs +7 -1
  165. package/ui/public/graph/save-dialog.mjs +3 -0
  166. package/ui/public/graph/view.mjs +23 -7
  167. package/ui/public/guardrails-view.mjs +15 -3
  168. package/ui/public/guide-spot.mjs +87 -9
  169. package/ui/public/index.html +480 -141
  170. package/ui/public/memory-view.mjs +22 -4
  171. package/ui/public/models-view.mjs +162 -17
  172. package/ui/public/node-tunables.mjs +33 -4
  173. package/ui/public/plugins-view.mjs +23 -1
  174. package/ui/public/results-view.mjs +4 -2
  175. package/ui/public/schedule-sheet.mjs +430 -0
  176. package/ui/public/schedules-view.mjs +432 -0
  177. package/ui/public/script-bench-view.mjs +1154 -0
  178. package/ui/public/script-forms.mjs +282 -0
  179. package/ui/public/script-wizard.mjs +529 -0
  180. package/ui/public/scripts-view.mjs +868 -0
  181. package/ui/public/stats-view.mjs +159 -52
  182. package/ui/public/style.css +1461 -44
  183. package/ui/public/team-metrics-surfaces.mjs +77 -16
  184. package/ui/public/team-metrics-view.mjs +68 -5
  185. package/ui/public/team-policy-view.mjs +1402 -0
  186. package/ui/public/ui-level.mjs +237 -0
  187. package/ui/server.mjs +1827 -73
@@ -15,6 +15,16 @@ function h(doc, tag, cls, text) {
15
15
  const dayLabel = (iso) => { const d = new Date(iso); return Number.isFinite(d.getTime()) ? `${MO[d.getUTCMonth()]} ${d.getUTCDate()}` : ''; };
16
16
  const dot = (doc, tone) => { const d = h(doc, 'span', `dot tm-dot ${tone}`); d.setAttribute('aria-hidden', 'true'); return d; };
17
17
  const code = (doc, text) => h(doc, 'code', 'mono', text);
18
+ /** A project, workspace or home NAME inside prose: bold and mono, so it reads as a name, not a word. */
19
+ const ref = (doc, name) => h(doc, 'b', 'ref mono', name);
20
+ /** `text` with every occurrence of `name` rendered as a ref; plain text when the name is absent. */
21
+ function withRef(doc, text, name) {
22
+ const frag = doc.createDocumentFragment();
23
+ if (!name || !String(text).includes(name)) { frag.append(String(text)); return frag; }
24
+ const parts = String(text).split(name);
25
+ parts.forEach((part, i) => { if (i) frag.append(ref(doc, name)); if (part) frag.append(part); });
26
+ return frag;
27
+ }
18
28
  function btn(doc, cls, text) { const b = h(doc, 'button', `btn-ghost btn-mini ${cls}`, text); b.type = 'button'; return b; }
19
29
 
20
30
  /** Classify a project status (from /api/team-metrics/scopes → projects[]) into a cell variant. */
@@ -44,19 +54,61 @@ function recordSwitch(doc, s) {
44
54
  return label;
45
55
  }
46
56
 
57
+ const cap = (t) => (t ? t[0].toUpperCase() + t.slice(1) : t);
58
+ /**
59
+ * One project's team-metrics state in a few words: `short` is what a row chip and the project
60
+ * page's stat card show ("on · 3 runs", "via acme/gateway", "push failed"), `detail` the sentence
61
+ * behind it, `tone` the dot colour. The full cell (renderProjectTmCell) says the same at length.
62
+ */
63
+ export function projectTmSummary(s) {
64
+ const { kind } = projectTmState(s);
65
+ const excluded = s.record === false;
66
+ const runs = s.runs == null ? '' : `${s.runs} run${s.runs === 1 ? '' : 's'}`;
67
+ const since = s.enabledAt ? `since ${dayLabel(s.enabledAt)}` : '';
68
+ switch (kind) {
69
+ case 'no-origin': return { kind, tone: 'muted', short: 'not available', detail: s.noGit ? 'not a git repository' : 'no origin remote' };
70
+ case 'off': return { kind, tone: 'grey', short: 'off', detail: 'runs stay on this machine' };
71
+ case 'delegate-invalid': return { kind, tone: 'red', short: 'delegate invalid', detail: `points at ${s.delegateTo} · runs are not being recorded` };
72
+ case 'blocked': return { kind, tone: 'amber', short: 'on · not recording', detail: s.delegateCode === 'CONFIG_UNKNOWN' ? 'branch not read yet' : (s.delegateDetail || 'the metrics branch could not be resolved') };
73
+ case 'rejected': return { kind, tone: 'red', short: 'push failed', detail: s.lastErrorCode === 'PUSH_REJECTED' && s.lastErrorHint ? 'branch protection' : String(s.lastError || '').trim().split('\n')[0] };
74
+ case 'pending': return { kind, tone: 'amber', short: `on · ${s.pending} pending push`, detail: since };
75
+ case 'delegated': return { kind, tone: excluded ? 'grey' : 'green', short: excluded ? `via ${s.delegateTo} · yours excluded` : `via ${s.delegateTo}`, ref: s.delegateTo, detail: [runs ? `${runs} recorded` : '', 'recorded in that project\'s branch'].filter(Boolean).join(' · ') };
76
+ default: return { kind, tone: excluded ? 'grey' : 'green', short: excluded ? 'on · yours excluded' : (runs ? `on · ${runs}` : 'on'), detail: since };
77
+ }
78
+ }
79
+
80
+ /**
81
+ * The Projects-row chip: a dot, the word and the short state — no controls, no hints. Everything
82
+ * it leaves out is on the project page's Team tab (renderProjectTmCell). Expert-level like the
83
+ * page tab; the row itself is the only control.
84
+ */
85
+ export function renderProjectTmChip(s, { doc = globalThis.document } = {}) {
86
+ const chip = h(doc, 'span', 'pl-team-item pl-tm');
87
+ chip.dataset.key = s.key;
88
+ const sum = projectTmSummary(s);
89
+ chip.dataset.kind = sum.kind;
90
+ if (sum.tone !== 'muted') chip.append(dot(doc, sum.tone));
91
+ const state = h(doc, 'span', `pl-team-state${sum.tone === 'muted' ? ' muted' : ''}`);
92
+ state.append(withRef(doc, sum.short, sum.ref));
93
+ chip.append(h(doc, 'span', 'pl-team-name', 'Metrics'), ' ', state);
94
+ chip.title = `Team metrics: ${cap(sum.short)}${sum.detail ? ` · ${sum.detail}` : ''}`;
95
+ return chip;
96
+ }
97
+
47
98
  /**
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.
99
+ * The project page's team-metrics block (and, before this branch, the Projects-list cell): a
100
+ * fixed two-row grid — the "Team metrics" title over a status line on the left, and ONE control
101
+ * column on the right that spans both rows (see .tm-cell in style.css). The control never wraps
102
+ * under the status. `heading: false` drops the title when a panel head already names it.
52
103
  */
53
- export function renderProjectTmCell(s, { doc = globalThis.document } = {}) {
104
+ export function renderProjectTmCell(s, { doc = globalThis.document, heading = true } = {}) {
54
105
  const cell = h(doc, 'div', 'tm-cell');
55
106
  cell.dataset.key = s.key;
56
- cell.append(h(doc, 'span', 'tm-label', 'Team metrics'));
107
+ if (heading) cell.append(h(doc, 'span', 'tm-label', 'Team metrics'));
57
108
  const line = h(doc, 'div', 'tm-line');
58
109
  const status = h(doc, 'span', 'tm-status');
59
110
  const { kind } = projectTmState(s);
111
+ cell.dataset.kind = kind; // the Getting started guide reads it (no-origin / off / on)
60
112
  const actions = h(doc, 'div', 'tm-actions');
61
113
  const runs = s.runs == null ? '' : ` · ${s.runs} run${s.runs === 1 ? '' : 's'}`;
62
114
  const excluded = s.record === false; // personal opt-out (the "Include my runs" switch)
@@ -74,7 +126,7 @@ export function renderProjectTmCell(s, { doc = globalThis.document } = {}) {
74
126
  case 'delegate-invalid':
75
127
  line.append(dot(doc, 'red'));
76
128
  // 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),
129
+ status.append(`Delegate invalid · points at `, ref(doc, s.delegateTo),
78
130
  s.delegateCode === 'DELEGATE_CHAIN' ? ', which itself delegates (no chains)'
79
131
  : s.delegateCode === 'DELEGATE_UNKNOWN' ? ', which is not a project in Worca on this machine'
80
132
  : ', which no longer records');
@@ -102,8 +154,8 @@ export function renderProjectTmCell(s, { doc = globalThis.document } = {}) {
102
154
  line.append(dot(doc, excluded ? 'grey' : 'green'));
103
155
  // Kept short: the status shares ~220px with the switch (see .tm-status), so the
104
156
  // 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);
157
+ if (excluded) status.append('On for the team · in ', ref(doc, s.delegateTo), ' · yours excluded');
158
+ else status.append('On · recorded in ', ref(doc, s.delegateTo), runs);
107
159
  actions.append(recordSwitch(doc, s));
108
160
  break;
109
161
  default:
@@ -135,7 +187,9 @@ export function renderProjectTmCell(s, { doc = globalThis.document } = {}) {
135
187
  if (s.enabled && Array.isArray(s.homeFor) && s.homeFor.length) {
136
188
  const names = s.homeFor.join(', ');
137
189
  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`);
190
+ hint.append('Metrics home for ');
191
+ s.homeFor.forEach((x, i) => { if (i) hint.append(', '); hint.append(ref(doc, x)); });
192
+ hint.append(' · "Include my runs" covers its workspace runs too');
139
193
  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
194
  cell.append(hint);
141
195
  }
@@ -230,7 +284,7 @@ export function renderMetricsHomePicker(members, { selectedPath, doc = globalThi
230
284
  status.append(dot(doc, 'green'), lines);
231
285
  row.append(label, status);
232
286
  } else if (m.enabled && m.delegateTo) {
233
- status.append(dot(doc, 'grey'), `Delegates to ${m.delegateTo} · cannot host metrics`);
287
+ status.append(dot(doc, 'grey'), 'Delegates to ', ref(doc, m.delegateTo), ' · cannot host metrics');
234
288
  row.classList.add('off');
235
289
  row.append(name, status);
236
290
  } else {
@@ -276,22 +330,29 @@ function homeMark(doc) {
276
330
  * Answers what people scan for — how big, where runs go, is anything wrong — on one line.
277
331
  */
278
332
  export function renderWsSummary(ws, { doc = globalThis.document, pending = false } = {}) {
279
- const frag = doc.createDocumentFragment();
333
+ let frag = doc.createDocumentFragment();
280
334
  const n = (ws.projectPaths || ws.members || []).length;
281
335
  frag.append(`${n} project${n === 1 ? '' : 's'}`);
336
+ // Everything after the count is team-metrics state: expert detail (docs/ui-levels.md), so it rides
337
+ // one tagged span the stylesheet can drop. `frag` is re-pointed so the appends below land in it.
338
+ const out = frag;
339
+ const tm = h(doc, 'span', 'ws-sum-tm');
340
+ tm.dataset.minLevel = 'expert';
341
+ out.append(tm);
342
+ frag = tm;
282
343
  // 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; }
344
+ if (pending) { frag.append(' · ', h(doc, 'span', 'ws-sum-pending', 'checking metrics…')); return out; }
284
345
  const home = ws.home || { state: 'unset' };
285
346
  if (home.state === 'unset') {
286
347
  frag.append(' · ', h(doc, 'span', 'muted', 'no metrics home'));
287
348
  } else {
288
- frag.append(' · ', homeMark(doc), h(doc, 'span', 'mono', home.slug));
349
+ frag.append(' · ', homeMark(doc), ref(doc, home.slug));
289
350
  if (home.state !== 'ok') frag.append(' · ', h(doc, 'span', 'ws-sum-bad', home.detail || 'stale'));
290
351
  else if (home.runs != null) frag.append(` · ${home.runs} workspace run${home.runs === 1 ? '' : 's'}`); // project-level runs live elsewhere
291
352
  }
292
353
  const silent = (ws.members || []).filter((x) => x.state !== 'home' && !x.recordsOn).length;
293
354
  if (silent) frag.append(' · ', h(doc, 'span', 'ws-sum-warn', `${silent} not recording`));
294
- return frag;
355
+ return out;
295
356
  }
296
357
 
297
358
  /** One table cell: what this member's metrics branch is, or that there is none. */
@@ -432,7 +493,7 @@ export function renderWsMetricsRow(ws, { doc = globalThis.document } = {}) {
432
493
  root.append(you);
433
494
  } else if (home.record === false) {
434
495
  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}.`);
496
+ you.append(dot(doc, 'amber'), ' Your workspace runs are not recorded: "Include my runs" is off on ', ref(doc, home.slug), '.');
436
497
  root.append(you);
437
498
  }
438
499
  root.append(h(doc, 'div', 'ws-route-results'));
@@ -2,7 +2,7 @@
2
2
  // Team metrics page renderers (team-metrics-design.md §4.10; mockup boards 1–3). Pure DOM:
3
3
  // every function takes an aggregate() result (src/shared/team-metrics/aggregate.mjs) and
4
4
  // returns detached elements. app.js owns fetch, mounting and delegated events.
5
- import { niceScale, roundedTopBar } from './stats-view.mjs';
5
+ import { niceScale, roundedTopBar, savedMultChip } from './stats-view.mjs';
6
6
  import { safeHttpUrl } from '../../src/shared/team-metrics/aggregate.mjs';
7
7
 
8
8
  const SVG_NS = 'http://www.w3.org/2000/svg';
@@ -25,6 +25,7 @@ export const TM_FMT = {
25
25
  if (s < 3600) return `${Math.floor(s / 60)}m ${String(s % 60).padStart(2, '0')}s`;
26
26
  return `${Math.floor(s / 3600)}h ${String(Math.floor((s % 3600) / 60)).padStart(2, '0')}m`;
27
27
  },
28
+ hours: (h) => `${(Math.round(((Number(h) || 0) + Number.EPSILON) * 10) / 10).toLocaleString('en-US', { maximumFractionDigits: 1 })} h`,
28
29
  pct: (v) => (v == null ? '—' : `${Math.round(v * 100)}%`),
29
30
  day: (iso) => { const d = new Date(iso); return `${MO[d.getUTCMonth()]} ${d.getUTCDate()}`; },
30
31
  };
@@ -136,7 +137,8 @@ function deltaChip(doc, text, title) {
136
137
  }
137
138
  const signedPct = (v) => (v == null ? null : `${v >= 0 ? '+' : '−'}${Math.abs(Math.round(v * 100))}%`);
138
139
 
139
- /** The six mockup tile glyphs, path data verbatim from boards 1–2 (`svg.stat-ico`). */
140
+ /** The six mockup tile glyphs, path data verbatim from boards 1–2 (`svg.stat-ico`); `saved` (a money bag
141
+ * resting on an open hand) is not a board glyph — it is shared with the Stats view. */
140
142
  const TILE_ICONS = Object.freeze({
141
143
  spend: ['M12 3v18M17 7.5c0-1.9-2.2-3-5-3s-5 1.1-5 3 2.2 2.6 5 3 5 1.1 5 3-2.2 3-5 3-5-1.1-5-3'],
142
144
  runs: ['M5 20v-7M12 20V4M19 20v-11'],
@@ -144,6 +146,7 @@ const TILE_ICONS = Object.freeze({
144
146
  duration: ['circle:12,12,8', 'M12 8v4l3 2'],
145
147
  autonomy: ['M4 12h4l2-5 4 10 2-5h4'],
146
148
  cycles: ['M3 12a9 9 0 1 0 3-6.7', 'M3 4v4h4'],
149
+ saved: ['M9.6 4.6h3.8', 'M9.9 4.6c.3-1.5 2.9-1.5 3.2 0', 'M9.6 4.6C7.8 6.2 6.8 7.8 6.8 9.4c0 1.6.6 2.9 1.4 3.8h6.6c.8-.9 1.4-2.2 1.4-3.8 0-1.6-1-3.2-2.8-4.8', 'M8.2 13.2c-.5 0-1 .2-1.3.6L3 17.6', 'M7 21l1.6-1.4c.3-.4.8-.6 1.4-.6h4c1.1 0 2.1-.4 2.8-1.2l4.6-4.4a2 2 0 0 0-2.75-2.91L14.8 13.2', 'M2 16.6l6 6'],
147
150
  });
148
151
  function tileIcon(doc, key) {
149
152
  const svg = s(doc, 'svg', { class: 'stat-ico', viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', 'stroke-width': 1.9, 'stroke-linecap': 'round', 'stroke-linejoin': 'round' });
@@ -190,6 +193,23 @@ export function renderTmKpiRow(agg, { doc = globalThis.document, now = Date.now(
190
193
  ? ['attributed to the workspace, not to single projects']
191
194
  : nowInRange ? [{ b: TM_FMT.usd(k.spendThisMonthUsd) }, ` so far in ${month}`] : [],
192
195
  }));
196
+
197
+ // Saved (money-saved design §10): Σ human hours × the effective rate − spend. Gated like the
198
+ // Off-policy tile: records pushed before this feature carry no `human`, and a range made only
199
+ // of those must not print "−$spend".
200
+ if (k.humanRuns > 0) {
201
+ const saved = Number(k.savedUsd || 0);
202
+ const savedTile = tile(doc, {
203
+ icon: 'saved', label: 'Saved', chip: deltaChip(doc, d && withTitle(signedPct(d.savedPct))),
204
+ value: `${saved < 0 ? '−' : ''}${TM_FMT.usd(Math.abs(saved))}`,
205
+ sub: ['≈ ', { b: TM_FMT.hours(k.humanHours) }, ' of human work'],
206
+ });
207
+ savedTile.querySelector('.stat-value').classList.toggle('is-neg', saved < 0);
208
+ savedTile.querySelector('.stat-value').classList.toggle('is-pos', saved > 0);
209
+ const mult = savedMultChip(doc, saved, k.spendUsd); // "32× spend", right of the delta pill
210
+ if (mult) savedTile.querySelector('.stat-label').append(mult);
211
+ row.append(savedTile);
212
+ }
193
213
  row.append(tile(doc, {
194
214
  icon: 'runs', label: 'Runs', chip: deltaChip(doc, d && signedPct(d.runsPct)), value: String(k.runs),
195
215
  sub: [{ b: String(k.done) }, ` done · ${k.failed} failed · ${k.stopped} stopped · `, { b: TM_FMT.pct(k.successRate) }, ' success'],
@@ -204,16 +224,46 @@ export function renderTmKpiRow(agg, { doc = globalThis.document, now = Date.now(
204
224
  row.append(tile(doc, {
205
225
  icon: 'autonomy', label: 'Autonomy', chip: deltaChip(doc, d && d.autonomyPts != null ? `${d.autonomyPts >= 0 ? '+' : '−'}${Math.abs(d.autonomyPts)} pts` : null),
206
226
  value: TM_FMT.pct(k.autonomy), meterPct: k.autonomy == null ? 0 : k.autonomy * 100,
207
- sub: ['active ÷ wall-clock · ', { b: k.interventionsPerRun == null ? '—' : String(k.interventionsPerRun) }, ' interventions per run'],
227
+ sub: ['active ÷ (wall-clock − paused) · ', { b: k.interventionsPerRun == null ? '—' : String(k.interventionsPerRun) }, ' interventions per run'],
208
228
  }));
209
229
  row.append(tile(doc, {
210
230
  icon: 'cycles', label: 'Review cycles', chip: deltaChip(doc, d && d.reviewCycles != null ? `${d.reviewCycles >= 0 ? '+' : '−'}${Math.abs(d.reviewCycles)}` : null),
211
231
  value: k.reviewCyclesMean == null ? '—' : String(k.reviewCyclesMean),
212
232
  sub: ['mean implement→review loops · ', { b: TM_FMT.pct(k.convergeInOneRate) }, ' converge in one'],
213
233
  }));
234
+ // Team policy (team-policy design §10, board 10): only once a record in range carries a policy.
235
+ if (k.policyRuns > 0) {
236
+ row.append(tile(doc, {
237
+ icon: 'cycles', label: 'Off-policy', value: String(k.offPolicyRuns),
238
+ sub: [{ b: String(k.capOverrides) }, ` cap override${k.capOverrides === 1 ? '' : 's'} · `, { b: String(k.deviations) }, ` deviation${k.deviations === 1 ? '' : 's'} · of ${k.policyRuns} run${k.policyRuns === 1 ? '' : 's'} under a policy`],
239
+ }));
240
+ }
214
241
  return row;
215
242
  }
216
243
 
244
+ /**
245
+ * The pooled-budget tile (team-policy design §7, board 10): advisory, never a pause. `spentUsd`
246
+ * is the sum of the records in the budget's current window; the pace line projects it to the
247
+ * window's end. Warn from 80 %, over from 100 %.
248
+ */
249
+ export function renderPooledBudgetTile({ budgetUsd, window: win = 'monthly', spentUsd = 0, windowStartMs, windowEndMs }, { doc = globalThis.document, now = Date.now() } = {}) {
250
+ const ratio = budgetUsd > 0 ? spentUsd / budgetUsd : 0;
251
+ const elapsed = windowEndMs > windowStartMs ? Math.max(0, Math.min(1, (now - windowStartMs) / (windowEndMs - windowStartMs))) : 1;
252
+ const pace = elapsed > 0.05 ? spentUsd / elapsed : null;
253
+ const end = new Date(windowEndMs);
254
+ const endLabel = `${MO[end.getMonth()]} ${end.getDate()}`;
255
+ const card = tile(doc, {
256
+ icon: 'spend', label: 'Pooled budget', value: TM_FMT.pct(ratio), meterPct: ratio * 100,
257
+ sub: [{ b: TM_FMT.usd(spentUsd) }, ' of ', { b: TM_FMT.usd(budgetUsd) }, ` this ${win === 'weekly' ? 'week' : 'month'}`,
258
+ ...(pace != null && ratio < 1 ? [' · on pace for ', { b: TM_FMT.usd(pace) }, ` by ${endLabel}`] : ratio >= 1 ? [' · over budget'] : [])],
259
+ });
260
+ card.classList.add('tm-pooled');
261
+ const fill = card.querySelector('.stat-meter-fill');
262
+ if (fill) fill.classList.toggle('warn', ratio >= 0.8 && ratio < 1);
263
+ if (fill) fill.classList.toggle('over', ratio >= 1);
264
+ return card;
265
+ }
266
+
217
267
  const CW = 560, CH = 240, L = 44, R = 12, T = 18, B = 26;
218
268
  const PW = CW - L - R, PH = CH - T - B;
219
269
 
@@ -293,7 +343,8 @@ export function renderStackedWeekChart({ title, weeks, stacks, valueOf, yFmt, ti
293
343
  const BREAKDOWN_SPECS = {
294
344
  workflow: { title: 'By workflow', sub: 'share of spend', cols: [['label', 'Workflow'], ['runs', 'Runs'], ['usd', 'Spend'], ['perRunUsd', 'Per run'], ['successRate', 'Success'], ['share', '']] },
295
345
  source: { title: 'By ticket', sub: 'task source · top 5', limit: 5, cols: [['label', 'Source'], ['runs', 'Runs'], ['usd', 'Spend'], ['perRunUsd', 'Per run'], ['cyclesMean', 'Cycles']] },
296
- actor: { title: 'By actor', sub: 'who ran it', cols: [['label', 'Actor'], ['runs', 'Runs'], ['usd', 'Spend'], ['perRunUsd', 'Per run'], ['successRate', 'Success']] },
346
+ // `overrides`: team caps continued past or exceeded (team policy §10, board 10); "–" without a policy.
347
+ actor: { title: 'By actor', sub: 'who ran it', cols: [['label', 'Actor'], ['runs', 'Runs'], ['usd', 'Spend'], ['perRunUsd', 'Per run'], ['successRate', 'Success'], ['overrides', 'Overrides']] },
297
348
  // No spend column: a run's cost is not a per-project fact. Runs touched and files changed are;
298
349
  // the bar is the share of runs in range that touched the project. Cost questions go through
299
350
  // the row filter, which narrows every panel to the runs that touched it.
@@ -306,6 +357,7 @@ function cellText(key, row) {
306
357
  case 'usd': case 'perRunUsd': return row[key] == null ? '—' : TM_FMT.usd(row[key]);
307
358
  case 'successRate': return TM_FMT.pct(row[key]);
308
359
  case 'cyclesMean': case 'filesChanged': return row[key] == null ? '—' : String(row[key]);
360
+ case 'overrides': return row[key] ? String(row[key]) : '–';
309
361
  default: return String(row[key] ?? '—');
310
362
  }
311
363
  }
@@ -379,7 +431,7 @@ export function renderRunsTable(runs, { doc = globalThis.document, total = runs.
379
431
  if (!runs.length) { card.append(h(doc, 'div', 'chart-empty hint', 'No runs recorded in this range')); return card; }
380
432
  const table = h(doc, 'table', 'tm-tbl tm-run-tbl');
381
433
  const thead = h(doc, 'thead'); const thr = h(doc, 'tr');
382
- for (const c of ['Title', 'Workflow', 'Result', 'Cost', 'Duration', 'Cycles', 'PR', 'Actor', 'Started']) thr.append(h(doc, 'th', null, c));
434
+ for (const c of ['Title', 'Workflow', 'Result', 'Cost', 'Saved', 'Duration', 'Cycles', 'PR', 'Actor', 'Started']) thr.append(h(doc, 'th', null, c));
383
435
  thead.append(thr); table.append(thead);
384
436
  const tbody = h(doc, 'tbody');
385
437
  for (const r of runs.slice(0, limit)) {
@@ -388,6 +440,17 @@ export function renderRunsTable(runs, { doc = globalThis.document, total = runs.
388
440
  tr.append(h(doc, 'td', null, r.workflow ?? '—'));
389
441
  const res = h(doc, 'td'); res.append(h(doc, 'span', `badge ${r.result === 'done' ? 'green' : r.result === 'failed' ? 'red' : 'grey'}`, r.result)); tr.append(res);
390
442
  tr.append(h(doc, 'td', 'mono num', TM_FMT.usd(r.usd)));
443
+ // Saved per run (money-saved design §10): hours × rate − cost, coloured like the KPI tile;
444
+ // "—" for a record pushed without `human`.
445
+ const saved = h(doc, 'td', 'mono num tm-saved');
446
+ if (r.savedUsd == null) saved.textContent = '—';
447
+ else {
448
+ saved.textContent = `${r.savedUsd < 0 ? '−' : ''}${TM_FMT.usd(Math.abs(r.savedUsd))}`;
449
+ saved.classList.toggle('neg', r.savedUsd < 0);
450
+ saved.classList.toggle('pos', r.savedUsd > 0);
451
+ saved.title = `≈ ${TM_FMT.hours(r.humanHours)} of human work`;
452
+ }
453
+ tr.append(saved);
391
454
  tr.append(h(doc, 'td', 'mono num', TM_FMT.duration(r.wallMs)));
392
455
  tr.append(h(doc, 'td', 'mono num', r.reviewCycles == null ? '—' : String(r.reviewCycles)));
393
456
  const pr = h(doc, 'td', 'mono');