@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
@@ -0,0 +1,1402 @@
1
+ // ui/public/team-policy-view.mjs
2
+ // Pure DOM renderers for the team-policy surfaces (team-policy design §11, boards 1–11):
3
+ // the Projects cell, the enable dialog body, the effective-policy table, the editor, the
4
+ // workspace card line, the Settings readout, the New pipeline notes line, the team-cap
5
+ // pause banner, the Plugins strip and the setup checklist. Like team-metrics-surfaces.mjs:
6
+ // no fetch, no network — app.js owns data and delegates events. The few listeners here are
7
+ // purely local (a show-all toggle, chip add/remove inside the editor).
8
+
9
+ const MO = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
10
+ export const POLICY_PAUSE_REASONS = Object.freeze(['cost_pipeline_policy', 'cost_total_policy']);
11
+ export const PROTECT_HINT = 'Protect worca-policy on your git host so only maintainers can push. Worca reads it for everyone and writes it only from the Team policy page.';
12
+
13
+ function h(doc, tag, cls, text) {
14
+ const n = doc.createElement(tag);
15
+ if (cls) n.className = cls;
16
+ if (text != null) n.textContent = text;
17
+ return n;
18
+ }
19
+ const dot = (doc, tone) => { const d = h(doc, 'span', `dot tm-dot ${tone}`); d.setAttribute('aria-hidden', 'true'); return d; };
20
+ const code = (doc, text) => h(doc, 'code', 'mono', text);
21
+ /** A project, workspace or home NAME inside prose: bold and mono, so it reads as a name, not a word. */
22
+ const ref = (doc, name) => h(doc, 'b', 'ref mono', name);
23
+ /** `text` with every occurrence of `name` rendered as a ref; plain text when the name is absent. */
24
+ function withRef(doc, text, name) {
25
+ const frag = doc.createDocumentFragment();
26
+ if (!name || !String(text).includes(name)) { frag.append(String(text)); return frag; }
27
+ const parts = String(text).split(name);
28
+ parts.forEach((part, i) => { if (i) frag.append(ref(doc, name)); if (part) frag.append(part); });
29
+ return frag;
30
+ }
31
+ function btn(doc, cls, text, primary = false) { const b = h(doc, 'button', `${primary ? 'btn btn-primary btn-mini' : 'btn-ghost btn-mini'} ${cls}`, text); b.type = 'button'; return b; }
32
+ const usd = (n) => `$${(Number(n) || 0).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
33
+ export function relTime(iso, now = Date.now()) {
34
+ const ms = now - Date.parse(iso);
35
+ if (!Number.isFinite(ms)) return null;
36
+ const m = Math.floor(ms / 60_000);
37
+ if (m < 1) return 'just now';
38
+ if (m < 60) return `${m} min ago`;
39
+ const hrs = Math.round(m / 60);
40
+ if (hrs < 48) return `${hrs} h ago`;
41
+ const d = Math.round(hrs / 24);
42
+ if (d < 30) return `${d} d ago`;
43
+ const dt = new Date(iso);
44
+ return `${MO[dt.getUTCMonth()]} ${dt.getUTCDate()}`;
45
+ }
46
+ const capText = (caps) => {
47
+ if (!caps) return '';
48
+ const parts = [];
49
+ if (caps.pipeline) parts.push(`pipeline cap ${usd(caps.pipeline.value)} (${caps.pipeline.kind})`);
50
+ if (caps.total) parts.push(`total ${usd(caps.total.value)}${caps.resetPeriod ? `/${caps.resetPeriod === 'weekly' ? 'week' : 'month'}` : ''} (${caps.total.kind})`);
51
+ return parts.join(' · ');
52
+ };
53
+
54
+ // ---- Projects cell (board 2) --------------------------------------------------------------
55
+ /** Classify a project status (from /api/policy/scopes → projects[]) into a cell variant. */
56
+ export function projectTpState(s) {
57
+ if (s.hasOrigin === false) return { kind: 'no-origin' };
58
+ if (!s.present) return { kind: 'off' };
59
+ if (s.unknownSchema) return { kind: 'unsupported' };
60
+ if (s.delegateTo && s.delegateState === 'invalid') return { kind: 'delegate-invalid' };
61
+ if (s.blocked) return { kind: 'blocked' };
62
+ if (s.delegateTo) return { kind: 'follows' };
63
+ return { kind: 'home' };
64
+ }
65
+
66
+ const cap = (t) => (t ? t[0].toUpperCase() + t.slice(1) : t);
67
+ /**
68
+ * One project's team-policy state in a few words — the metrics module's projectTmSummary shape:
69
+ * `short` for a row chip and the project page's stat card ("home", "follows acme/gateway", "off"),
70
+ * `detail` the sentence behind it, `tone` the dot colour.
71
+ */
72
+ export function projectTpSummary(s, { now = Date.now() } = {}) {
73
+ const { kind } = projectTpState(s);
74
+ const fields = s.fieldCount == null ? '' : `${s.fieldCount} field${s.fieldCount === 1 ? '' : 's'}`;
75
+ const caps = (kind === 'home' || kind === 'follows') ? capText(s.caps) : '';
76
+ switch (kind) {
77
+ case 'no-origin': return { kind, tone: 'muted', short: 'not available', detail: s.noGit ? 'not a git repository' : 'no origin remote' };
78
+ case 'off': return { kind, tone: 'grey', short: 'off', detail: 'your settings apply' };
79
+ case 'unsupported': return { kind, tone: 'red', short: 'needs a newer Worca', detail: s.warnings?.[0] || 'the policy uses a newer schema' };
80
+ case 'delegate-invalid': return { kind, tone: 'red', short: 'follow invalid', detail: `follows ${s.delegateTo} · your settings apply` };
81
+ case 'blocked': return { kind, tone: 'amber', short: s.delegateCode === 'DOC_UNKNOWN' || s.blocked === 'DOC_UNKNOWN' ? 'on · not read yet' : 'on · unresolved', detail: `${s.delegateDetail || 'the policy could not be resolved'} · your settings apply` };
82
+ case 'follows': return { kind, tone: 'green', short: `follows ${s.home || s.delegateTo}`, ref: s.home || s.delegateTo, detail: [fields, caps].filter(Boolean).join(' · ') };
83
+ default: return { kind, tone: 'green', short: 'home', detail: [fields, s.updatedAt ? `updated ${relTime(s.updatedAt, now)}` : '', caps].filter(Boolean).join(' · ') };
84
+ }
85
+ }
86
+
87
+ /** The Projects-row chip, the metrics chip's twin: a dot, the word and the short state, nothing else. */
88
+ export function renderProjectTpChip(s, { doc = globalThis.document, now = Date.now() } = {}) {
89
+ const chip = h(doc, 'span', 'pl-team-item pl-tp');
90
+ chip.dataset.key = s.key;
91
+ const sum = projectTpSummary(s, { now });
92
+ chip.dataset.kind = sum.kind;
93
+ if (sum.tone !== 'muted') chip.append(dot(doc, sum.tone));
94
+ const state = h(doc, 'span', `pl-team-state${sum.tone === 'muted' ? ' muted' : ''}`);
95
+ state.append(withRef(doc, sum.short, sum.ref));
96
+ chip.append(h(doc, 'span', 'pl-team-name', 'Policy'), ' ', state);
97
+ chip.title = `Team policy: ${cap(sum.short)}${sum.detail ? ` · ${sum.detail}` : ''}`;
98
+ return chip;
99
+ }
100
+
101
+ /** The project page's team-policy block: the same two-row grid as the team-metrics cell. */
102
+ export function renderProjectTpCell(s, { doc = globalThis.document, now = Date.now(), heading = true } = {}) {
103
+ const cell = h(doc, 'div', 'tm-cell tp-cell');
104
+ cell.dataset.key = s.key;
105
+ if (heading) cell.append(h(doc, 'span', 'tm-label', 'Team policy'));
106
+ const line = h(doc, 'div', 'tm-line');
107
+ const status = h(doc, 'span', 'tm-status');
108
+ const actions = h(doc, 'div', 'tm-actions');
109
+ const { kind } = projectTpState(s);
110
+ cell.dataset.kind = kind; // the Getting started guide reads it (no-origin / off / on)
111
+ const fields = s.fieldCount == null ? '' : ` · ${s.fieldCount} field${s.fieldCount === 1 ? '' : 's'}`;
112
+ let hint = null;
113
+ switch (kind) {
114
+ case 'no-origin':
115
+ status.classList.add('muted');
116
+ status.textContent = s.noGit ? 'Not available · not a git repository' : 'Not available · no origin remote';
117
+ break;
118
+ case 'off':
119
+ status.classList.add('muted'); status.textContent = 'Off · your settings apply';
120
+ actions.append(btn(doc, 'tp-enable', 'Set up team policy…'));
121
+ break;
122
+ case 'unsupported':
123
+ line.append(dot(doc, 'red'));
124
+ status.textContent = 'Needs a newer Worca · your settings apply';
125
+ hint = h(doc, 'small', 'hint tm-hint', s.warnings?.[0] || 'the policy uses a newer schema');
126
+ break;
127
+ case 'delegate-invalid':
128
+ line.append(dot(doc, 'red'));
129
+ status.append('Follow invalid · follows ', ref(doc, s.delegateTo),
130
+ s.delegateCode === 'DELEGATE_CHAIN' ? ', which itself follows another (no chains)'
131
+ : s.delegateCode === 'DELEGATE_UNKNOWN' ? ', which is not a project in Worca on this machine'
132
+ : ', which no longer carries a policy');
133
+ actions.append(btn(doc, 'tp-change', 'Change…'));
134
+ hint = h(doc, 'small', 'hint mono tm-hint', 'your settings apply · pick a new home');
135
+ break;
136
+ case 'blocked':
137
+ line.append(dot(doc, 'amber'));
138
+ status.textContent = s.delegateCode === 'DOC_UNKNOWN' || s.blocked === 'DOC_UNKNOWN'
139
+ ? 'On · branch not read yet · your settings apply'
140
+ : `On · ${s.delegateDetail || 'the policy could not be resolved'} · your settings apply`;
141
+ break;
142
+ case 'follows':
143
+ line.append(dot(doc, 'green'));
144
+ status.append('On · follows ', ref(doc, s.home || s.delegateTo), fields);
145
+ actions.append(btn(doc, 'tp-open', 'Open'), btn(doc, 'tp-change', 'Change…'));
146
+ break;
147
+ default:
148
+ line.append(dot(doc, 'green'));
149
+ status.textContent = `On · policy home${fields}${s.updatedAt ? ` · updated ${relTime(s.updatedAt, now)}` : ''}`;
150
+ actions.append(btn(doc, 'tp-open', 'Open'));
151
+ }
152
+ if ((kind === 'home' || kind === 'follows') && s.title) status.title = s.title;
153
+ line.append(status);
154
+ cell.append(line);
155
+ if (hint) cell.append(hint);
156
+ if ((kind === 'home' || kind === 'follows')) {
157
+ const capLine = capText(s.caps);
158
+ if (capLine) cell.append(h(doc, 'small', 'hint tm-hint', capLine));
159
+ if (Array.isArray(s.warnings) && s.warnings.length) {
160
+ const w = h(doc, 'small', 'hint tm-hint tp-warn', s.warnings[0]);
161
+ w.title = s.warnings.join('\n');
162
+ cell.append(w);
163
+ }
164
+ }
165
+ if (actions.childNodes.length) cell.append(actions);
166
+ return cell;
167
+ }
168
+
169
+ function radioCard(doc, { name, value, checked, title, body, disabled = false }) {
170
+ const card = h(doc, 'label', `radio-card${checked ? ' on' : ''}${disabled ? ' disabled' : ''}`);
171
+ const input = h(doc, 'input');
172
+ input.type = 'radio'; input.name = name; input.value = value; input.checked = !!checked; input.disabled = disabled;
173
+ const text = h(doc, 'span', 'radio-card-text');
174
+ text.append(h(doc, 'b', null, title));
175
+ const p = h(doc, 'small', 'hint');
176
+ for (const part of body) p.append(typeof part === 'string' ? part : code(doc, part.code));
177
+ text.append(p);
178
+ card.append(input, text);
179
+ return card;
180
+ }
181
+
182
+ /** Enable dialog body (board 3). mode: 'here' | 'follow'. */
183
+ export function renderPolicyEnableDialogBody({ project, origin, candidates = [], mode = 'here', change = false }, { doc = globalThis.document } = {}) {
184
+ const root = h(doc, 'div', 'tp-enable-body');
185
+ const sub = h(doc, 'div', 'sub');
186
+ sub.append('for ', h(doc, 'b', null, project.name), ' · origin ', code(doc, origin || '—'));
187
+ root.append(sub);
188
+ const where = h(doc, 'div', 'field');
189
+ where.append(h(doc, 'label', null, 'Where the policy lives'));
190
+ where.append(radioCard(doc, { name: 'tp-where', value: 'here', checked: mode === 'here' && !change, disabled: change, title: 'Here, on this repository',
191
+ body: ['Creates an orphan branch ', { code: 'worca-policy' }, ' on origin holding only ', { code: '.worca-policy/' }, '. Starts empty; you edit it on the Team policy page. Everyone who runs Worca on this repository reads it.'] }));
192
+ const follow = radioCard(doc, { name: 'tp-where', value: 'follow', checked: mode === 'follow', disabled: candidates.length === 0,
193
+ title: "Follow another project's policy",
194
+ body: ["A tiny marker branch on this origin points teammates at the target. This project's runs use the target's policy."] });
195
+ where.append(follow);
196
+ if (mode === 'follow') {
197
+ const sel = h(doc, 'select', 'select tp-follow-target');
198
+ sel.setAttribute('aria-label', 'Policy home');
199
+ const want = project.metricsFollow ? String(project.metricsFollow).toLowerCase() : null;
200
+ for (const c of candidates) {
201
+ const o = h(doc, 'option', null, c.label);
202
+ o.value = c.slug;
203
+ if (want && c.slug.toLowerCase() === want) o.selected = true;
204
+ sel.append(o);
205
+ }
206
+ const wrap = h(doc, 'div', 'select-wrap tm-delegate-wrap');
207
+ wrap.append(sel);
208
+ follow.append(wrap);
209
+ follow.append(h(doc, 'small', 'hint', want && candidates.some((c) => c.slug.toLowerCase() === want)
210
+ ? 'Only projects that carry a policy are listed. Preselected to match where this project\'s metrics go.'
211
+ : 'Only projects that carry a policy are listed.'));
212
+ }
213
+ if (change) where.append(h(doc, 'small', 'hint', 'This project already has a marker branch — pick a new home.'));
214
+ if (!candidates.length) where.append(h(doc, 'small', 'hint', 'No project carries a policy yet — set one up here first, then point other projects at it.'));
215
+ root.append(where);
216
+ const warn = h(doc, 'div', 'hint tm-warn');
217
+ warn.append('Protect ', code(doc, 'worca-policy'), ' on your git host so only maintainers can push. Worca reads it for everyone and writes it only from the Team policy page. This is the opposite of ', code(doc, 'worca-metrics'), ', which every teammate must be able to push to.');
218
+ root.append(warn);
219
+ return root;
220
+ }
221
+
222
+ // ---- Team policy page: read mode (board 4) ----------------------------------------------
223
+ const GROUP_LABELS = { cost: 'Cost', ask: 'Ask Worca', guardrails: 'Guardrails', models: 'Models', plugins: 'Plugins', runs: 'Runs' };
224
+ const kindChip = (doc, kind, declaredKind) => {
225
+ const c = h(doc, 'span', `tp-kind ${declaredKind === 'hard' ? 'hard' : kind}`, declaredKind === 'hard' ? 'hard' : kind);
226
+ if (declaredKind === 'hard') c.title = 'Hard constraints are not enforced by this version — treated as soft';
227
+ return c;
228
+ };
229
+
230
+ /** The effective-policy table: what the next run on this scope will use. */
231
+ export function renderEffectiveTable(payload, { doc = globalThis.document, showAll = false } = {}) {
232
+ const rows = payload.rows || [];
233
+ const card = h(doc, 'section', 'card tp-effective');
234
+ const head = h(doc, 'div', 'card-head');
235
+ head.append(h(doc, 'h2', null, 'Effective policy'), h(doc, 'small', 'hint', payload.policy?.workspaceRun ? 'Effective = what the next workspace run will use' : 'Effective = what your next run on this scope will use'));
236
+ card.append(head);
237
+ const table = h(doc, 'table', 'tm-tbl tp-tbl');
238
+ const thead = h(doc, 'thead'); const hr = h(doc, 'tr');
239
+ for (const t of ['Field', 'Team', 'Kind', 'Yours', 'Effective']) hr.append(h(doc, 'th', null, t));
240
+ thead.append(hr); table.append(thead);
241
+ const tbody = h(doc, 'tbody');
242
+ const groups = new Map();
243
+ for (const r of rows) { if (!groups.has(r.group)) groups.set(r.group, []); groups.get(r.group).push(r); }
244
+ let hiddenCount = 0;
245
+ for (const [group, list] of groups) {
246
+ const visible = list.filter((r) => showAll || r.shown);
247
+ if (!visible.length) { hiddenCount += list.length; continue; }
248
+ const gtr = h(doc, 'tr', 'tp-group'); const gtd = h(doc, 'td', null, GROUP_LABELS[group] || group); gtd.colSpan = 5; gtr.append(gtd); tbody.append(gtr);
249
+ for (const r of list) {
250
+ if (!showAll && !r.shown) { hiddenCount += 1; continue; }
251
+ const tr = h(doc, 'tr'); tr.dataset.key = r.key;
252
+ const key = h(doc, 'td', 'tp-key'); key.append(r.label);
253
+ if (r.help) key.append(h(doc, 'small', null, r.help));
254
+ const team = h(doc, 'td', 'tp-team');
255
+ if (r.team) {
256
+ team.append(h(doc, 'span', 'mono', r.team.display));
257
+ if (r.team.fromWorkspaceRuns) team.append(' ', h(doc, 'span', 'badge grey', 'workspace runs'));
258
+ const attrs = [];
259
+ if (r.team.kind === 'soft' && r.team.onBreach) attrs.push(`on breach: ${r.team.onBreach}`);
260
+ if (r.team.requireReason) attrs.push('reason required');
261
+ if (r.team.window) attrs.push(r.team.window);
262
+ if (attrs.length) team.append(h(doc, 'small', 'hint', attrs.join(' · ')));
263
+ } else team.append(h(doc, 'span', 'muted', '—'));
264
+ const kind = h(doc, 'td');
265
+ if (r.team) kind.append(kindChip(doc, r.team.kind, r.team.declaredKind));
266
+ const loose = !!(r.note && /looser/.test(r.note));
267
+ const yours = h(doc, 'td', `tp-eff${loose ? ' loose' : ''}`, r.local && r.local.set ? r.local.display : '—');
268
+ if (!(r.local && r.local.set)) yours.classList.add('muted');
269
+ const eff = h(doc, 'td', `tp-eff${r.effective.source === 'team' ? ' tight' : ''}`);
270
+ eff.append(r.effective.display);
271
+ const src = { team: 'team', 'team-default': 'team default', local: 'yours', advisory: 'advisory', default: 'default', none: '' }[r.effective.source] || r.effective.source;
272
+ if (src) eff.append(' ', h(doc, 'span', 'tp-src', r.note ? `${src}, ${r.note}` : src));
273
+ tr.append(key, team, kind, yours, eff);
274
+ tbody.append(tr);
275
+ }
276
+ }
277
+ if (!rows.some((r) => r.shown) && !showAll) {
278
+ const tr = h(doc, 'tr'); const td = h(doc, 'td'); td.colSpan = 5;
279
+ td.append(h(doc, 'small', 'hint', 'The policy sets no fields yet — your local settings apply.')); tr.append(td); tbody.append(tr);
280
+ }
281
+ table.append(tbody);
282
+ card.append(table);
283
+ const foot = h(doc, 'small', 'hint tp-foot');
284
+ foot.append('Struck-through = your value is looser than the team\'s and does not apply. ');
285
+ const toggle = h(doc, 'button', 'linkish tp-show-all', showAll ? 'show only the fields the team sets' : `show all ${rows.length} fields`);
286
+ toggle.type = 'button';
287
+ toggle.addEventListener('click', () => { card.replaceWith(renderEffectiveTable(payload, { doc, showAll: !showAll })); });
288
+ foot.append(toggle);
289
+ card.append(foot);
290
+ return card;
291
+ }
292
+
293
+ /** Topbar chip: synced <rel> · <sha7> + Check now. */
294
+ export function renderPolicySyncChip({ policy }, { doc = globalThis.document, now = Date.now(), busy = false } = {}) {
295
+ const chip = h(doc, 'div', `sync-chip-inner tp-chip${busy ? ' is-busy' : ''}`);
296
+ const warnings = policy?.warnings || [];
297
+ if (busy) { const sp = h(doc, 'span', 'tm-busy-spin'); sp.setAttribute('aria-hidden', 'true'); chip.append(sp); chip.setAttribute('aria-busy', 'true'); }
298
+ else chip.append(h(doc, 'span', `dot ${warnings.length ? 'amber' : 'green'}`));
299
+ const txt = h(doc, 'span', 'sync-text');
300
+ // The team-metrics chip's words, and only its words: the commit id belongs with "updated by"
301
+ // on the policy panel, not with how fresh this machine's copy is.
302
+ if (busy) txt.append(h(doc, 'span', 'tm-checking', 'Checking origin…'), policy?.checkedAt ? ' · ' : '');
303
+ else txt.append(policy?.checkedAt ? 'Synced ' : 'Not checked yet');
304
+ if (policy?.checkedAt) txt.append(h(doc, 'b', null, relTime(policy.checkedAt, now) || ''));
305
+ if (warnings.length) txt.append(` | ${warnings.length} warning${warnings.length === 1 ? '' : 's'}`);
306
+ chip.append(txt);
307
+ const check = h(doc, 'button', `btn-ghost btn-mini tp-check-now${busy ? ' busy' : ''}`, 'Refresh'); check.type = 'button';
308
+ if (busy) check.disabled = true;
309
+ chip.append(check);
310
+ if (warnings.length) { const w = h(doc, 'pre', 'hint mono tm-sync-error', warnings.join('\n')); chip.append(w); }
311
+ return chip;
312
+ }
313
+
314
+ // ---- Team policy page: the shared document (static) vs this machine (dynamic) ----------------
315
+ /**
316
+ * The header panel (board 4): what the TEAM published — title, where it comes from, the version
317
+ * and who last changed it, the notes — with Edit policy as its own action. Everything here is the
318
+ * same for every teammate; the stat cards below are what THIS machine makes of it.
319
+ */
320
+ export function renderPolicyHeader(payload, { doc = globalThis.document, now = Date.now(), editing = false } = {}) {
321
+ const policy = payload.policy || {};
322
+ const scope = payload.scope || {};
323
+ const card = h(doc, 'section', 'card tp-head');
324
+ const main = h(doc, 'div', 'tp-head-main');
325
+ main.append(h(doc, 'div', 'tp-head-kicker', 'SHARED WITH THE TEAM'));
326
+ main.append(h(doc, 'h2', 'tp-head-title', policy.doc?.title || `${policy.home || 'Team'} policy`));
327
+ const facts = h(doc, 'dl', 'tp-facts');
328
+ const fact = (label, ...value) => { facts.append(h(doc, 'dt', null, label)); const dd = h(doc, 'dd'); dd.append(...value); facts.append(dd); };
329
+ if (scope.kind === 'workspace') {
330
+ const v = [h(doc, 'span', null, 'Policy home '), ref(doc, policy.home || '—')];
331
+ if (policy.delegated && policy.from) v.push(h(doc, 'span', null, ' · the home follows it through '), ref(doc, policy.from));
332
+ fact('SOURCE', ...v);
333
+ fact('APPLIES TO', 'Workspace runs of ', ref(doc, scope.name || 'this workspace'), ' — the policy\'s workspace-run values sit on top');
334
+ } else if (policy.delegated) {
335
+ fact('SOURCE', h(doc, 'span', null, 'Follows '), ref(doc, policy.home || '—'), h(doc, 'span', null, ' — the document lives there'));
336
+ fact('APPLIES TO', 'Runs on ', ref(doc, scope.name || 'this project'));
337
+ } else {
338
+ fact('SOURCE', h(doc, 'span', null, 'This project\'s own '), code(doc, 'worca-policy'), h(doc, 'span', null, ' branch'));
339
+ fact('APPLIES TO', 'Runs on ', ref(doc, scope.name || 'this project'), ', and on every project that follows it');
340
+ }
341
+ const ver = [];
342
+ if (policy.sha) { const c = code(doc, String(policy.sha).slice(0, 7)); c.title = `Policy version: commit ${String(policy.sha).slice(0, 7)} on ${policy.home}'s worca-policy branch`; ver.push(c); }
343
+ if (policy.doc?.updatedAt) ver.push(h(doc, 'span', null, `${ver.length ? ' · ' : ''}updated ${relTime(policy.doc.updatedAt, now) || ''}${policy.doc.updatedBy ? ` by ${policy.doc.updatedBy}` : ''}`));
344
+ if (ver.length) fact('VERSION', ...ver);
345
+ if (policy.doc?.notes) fact('NOTES', h(doc, 'span', 'tp-head-notes', policy.doc.notes));
346
+ main.append(facts);
347
+ card.append(main);
348
+ const actions = h(doc, 'div', 'tp-head-actions');
349
+ const edit = h(doc, 'button', 'btn btn-ghost btn-mini tp-edit', editing ? 'Cancel editing' : 'Edit policy'); edit.type = 'button';
350
+ edit.disabled = !payload.canPublish;
351
+ actions.append(edit);
352
+ if (!payload.canPublish) { const hint = h(doc, 'small', 'hint'); hint.append('Edit it where ', ref(doc, policy.home || 'the policy home'), ' is registered in Worca'); actions.append(hint); }
353
+ card.append(actions);
354
+ return card;
355
+ }
356
+
357
+ const capSub = (row) => {
358
+ if (!row) return '';
359
+ const src = { team: 'the team cap', 'team-default': 'the team\'s starting value', local: 'your own limit', advisory: 'advisory', default: 'the Worca default', none: '' }[row.effective?.source] || '';
360
+ return [src, row.note].filter(Boolean).join(' · ');
361
+ };
362
+
363
+ /**
364
+ * The stat cards (board 4): what the policy means for THIS machine right now — the caps a run
365
+ * will hit, the plugins it expects, and where your setup differs. Dynamic by nature, so they sit
366
+ * apart from the published document above.
367
+ */
368
+ export function renderPolicyStats(payload, { doc = globalThis.document } = {}) {
369
+ const rows = payload.rows || [];
370
+ const byKey = (k) => rows.find((r) => r.key === k) || null;
371
+ const grid = h(doc, 'div', 'tp-ov-grid');
372
+ const statCard = (label, value, sub, tone = null) => {
373
+ const c = h(doc, 'div', 'tp-ov-card');
374
+ c.append(h(doc, 'div', 'tp-ov-label', label), h(doc, 'div', 'tp-ov-value mono', value));
375
+ if (sub) c.append(h(doc, 'div', `tp-ov-sub${tone ? ` is-${tone}` : ''}`, sub));
376
+ grid.append(c);
377
+ return c;
378
+ };
379
+ const pipeline = byKey('cost.pipelineLimitUsd');
380
+ statCard('PER-PIPELINE CAP', pipeline?.effective?.display && pipeline.effective.source !== 'none' ? pipeline.effective.display : 'none',
381
+ pipeline && pipeline.effective.source !== 'none' ? capSub(pipeline) : 'neither you nor the team set one');
382
+ const total = byKey('cost.totalLimitUsd');
383
+ const period = byKey('cost.resetPeriod');
384
+ statCard('TOTAL CAP', total?.effective?.display && total.effective.source !== 'none' ? total.effective.display : 'none',
385
+ total && total.effective.source !== 'none' ? [capSub(total), period?.effective?.display ? `per ${period.effective.display === 'weekly' ? 'week' : 'month'}` : ''].filter(Boolean).join(' · ') : 'neither you nor the team set one');
386
+ const reqs = payload.requirements || [];
387
+ const off = reqs.filter((r) => r.state !== 'ok');
388
+ // Counted by state ("1 missing · 1 below the floor"), never one word per plugin.
389
+ const byState = ['missing', 'outdated', 'disabled']
390
+ .map((st) => [off.filter((r) => r.state === st).length, st === 'outdated' ? 'below the floor' : st])
391
+ .filter(([n]) => n > 0).map(([n, label]) => `${n} ${label}`);
392
+ statCard('REQUIRED PLUGINS', reqs.length ? `${reqs.length - off.length}/${reqs.length}` : 'none',
393
+ reqs.length ? (off.length ? `${byState.join(' · ')} — see the Plugins tab` : 'all installed') : 'the policy expects none',
394
+ off.length ? 'warn' : null);
395
+ const dev = payload.deviations || [];
396
+ statCard('OFF-POLICY HERE', dev.length ? String(dev.length) : 'none',
397
+ dev.length ? dev[0].text : 'your setup matches what the team expects', dev.length ? 'warn' : null);
398
+ return grid;
399
+ }
400
+
401
+ const PLUGIN_STATE = { ok: ['green', 'installed'], missing: ['amber', 'not installed'], outdated: ['amber', 'below the floor'], disabled: ['amber', 'disabled here'] };
402
+
403
+ /** The Plugins tab (board 11): what the policy expects, against what this machine has. */
404
+ export function renderPolicyPluginsPanel(payload, { doc = globalThis.document } = {}) {
405
+ const reqs = payload.requirements || [];
406
+ const blocked = payload.blockedPlugins || [];
407
+ const root = h(doc, 'div', 'tp-sec-body');
408
+ const card = h(doc, 'section', 'card tp-plugins');
409
+ const head = h(doc, 'div', 'card-head');
410
+ head.append(h(doc, 'h2', null, 'Required plugins'));
411
+ const actions = h(doc, 'div', 'tp-plugins-actions');
412
+ actions.append(requiredActions(doc, reqs));
413
+ head.append(actions);
414
+ card.append(head);
415
+ card.append(h(doc, 'small', 'hint tp-plugins-hint', 'Installing shows the plugin\'s source, commit and what it ships, and waits for your click. Nothing installs on its own unless you trust the policy home on the Plugins page.'));
416
+ card.append(h(doc, 'p', 'form-msg tp-plugins-msg'));
417
+ if (!reqs.length) card.append(h(doc, 'div', 'hist-empty', 'This policy expects no plugins.'));
418
+ else {
419
+ const table = h(doc, 'table', 'tm-tbl tp-tbl tp-plugins-tbl');
420
+ const thead = h(doc, 'thead'); const hr = h(doc, 'tr');
421
+ for (const t of ['Plugin', 'Expected', 'Installed here', 'State', '']) hr.append(h(doc, 'th', null, t));
422
+ thead.append(hr); table.append(thead);
423
+ const tbody = h(doc, 'tbody');
424
+ for (const r of reqs) {
425
+ const tr = h(doc, 'tr'); tr.dataset.name = r.name;
426
+ const name = h(doc, 'td', 'tp-key'); name.append(r.name);
427
+ const by = h(doc, 'small');
428
+ by.append('expected by ');
429
+ const homes = (r.homes || []).length ? r.homes : [payload.policy?.home || 'the policy'];
430
+ homes.forEach((x, i) => { if (i) by.append(', '); by.append(ref(doc, x)); });
431
+ if (r.marketplace) by.append(` · from ${r.marketplace}`);
432
+ name.append(by);
433
+ const want = h(doc, 'td', 'mono', r.minVersion ? `≥ ${r.minVersion}` : 'any version');
434
+ const have = h(doc, 'td', 'mono', r.installed?.version || (r.installed ? 'installed' : '—'));
435
+ if (!r.installed) have.classList.add('muted');
436
+ const [tone, label] = PLUGIN_STATE[r.state] || ['grey', r.state];
437
+ const state = h(doc, 'td'); state.append(h(doc, 'span', `badge ${tone}`, label));
438
+ const act = h(doc, 'td', 'tp-plugin-act');
439
+ if (r.state === 'missing') { const b = btn(doc, 'pl-policy-install', 'Install…'); b.dataset.name = r.name; b.dataset.marketplace = r.marketplace || ''; act.append(b); } // quiet: the head carries the primary action
440
+ else if (r.state === 'outdated') { const b = btn(doc, 'pl-policy-update', 'Update…'); b.dataset.name = r.name; act.append(b); }
441
+ else if (r.state === 'disabled') act.append(h(doc, 'small', 'hint', 'enable it on the Plugins page'));
442
+ else if (r.config) { const b = btn(doc, 'pl-policy-configure', 'Configure…'); b.dataset.name = r.name; act.append(b); }
443
+ tr.append(name, want, have, state, act);
444
+ tbody.append(tr);
445
+ }
446
+ table.append(tbody);
447
+ card.append(table);
448
+ }
449
+ root.append(card);
450
+ if (blocked.length) {
451
+ const bc = h(doc, 'section', 'card tp-blocked');
452
+ const bh = h(doc, 'div', 'card-head');
453
+ bh.append(h(doc, 'h2', null, 'Blocked by the policy'), h(doc, 'small', 'hint', 'A run with one of these enabled proceeds and is recorded as off-policy'));
454
+ bc.append(bh);
455
+ const list = h(doc, 'div', 'tp-blocked-list');
456
+ for (const b of blocked) {
457
+ const row = h(doc, 'div', 'tp-blocked-row');
458
+ const by = h(doc, 'small', 'hint'); by.append('blocked by ', ref(doc, b.home));
459
+ row.append(h(doc, 'span', 'mono', b.name), h(doc, 'span', 'badge amber', 'enabled here'), by);
460
+ list.append(row);
461
+ }
462
+ bc.append(list);
463
+ root.append(bc);
464
+ }
465
+ return root;
466
+ }
467
+
468
+ /** The Catalog tab: the guardrail sets and models the policy ships to everyone who follows it. */
469
+ export function renderPolicyCatalogPanel(payload, { doc = globalThis.document } = {}) {
470
+ const cat = payload.policy?.doc?.catalogs || {};
471
+ const sets = cat.guardrailSets || [];
472
+ const models = cat.models || [];
473
+ const root = h(doc, 'div', 'tp-sec-body');
474
+ const sc = h(doc, 'section', 'card tp-catalog-sets');
475
+ const sh = h(doc, 'div', 'card-head');
476
+ sh.append(h(doc, 'h2', null, 'Guardrail sets'), h(doc, 'small', 'hint', 'Read-only on every machine that follows this policy · Settings › Guardrails'));
477
+ sc.append(sh);
478
+ if (!sets.length) sc.append(h(doc, 'div', 'hist-empty', 'This policy ships no guardrail sets.'));
479
+ else {
480
+ const list = h(doc, 'div', 'tp-cat-list');
481
+ for (const g of sets) {
482
+ const row = h(doc, 'div', 'tp-cat-row');
483
+ const main = h(doc, 'div', 'tp-cat-main');
484
+ main.append(h(doc, 'b', null, g.name || g.id), ' ', code(doc, `gp:${g.id}`));
485
+ const bits = [g.envScrub ? 'env scrubbed' : 'env passed through', `${(g.protectedPaths || []).length} protected path${(g.protectedPaths || []).length === 1 ? '' : 's'}`, `${(g.deny || []).length} deny rule${(g.deny || []).length === 1 ? '' : 's'}`];
486
+ main.append(h(doc, 'small', 'hint', bits.join(' · ')));
487
+ row.append(main, h(doc, 'span', 'badge blue', 'policy'));
488
+ list.append(row);
489
+ }
490
+ sc.append(list);
491
+ }
492
+ root.append(sc);
493
+ const mc = h(doc, 'section', 'card tp-catalog-models');
494
+ const mh = h(doc, 'div', 'card-head');
495
+ mh.append(h(doc, 'h2', null, 'Models'), h(doc, 'small', 'hint', 'Selectable everywhere a model is chosen · Settings › Models'));
496
+ mc.append(mh);
497
+ if (!models.length) mc.append(h(doc, 'div', 'hist-empty', 'This policy ships no models.'));
498
+ else {
499
+ const list = h(doc, 'div', 'tp-cat-list');
500
+ for (const m of models) {
501
+ const row = h(doc, 'div', 'tp-cat-row');
502
+ const main = h(doc, 'div', 'tp-cat-main');
503
+ main.append(h(doc, 'b', null, m.label || m.id), ' ', code(doc, m.id));
504
+ const envKeys = Object.keys(m.env || {});
505
+ main.append(h(doc, 'small', 'hint', [(m.efforts || []).join(' · ') || 'default efforts', envKeys.length ? `${envKeys.length} env var${envKeys.length === 1 ? '' : 's'}` : null, m.env?.ANTHROPIC_BASE_URL ? 'routes via base URL' : null].filter(Boolean).join(' · ')));
506
+ row.append(main, h(doc, 'span', 'badge blue', 'policy'));
507
+ list.append(row);
508
+ }
509
+ mc.append(list);
510
+ }
511
+ root.append(mc);
512
+ return root;
513
+ }
514
+
515
+ export function renderPolicyEmptyState({ doc = globalThis.document } = {}) {
516
+ const grid = h(doc, 'div', 'empty tm-empty');
517
+ const card = (step, title, body, href, label, primary) => {
518
+ const c = h(doc, 'section', 'card');
519
+ c.append(h(doc, 'div', 'tm-step', step), h(doc, 'h3', null, title));
520
+ const p = h(doc, 'p', 'hint');
521
+ for (const part of body) p.append(typeof part === 'string' ? part : h(doc, 'code', 'mono', part.code));
522
+ const a = h(doc, 'a', primary ? 'btn btn-primary btn-mini' : 'btn btn-ghost btn-mini', label); a.href = href;
523
+ c.append(p, a);
524
+ return c;
525
+ };
526
+ grid.append(
527
+ card('01 · PROJECT', 'Set up team policy on a project',
528
+ ['Creates a ', { code: 'worca-policy' }, " branch on the project's origin holding one document: cost caps, expected plugins, model and guardrail defaults. Every teammate's Worca reads it; nothing is added to your code branches."], '#projects', 'Go to Projects', true),
529
+ card('02 · WORKSPACE', 'Pick a policy home for a workspace',
530
+ ['Workspace runs use the policy of one member you choose, with the values that document sets for workspace runs. The choice lives on this machine and can be changed on the workspace card.'], '#workspaces', 'Go to Workspaces', false),
531
+ );
532
+ const foot = h(doc, 'small', 'hint tm-empty-foot', "Already set up by a teammate? Worca checks each project's origin for the branch on start and every hour. ");
533
+ const check = h(doc, 'button', 'linkish tp-check-now', 'Check now'); check.type = 'button';
534
+ foot.append(check);
535
+ const out = h(doc, 'div');
536
+ out.append(grid, foot);
537
+ return out;
538
+ }
539
+
540
+ // ---- Team policy page: edit mode (board 5) --------------------------------------------------
541
+ const KIND_LABEL = { default: 'Default', soft: 'Soft', hard: 'Hard' };
542
+ const EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max'];
543
+
544
+ function listChips(doc, row, items, labelOf) {
545
+ const list = row.querySelector('.tp-list');
546
+ list.replaceChildren();
547
+ for (const [i, it] of items.entries()) {
548
+ const chip = h(doc, 'span', 'chip tp-chip');
549
+ chip.append(labelOf(it));
550
+ const rm = h(doc, 'button', 'tp-chip-rm', '✕'); rm.type = 'button'; rm.dataset.index = String(i); rm.setAttribute('aria-label', `Remove ${labelOf(it)}`);
551
+ chip.append(rm);
552
+ list.append(chip);
553
+ }
554
+ if (!items.length) list.append(h(doc, 'span', 'muted tp-list-empty', '(none)'));
555
+ }
556
+
557
+ function readItems(row) { try { const v = JSON.parse(row.dataset.json || '[]'); return Array.isArray(v) ? v : []; } catch { return []; } }
558
+ function writeItems(row, items) { row.dataset.json = JSON.stringify(items); }
559
+
560
+ // ---- the catalog editor: guardrail sets and models as forms ------------------------------------
561
+ const CAT_TITLE = { guardrailSets: 'Guardrail sets', models: 'Models' };
562
+ const CAT_HINT = {
563
+ guardrailSets: 'Sets every teammate gets as read-only rows (gp:<id>) — the policy\'s default set and minimum tier point at them.',
564
+ models: 'Model entries every teammate gets as read-only rows — "allowed models" can name them. Env may use ${VAR} for tokens; a literal secret is refused at publish.',
565
+ };
566
+ /** A chip list for a catalog item field: the row idiom (dataset.json + .tp-list + an add row) on a small wrapper. */
567
+ function chipField(doc, label, key, items, placeholder) {
568
+ const wrap = h(doc, 'div', 'tp-cat-field tp-cat-chips'); wrap.dataset.key = key;
569
+ wrap.append(h(doc, 'label', null, label));
570
+ writeItems(wrap, Array.isArray(items) ? items : []);
571
+ wrap.append(h(doc, 'div', 'tp-list'));
572
+ const add = h(doc, 'div', 'path-row tp-add-row'); const inp = h(doc, 'input', 'input tp-add'); inp.type = 'text'; inp.placeholder = placeholder;
573
+ const b = h(doc, 'button', 'btn btn-ghost btn-mini tp-add-btn', '+ add'); b.type = 'button';
574
+ add.append(inp, b); wrap.append(add);
575
+ listChips(doc, wrap, readItems(wrap), (x) => String(x));
576
+ return wrap;
577
+ }
578
+ function textField(doc, label, cls, value, placeholder, mono = false) {
579
+ const wrap = h(doc, 'div', 'tp-cat-field');
580
+ wrap.append(h(doc, 'label', null, label));
581
+ const inp = h(doc, 'input', `input ${cls}${mono ? ' mono' : ''}`); inp.type = 'text'; inp.value = value == null ? '' : String(value); inp.placeholder = placeholder;
582
+ wrap.append(inp);
583
+ return wrap;
584
+ }
585
+ function boolField(doc, label, cls, on) {
586
+ const lab = h(doc, 'label', 'check-row tp-cat-bool'); const cb = h(doc, 'input', cls); cb.type = 'checkbox'; cb.checked = !!on;
587
+ lab.append(cb, ` ${label}`);
588
+ return lab;
589
+ }
590
+ function catalogItem(doc, cat, item) {
591
+ const it = h(doc, 'div', 'tp-cat-item'); it.dataset.cat = cat;
592
+ it.dataset.orig = JSON.stringify(item || {}); // the keys the document had: a round trip keeps them
593
+ const head = h(doc, 'div', 'tp-cat-item-head');
594
+ const rm = h(doc, 'button', 'btn-ghost btn-mini tp-cat-rm', 'Remove'); rm.type = 'button';
595
+ const grid = h(doc, 'div', 'tp-cat-grid');
596
+ if (cat === 'guardrailSets') {
597
+ head.append(h(doc, 'b', null, item?.name || item?.id || 'New guardrail set'), rm);
598
+ grid.append(
599
+ textField(doc, 'Id', 'tp-cat-id', item?.id, 'gateway-normal', true),
600
+ textField(doc, 'Name', 'tp-cat-name', item?.name, 'Gateway normal'),
601
+ );
602
+ const flags = h(doc, 'div', 'tp-cat-field tp-cat-flags');
603
+ flags.append(h(doc, 'label', null, 'Behaviour'), boolField(doc, 'honour project settings', 'tp-cat-honor', item?.honorProjectSettings), boolField(doc, 'scrub the environment', 'tp-cat-scrub', item?.envScrub));
604
+ it.append(head, grid, flags,
605
+ chipField(doc, 'Protected paths', 'protectedPaths', item?.protectedPaths, '.env*, **/secrets/**'),
606
+ chipField(doc, 'Deny rules', 'deny', item?.deny, 'Bash(git push)'),
607
+ chipField(doc, 'Env allowlist', 'envAllowlist', item?.envAllowlist, 'HOME'));
608
+ } else {
609
+ head.append(h(doc, 'b', null, item?.label || item?.id || 'New model'), rm);
610
+ grid.append(
611
+ textField(doc, 'Id', 'tp-cat-id', item?.id, 'acme-proxy-opus', true),
612
+ textField(doc, 'Label', 'tp-cat-label', item?.label, 'Opus via Acme gateway'),
613
+ );
614
+ const eff = h(doc, 'div', 'tp-cat-field tp-cat-efforts'); eff.append(h(doc, 'label', null, 'Efforts'));
615
+ const row = h(doc, 'div', 'tp-cat-effort-row');
616
+ for (const e of EFFORTS) { const lab = h(doc, 'label', 'check-row'); const cb = h(doc, 'input', 'tp-cat-effort'); cb.type = 'checkbox'; cb.value = e; cb.checked = Array.isArray(item?.efforts) && item.efforts.includes(e); lab.append(cb, ` ${e}`); row.append(lab); }
617
+ eff.append(row);
618
+ const env = h(doc, 'div', 'tp-cat-field tp-cat-env'); env.append(h(doc, 'label', null, 'Env'));
619
+ const rows = h(doc, 'div', 'tp-env-rows');
620
+ for (const [k, v] of Object.entries(item?.env || {})) rows.append(envRow(doc, k, v));
621
+ const addEnv = h(doc, 'button', 'btn btn-ghost btn-mini tp-env-add', '+ env var'); addEnv.type = 'button';
622
+ env.append(rows, addEnv, h(doc, 'small', 'hint', 'A token goes in as ${VAR}: the name of a variable on each teammate\'s machine, never the value.'));
623
+ it.append(head, grid, eff, env);
624
+ }
625
+ return it;
626
+ }
627
+ function envRow(doc, k = '', v = '') {
628
+ const r = h(doc, 'div', 'tp-env-row');
629
+ const key = h(doc, 'input', 'input mono tp-env-key'); key.type = 'text'; key.value = k; key.placeholder = 'ANTHROPIC_BASE_URL';
630
+ const val = h(doc, 'input', 'input mono tp-env-val'); val.type = 'text'; val.value = v; val.placeholder = 'https://… or ${ACME_LLM_TOKEN}';
631
+ const rm = h(doc, 'button', 'tp-chip-rm tp-env-rm', '✕'); rm.type = 'button'; rm.setAttribute('aria-label', 'Remove env var');
632
+ r.append(key, val, rm);
633
+ return r;
634
+ }
635
+ function catalogCard(doc, cat, items) {
636
+ const card = h(doc, 'section', 'card tp-cat-card'); card.dataset.cat = cat;
637
+ const head = h(doc, 'div', 'card-head'); head.append(h(doc, 'h2', null, CAT_TITLE[cat]), h(doc, 'small', 'hint', CAT_HINT[cat]));
638
+ card.append(head);
639
+ const list = h(doc, 'div', 'tp-cat-list'); list.dataset.cat = cat;
640
+ for (const it of items) list.append(catalogItem(doc, cat, it));
641
+ card.append(list);
642
+ const add = h(doc, 'button', 'btn btn-ghost btn-mini tp-cat-add', cat === 'guardrailSets' ? '+ Add guardrail set' : '+ Add model'); add.type = 'button'; add.dataset.cat = cat;
643
+ card.append(add);
644
+ return card;
645
+ }
646
+ /** One catalog item back to its object: the keys the document had, plus whatever the form set. */
647
+ function readCatalogItem(item) {
648
+ let orig = {}; try { orig = JSON.parse(item.dataset.orig || '{}') || {}; } catch { orig = {}; }
649
+ const id = item.querySelector('.tp-cat-id')?.value.trim();
650
+ if (!id) return null;
651
+ const out = { ...orig, id };
652
+ const text = (cls, key) => { const v = item.querySelector(cls)?.value.trim() || ''; if (v || key in orig) out[key] = v; };
653
+ const bool = (cls, key) => { const on = !!item.querySelector(cls)?.checked; if (on || key in orig) out[key] = on; };
654
+ if (item.dataset.cat === 'guardrailSets') {
655
+ text('.tp-cat-name', 'name');
656
+ bool('.tp-cat-honor', 'honorProjectSettings'); bool('.tp-cat-scrub', 'envScrub');
657
+ for (const key of ['envAllowlist', 'protectedPaths', 'deny']) {
658
+ const wrap = item.querySelector(`.tp-cat-chips[data-key="${key}"]`);
659
+ const items = wrap ? readItems(wrap) : [];
660
+ if (items.length || key in orig) out[key] = items;
661
+ }
662
+ } else {
663
+ text('.tp-cat-label', 'label');
664
+ const efforts = [...item.querySelectorAll('.tp-cat-effort:checked')].map((cb) => cb.value);
665
+ if (efforts.length || 'efforts' in orig) out.efforts = efforts;
666
+ const env = {};
667
+ for (const r of item.querySelectorAll('.tp-env-row')) { const k = r.querySelector('.tp-env-key').value.trim(); if (k) env[k] = r.querySelector('.tp-env-val').value.trim(); }
668
+ if (Object.keys(env).length || 'env' in orig) out.env = env;
669
+ }
670
+ return out;
671
+ }
672
+
673
+ /** Value control for one registry row; the row's dataset carries list-shaped values. */
674
+ function valueControl(doc, meta, entry, row) {
675
+ const wrap = h(doc, 'div', 'tp-control');
676
+ const v = entry ? entry.value : undefined;
677
+ switch (meta.type) {
678
+ case 'usd': case 'int': {
679
+ const inp = h(doc, 'input', 'input tp-val'); inp.type = 'number'; inp.step = meta.type === 'usd' ? '0.01' : '1';
680
+ if (meta.min != null) inp.min = String(meta.min); if (meta.max != null) inp.max = String(meta.max);
681
+ inp.value = v == null ? '' : String(v); inp.setAttribute('aria-label', meta.label);
682
+ wrap.append(inp); break;
683
+ }
684
+ case 'usd-or-null': {
685
+ const inp = h(doc, 'input', 'input tp-val'); inp.type = 'number'; inp.step = '0.1'; inp.min = String(meta.min ?? 0.1); inp.max = String(meta.max ?? 100);
686
+ inp.value = v == null ? '' : String(v); inp.setAttribute('aria-label', meta.label);
687
+ const lab = h(doc, 'label', 'check-row'); const cb = h(doc, 'input', 'tp-null'); cb.type = 'checkbox'; cb.checked = entry ? v === null : false;
688
+ lab.append(cb, ' No cap');
689
+ wrap.append(inp, lab); break;
690
+ }
691
+ case 'bool': {
692
+ const lab = h(doc, 'label', 'switch-row'); const cb = h(doc, 'input', 'sw-input tp-val'); cb.type = 'checkbox'; cb.checked = v === true;
693
+ cb.setAttribute('aria-label', meta.label);
694
+ lab.append(cb, h(doc, 'span', 'switch switch-sm'), h(doc, 'span', 'txt', v === true ? 'on' : 'off'));
695
+ cb.addEventListener('change', () => { lab.querySelector('.txt').textContent = cb.checked ? 'on' : 'off'; });
696
+ wrap.append(lab); break;
697
+ }
698
+ case 'enum': {
699
+ const sw = h(doc, 'div', 'select-wrap'); const sel = h(doc, 'select', 'select tp-val'); sel.setAttribute('aria-label', meta.label);
700
+ for (const val of meta.values || []) { const o = h(doc, 'option', null, val); o.value = val; if (v === val) o.selected = true; sel.append(o); }
701
+ sw.append(sel); wrap.append(sw); break;
702
+ }
703
+ case 'string': case 'semver': {
704
+ const inp = h(doc, 'input', 'input tp-val'); inp.type = 'text'; inp.value = v == null ? '' : String(v); inp.setAttribute('aria-label', meta.label);
705
+ inp.placeholder = meta.type === 'semver' ? '1.4.0' : meta.key === 'workflows.default' ? 'wf_full or wfp_<plugin>_<slug>' : meta.key === 'guardrails.default' ? 'normal, gr_<slug> or gp:<name>' : '';
706
+ wrap.append(inp); break;
707
+ }
708
+ case 'string[]': {
709
+ writeItems(row, Array.isArray(v) ? v : []);
710
+ wrap.append(h(doc, 'div', 'tp-list'));
711
+ const add = h(doc, 'div', 'path-row tp-add-row'); const inp = h(doc, 'input', 'input tp-add'); inp.type = 'text'; inp.placeholder = meta.key === 'plugins.marketplaces' ? 'owner/repo or URL' : meta.key === 'models.allowed' ? 'model id' : 'plugin name';
712
+ const b = h(doc, 'button', 'btn btn-ghost btn-mini tp-add-btn', '+ add'); b.type = 'button';
713
+ add.append(inp, b); wrap.append(add);
714
+ break;
715
+ }
716
+ case 'plugins': {
717
+ writeItems(row, Array.isArray(v) ? v : []);
718
+ wrap.append(h(doc, 'div', 'tp-list'));
719
+ const add = h(doc, 'div', 'path-row tp-add-row');
720
+ const name = h(doc, 'input', 'input tp-add tp-plugin-name'); name.type = 'text'; name.placeholder = 'plugin name';
721
+ const mkt = h(doc, 'input', 'input tp-plugin-marketplace'); mkt.type = 'text'; mkt.placeholder = 'marketplace (optional)';
722
+ const min = h(doc, 'input', 'input tp-plugin-min'); min.type = 'text'; min.placeholder = 'min version';
723
+ const b = h(doc, 'button', 'btn btn-ghost btn-mini tp-add-btn', '+ add'); b.type = 'button';
724
+ add.append(name, mkt, min, b); wrap.append(add);
725
+ break;
726
+ }
727
+ case 'steps': {
728
+ const items = v && typeof v === 'object' ? Object.entries(v).map(([role, s]) => ({ role, model: s?.model || '', effort: s?.effort || '' })) : [];
729
+ writeItems(row, items);
730
+ wrap.append(h(doc, 'div', 'tp-list'));
731
+ const add = h(doc, 'div', 'path-row tp-add-row');
732
+ const role = h(doc, 'input', 'input tp-add tp-step-role'); role.type = 'text'; role.placeholder = 'role (planner, implementer, …)';
733
+ const model = h(doc, 'input', 'input tp-step-model'); model.type = 'text'; model.placeholder = 'model id';
734
+ const sw = h(doc, 'div', 'select-wrap'); const effort = h(doc, 'select', 'select tp-step-effort');
735
+ const none = h(doc, 'option', null, 'effort'); none.value = ''; effort.append(none);
736
+ for (const e of EFFORTS) { const o = h(doc, 'option', null, e); o.value = e; effort.append(o); }
737
+ sw.append(effort);
738
+ const b = h(doc, 'button', 'btn btn-ghost btn-mini tp-add-btn', '+ add'); b.type = 'button';
739
+ add.append(role, model, sw, b); wrap.append(add);
740
+ break;
741
+ }
742
+ default: wrap.append(h(doc, 'span', 'muted', '—'));
743
+ }
744
+ return wrap;
745
+ }
746
+
747
+ const chipLabel = (meta) => (it) => (meta.type === 'plugins' ? `${it.name}${it.minVersion ? ` ≥ ${it.minVersion}` : ''}${it.marketplace ? ` (${it.marketplace})` : ''}`
748
+ : meta.type === 'steps' ? `${it.role} ${it.model || '·'}${it.effort ? ` / ${it.effort}` : ''}` : String(it));
749
+
750
+ function editorRow(doc, meta, entry, scope) {
751
+ const row = h(doc, 'div', 'tp-edit-row');
752
+ row.dataset.key = meta.key; row.dataset.scope = scope; row.dataset.type = meta.type;
753
+ const key = h(doc, 'div', 'tp-key'); key.append(meta.label);
754
+ if (meta.help) key.append(h(doc, 'small', null, meta.help));
755
+ row.append(key);
756
+ row.append(valueControl(doc, meta, entry, row));
757
+ const seg = h(doc, 'div', 'seg tp-kind-seg'); seg.setAttribute('role', 'group'); seg.setAttribute('aria-label', 'Kind');
758
+ const allowed = Array.isArray(meta.kinds) ? meta.kinds : ['default'];
759
+ for (const k of ['default', 'soft', 'hard']) {
760
+ const b = h(doc, 'button', `tp-kind-btn${entry && entry.kind === k ? ' on' : ''}`, KIND_LABEL[k]); b.type = 'button'; b.dataset.kind = k;
761
+ if (k === 'hard') { b.disabled = true; b.title = 'Hard constraints arrive in a later version. The value is stored today and treated as soft.'; }
762
+ else if (!allowed.includes(k)) { b.disabled = true; b.title = k === 'soft' ? 'Only a default: the developer\'s own value wins' : 'Only soft: an expectation the run may go past'; }
763
+ seg.append(b);
764
+ }
765
+ row.append(seg);
766
+ // "unset" keeps its own cell, so the first line never changes shape.
767
+ const unsetCell = h(doc, 'div', 'tp-row-unset');
768
+ const unset = h(doc, 'button', 'tp-unset', 'unset'); unset.type = 'button'; unsetCell.append(unset);
769
+ row.append(unsetCell);
770
+ // The options a soft cap carries (what a breach does, whether a reason is asked) sit on a line
771
+ // of their own under the value — the whole line appears or goes, and the input above never
772
+ // moves. An advisory field's window select is always there.
773
+ const extra = h(doc, 'div', 'tp-extra');
774
+ if (meta.cap) {
775
+ const lab = h(doc, 'label', 'tp-onbreach'); lab.append('on breach ');
776
+ const sw = h(doc, 'span', 'select-wrap'); const sel = h(doc, 'select', 'select tp-onbreach-sel');
777
+ for (const val of ['pause', 'warn']) { const o = h(doc, 'option', null, val); o.value = val; if ((entry?.onBreach || 'pause') === val) o.selected = true; sel.append(o); }
778
+ sw.append(sel); lab.append(sw);
779
+ const rr = h(doc, 'label', 'check-row tp-require'); const cb = h(doc, 'input', 'tp-require-reason'); cb.type = 'checkbox'; cb.checked = !!entry?.requireReason;
780
+ rr.append(cb, ' reason required');
781
+ extra.append(lab, rr, h(doc, 'span', 'tp-src', 'soft cap: the run pauses (or warns) at the tighter of team and local'));
782
+ const sync = () => { extra.hidden = seg.querySelector('.on')?.dataset.kind !== 'soft'; };
783
+ seg.addEventListener('click', () => setTimeout(sync, 0)); sync();
784
+ } else if (meta.advisory) {
785
+ const lab = h(doc, 'label', 'tp-onbreach'); lab.append('window ');
786
+ const sw = h(doc, 'span', 'select-wrap'); const sel = h(doc, 'select', 'select tp-window-sel');
787
+ for (const val of ['monthly', 'weekly']) { const o = h(doc, 'option', null, val); o.value = val; if ((entry?.window || 'monthly') === val) o.selected = true; sel.append(o); }
788
+ sw.append(sel); lab.append(sw); extra.append(lab, h(doc, 'span', 'tp-src', 'always warn'));
789
+ } else extra.hidden = true;
790
+ row.append(extra);
791
+ if (meta.type === 'string[]' || meta.type === 'plugins' || meta.type === 'steps') listChips(doc, row, readItems(row), chipLabel(meta));
792
+ return row;
793
+ }
794
+
795
+ /** Auto-pick the first allowed kind when a value changes on an unset row. */
796
+ function ensureKind(row) {
797
+ const seg = row.querySelector('.tp-kind-seg');
798
+ if (seg.querySelector('.on')) return;
799
+ const first = [...seg.querySelectorAll('button')].find((b) => !b.disabled);
800
+ if (first) { first.classList.add('on'); seg.dispatchEvent(new (row.ownerDocument.defaultView.Event)('click', { bubbles: true })); }
801
+ }
802
+
803
+ function wireEditor(doc, root, registry) {
804
+ const metaOf = (key) => registry.find((m) => m.key === key);
805
+ root.addEventListener('click', (e) => {
806
+ const kindBtn = e.target.closest && e.target.closest('.tp-kind-btn');
807
+ if (kindBtn && !kindBtn.disabled) {
808
+ const seg = kindBtn.closest('.tp-kind-seg');
809
+ seg.querySelectorAll('button').forEach((b) => b.classList.toggle('on', b === kindBtn));
810
+ root.dispatchEvent(new (doc.defaultView.Event)('tp-change', { bubbles: true }));
811
+ return;
812
+ }
813
+ const unset = e.target.closest && e.target.closest('.tp-unset');
814
+ if (unset) {
815
+ const row = unset.closest('.tp-edit-row');
816
+ row.querySelectorAll('.tp-kind-btn').forEach((b) => b.classList.remove('on'));
817
+ const meta = metaOf(row.dataset.key);
818
+ if (meta.type === 'string[]' || meta.type === 'plugins' || meta.type === 'steps') { writeItems(row, []); listChips(doc, row, [], chipLabel(meta)); }
819
+ else if (meta.type === 'bool') { const cb = row.querySelector('.tp-val'); cb.checked = false; cb.dispatchEvent(new (doc.defaultView.Event)('change')); }
820
+ else { const v = row.querySelector('.tp-val'); if (v) v.value = ''; const n = row.querySelector('.tp-null'); if (n) n.checked = false; }
821
+ root.dispatchEvent(new (doc.defaultView.Event)('tp-change', { bubbles: true }));
822
+ return;
823
+ }
824
+ // The catalog editor: an item, an env row, a chip list on an item.
825
+ const catRm = e.target.closest && e.target.closest('.tp-cat-rm');
826
+ if (catRm) { catRm.closest('.tp-cat-item').remove(); root.dispatchEvent(new (doc.defaultView.Event)('tp-change', { bubbles: true })); return; }
827
+ const catAdd = e.target.closest && e.target.closest('.tp-cat-add');
828
+ if (catAdd) {
829
+ const item = catalogItem(doc, catAdd.dataset.cat, null);
830
+ catAdd.closest('.tp-cat-card').querySelector('.tp-cat-list').append(item);
831
+ item.querySelector('.tp-cat-id').focus();
832
+ root.dispatchEvent(new (doc.defaultView.Event)('tp-change', { bubbles: true }));
833
+ return;
834
+ }
835
+ const envAdd = e.target.closest && e.target.closest('.tp-env-add');
836
+ if (envAdd) { const r = envRow(doc); envAdd.closest('.tp-cat-env').querySelector('.tp-env-rows').append(r); r.querySelector('.tp-env-key').focus(); return; }
837
+ const envRm = e.target.closest && e.target.closest('.tp-env-rm');
838
+ if (envRm) { envRm.closest('.tp-env-row').remove(); root.dispatchEvent(new (doc.defaultView.Event)('tp-change', { bubbles: true })); return; }
839
+ const rm = e.target.closest && e.target.closest('.tp-chip-rm');
840
+ if (rm) {
841
+ const chips = rm.closest('.tp-cat-chips');
842
+ if (chips) {
843
+ const items = readItems(chips); items.splice(Number(rm.dataset.index), 1);
844
+ writeItems(chips, items); listChips(doc, chips, items, (x) => String(x));
845
+ root.dispatchEvent(new (doc.defaultView.Event)('tp-change', { bubbles: true }));
846
+ return;
847
+ }
848
+ const row = rm.closest('.tp-edit-row'); const meta = metaOf(row.dataset.key);
849
+ const items = readItems(row); items.splice(Number(rm.dataset.index), 1);
850
+ writeItems(row, items); listChips(doc, row, items, chipLabel(meta));
851
+ root.dispatchEvent(new (doc.defaultView.Event)('tp-change', { bubbles: true }));
852
+ return;
853
+ }
854
+ const add = e.target.closest && e.target.closest('.tp-add-btn');
855
+ if (add && add.closest('.tp-cat-chips')) {
856
+ const chips = add.closest('.tp-cat-chips');
857
+ const val = chips.querySelector('.tp-add').value.trim();
858
+ const items = readItems(chips);
859
+ if (!val || items.includes(val)) return;
860
+ items.push(val); chips.querySelector('.tp-add').value = '';
861
+ writeItems(chips, items); listChips(doc, chips, items, (x) => String(x));
862
+ root.dispatchEvent(new (doc.defaultView.Event)('tp-change', { bubbles: true }));
863
+ return;
864
+ }
865
+ if (add) {
866
+ const row = add.closest('.tp-edit-row'); const meta = metaOf(row.dataset.key);
867
+ const items = readItems(row);
868
+ if (meta.type === 'plugins') {
869
+ const name = row.querySelector('.tp-plugin-name').value.trim();
870
+ if (!name) return;
871
+ const it = { name };
872
+ let mkt = row.querySelector('.tp-plugin-marketplace').value.trim();
873
+ // Picked from the list: the marketplace it came from, unless one was typed.
874
+ if (!mkt) { const opt = root.querySelector(`#tp-known-plugins option[value="${name.replace(/"/g, '\\"')}"]`); const from = opt && /^from (.+)$/.exec(opt.label || ''); if (from) mkt = from[1]; }
875
+ if (mkt) it.marketplace = mkt;
876
+ const min = row.querySelector('.tp-plugin-min').value.trim(); if (min) it.minVersion = min;
877
+ items.push(it);
878
+ row.querySelector('.tp-plugin-name').value = ''; row.querySelector('.tp-plugin-marketplace').value = ''; row.querySelector('.tp-plugin-min').value = '';
879
+ } else if (meta.type === 'steps') {
880
+ const role = row.querySelector('.tp-step-role').value.trim();
881
+ if (!role) return;
882
+ const it = { role, model: row.querySelector('.tp-step-model').value.trim(), effort: row.querySelector('.tp-step-effort').value };
883
+ const i = items.findIndex((x) => x.role === role); if (i >= 0) items[i] = it; else items.push(it);
884
+ row.querySelector('.tp-step-role').value = ''; row.querySelector('.tp-step-model').value = ''; row.querySelector('.tp-step-effort').value = '';
885
+ } else {
886
+ const val = row.querySelector('.tp-add').value.trim();
887
+ if (!val || items.includes(val)) return;
888
+ items.push(val); row.querySelector('.tp-add').value = '';
889
+ }
890
+ writeItems(row, items); listChips(doc, row, items, chipLabel(meta));
891
+ ensureKind(row);
892
+ root.dispatchEvent(new (doc.defaultView.Event)('tp-change', { bubbles: true }));
893
+ }
894
+ });
895
+ root.addEventListener('keydown', (e) => {
896
+ if (e.key !== 'Enter') return;
897
+ const inp = e.target.closest && e.target.closest('.tp-add-row input');
898
+ if (!inp) return;
899
+ e.preventDefault();
900
+ inp.closest('.tp-add-row').querySelector('.tp-add-btn').click();
901
+ });
902
+ root.addEventListener('input', (e) => {
903
+ const row = e.target.closest && e.target.closest('.tp-edit-row');
904
+ if (!row) {
905
+ // The document tab and the catalog editor: any keystroke is a change.
906
+ if (e.target.closest && (e.target.closest('.tp-doc-head') || e.target.closest('.tp-catalogs'))) root.dispatchEvent(new (doc.defaultView.Event)('tp-change', { bubbles: true }));
907
+ return;
908
+ }
909
+ if (!(e.target.classList.contains('tp-val') || e.target.classList.contains('tp-null'))) return;
910
+ ensureKind(row);
911
+ root.dispatchEvent(new (doc.defaultView.Event)('tp-change', { bubbles: true }));
912
+ });
913
+ root.addEventListener('change', (e) => {
914
+ const row = e.target.closest && e.target.closest('.tp-edit-row');
915
+ if (row && (e.target.classList.contains('tp-val') || e.target.classList.contains('tp-null'))) ensureKind(row);
916
+ if (e.target.closest && (e.target.closest('.tp-edit-row') || e.target.closest('.tp-doc-head') || e.target.closest('.tp-catalogs'))) {
917
+ root.dispatchEvent(new (doc.defaultView.Event)('tp-change', { bubbles: true }));
918
+ }
919
+ });
920
+ }
921
+
922
+ /**
923
+ * The editor (board 5): one card per registry group, a collapsed "For workspace runs" card,
924
+ * the catalogs, a sticky publish bar. `registry` is the FIELDS list served by GET /api/policy.
925
+ */
926
+ /**
927
+ * `known`: what this machine can offer as choices — { models: [{id,label}], plugins: [{name,marketplace}],
928
+ * marketplaces: [id], guardrails: [{id,name}], workflows: [{id,name}] }. Each becomes a <datalist> on the
929
+ * inputs that name such a thing (allowed models, a step's model, a required plugin and its marketplace, the
930
+ * default guardrail set and workflow), so a value can be picked or typed — a policy may name something the
931
+ * machine has not installed yet.
932
+ */
933
+ export function renderPolicyEditor(policyDoc, { registry = [], doc = globalThis.document, known = null } = {}) {
934
+ const src = policyDoc || {};
935
+ const root = h(doc, 'div', 'tp-editor');
936
+ root.dataset.original = JSON.stringify(canonicalDoc(src));
937
+ // ---- tabs: the read view's pills, one section per subject, so the form is never one long scroll.
938
+ const tabsBar = h(doc, 'div', 'tp-tabs tp-edit-tabs'); tabsBar.setAttribute('role', 'tablist');
939
+ const secs = h(doc, 'div', 'tp-edit-sections');
940
+ const tabs = [];
941
+ const addTab = (key, label, count) => {
942
+ const btn = h(doc, 'button', 'tp-tab'); btn.type = 'button'; btn.dataset.sec = key; btn.setAttribute('role', 'tab');
943
+ btn.append(label);
944
+ const badge = h(doc, 'span', 'tp-tab-badge'); badge.hidden = true; btn.append(badge);
945
+ const sec = h(doc, 'div', 'tp-sec tp-edit-sec'); sec.dataset.sec = key; sec.hidden = true; sec.setAttribute('role', 'tabpanel');
946
+ tabsBar.append(btn); secs.append(sec);
947
+ tabs.push({ key, btn, sec, badge, count });
948
+ return sec;
949
+ };
950
+ const activate = (key) => {
951
+ for (const t of tabs) { const on = t.key === key; t.btn.classList.toggle('active', on); t.btn.setAttribute('aria-selected', on ? 'true' : 'false'); t.sec.hidden = !on; }
952
+ };
953
+ tabsBar.addEventListener('click', (e) => { const b = e.target.closest && e.target.closest('button[data-sec]'); if (b) activate(b.dataset.sec); });
954
+
955
+ // Document: title and notes.
956
+ const docSec = addTab('document', 'Document', () => 0);
957
+ const headCard = h(doc, 'section', 'card tp-doc-head');
958
+ const hh = h(doc, 'div', 'card-head'); hh.append(h(doc, 'h2', null, 'Document'), h(doc, 'small', 'hint', 'What the policy is called and who to ask. Soft caps pause and can be continued past; defaults only start a developer off.'));
959
+ headCard.append(hh);
960
+ const tf = h(doc, 'div', 'field field-compact'); const tl = h(doc, 'label', null, 'Title'); tl.htmlFor = 'tp-title';
961
+ const ti = h(doc, 'input', 'input tp-title'); ti.id = 'tp-title'; ti.type = 'text'; ti.value = src.title || ''; ti.placeholder = 'e.g. Gateway team policy';
962
+ tf.append(tl, ti);
963
+ const nf = h(doc, 'div', 'field field-compact'); const nl = h(doc, 'label', null, 'Notes'); nl.htmlFor = 'tp-notes';
964
+ const na = h(doc, 'textarea', 'textarea tp-notes'); na.id = 'tp-notes'; na.rows = 2; na.value = src.notes || ''; na.placeholder = 'Who to ask before raising anything, what the caps are for…';
965
+ nf.append(nl, na);
966
+ headCard.append(tf, nf);
967
+ docSec.append(headCard);
968
+
969
+ // One tab per registry group; the badge counts the fields the policy sets there.
970
+ const groups = [];
971
+ for (const m of registry) if (!groups.includes(m.group)) groups.push(m.group);
972
+ const setCount = (scope, group) => [...root.querySelectorAll(`.tp-edit-row[data-scope="${scope}"]`)]
973
+ .filter((r) => (!group || (registry.find((m) => m.key === r.dataset.key) || {}).group === group) && r.querySelector('.tp-kind-seg .on')).length;
974
+ for (const g of groups) {
975
+ const sec = addTab(g, GROUP_LABELS[g] || g, () => setCount('fields', g));
976
+ const card = h(doc, 'section', 'card tp-group-card'); card.dataset.group = g;
977
+ const head = h(doc, 'div', 'card-head'); head.append(h(doc, 'h2', null, GROUP_LABELS[g] || g));
978
+ if (g === 'cost') head.append(h(doc, 'small', 'hint', 'Caps apply per developer. Soft caps pause and can be continued past.'));
979
+ if (g === 'plugins') head.append(h(doc, 'small', 'hint', 'Worca offers to install; it never installs without the developer\'s consent.'));
980
+ if (g === 'models') head.append(h(doc, 'small', 'hint', 'Catalog entries may use ${VAR} for tokens. Literal secrets are refused.'));
981
+ card.append(head);
982
+ for (const m of registry.filter((x) => x.group === g)) card.append(editorRow(doc, m, src.fields?.[m.key] || null, 'fields'));
983
+ sec.append(card);
984
+ }
985
+
986
+ // Workspace runs: the same fields, replacing the ones above for pipelines that target a workspace.
987
+ const wsSec = addTab('workspace', 'Workspace runs', () => setCount('workspaceRuns'));
988
+ const ws = h(doc, 'section', 'card tp-ws-card');
989
+ const wsHead = h(doc, 'div', 'card-head'); wsHead.append(h(doc, 'h2', null, 'For workspace runs'), h(doc, 'small', 'hint', 'Values here replace the ones on the other tabs for pipelines that target a workspace following this home. A field left unset keeps the project-run value.'));
990
+ ws.append(wsHead);
991
+ for (const m of registry.filter((x) => !x.advisory && x.key !== 'plugins.marketplaces' && x.key !== 'plugins.required' && x.key !== 'plugins.blocked' && x.key !== 'worca.minVersion' && x.key !== 'metrics.record')) {
992
+ ws.append(editorRow(doc, m, src.workspaceRuns?.[m.key] || null, 'workspaceRuns'));
993
+ }
994
+ wsSec.append(ws);
995
+
996
+ // Catalog: the guardrail sets and models the policy ships, edited as forms, never as JSON.
997
+ const catSec = addTab('catalog', 'Catalog', () => root.querySelectorAll('.tp-cat-item').length);
998
+ const cat = h(doc, 'div', 'tp-catalogs');
999
+ cat.append(catalogCard(doc, 'guardrailSets', src.catalogs?.guardrailSets || []), catalogCard(doc, 'models', src.catalogs?.models || []));
1000
+ catSec.append(cat);
1001
+
1002
+ root.append(tabsBar, secs);
1003
+ activate('document');
1004
+ // Pick or type: one datalist per kind of thing, wired to every input that names one.
1005
+ const lists = {
1006
+ models: (known?.models || []).map((m) => ({ value: m.id, label: m.label && m.label !== m.id ? m.label : '' })),
1007
+ plugins: (known?.plugins || []).map((p) => ({ value: p.name, label: p.marketplace ? `from ${p.marketplace}` : '' })),
1008
+ marketplaces: (known?.marketplaces || []).map((x) => ({ value: typeof x === 'string' ? x : x.id, label: '' })),
1009
+ guardrails: (known?.guardrails || []).map((g) => ({ value: g.id, label: g.name && g.name !== g.id ? g.name : '' })),
1010
+ workflows: (known?.workflows || []).map((w) => ({ value: w.id, label: w.name && w.name !== w.id ? w.name : '' })),
1011
+ };
1012
+ for (const [kind, items] of Object.entries(lists)) {
1013
+ if (!items.length) continue;
1014
+ const dl = h(doc, 'datalist'); dl.id = `tp-known-${kind}`;
1015
+ const seen = new Set();
1016
+ for (const it of items) { if (!it.value || seen.has(it.value)) continue; seen.add(it.value); const o = h(doc, 'option'); o.value = it.value; if (it.label) o.label = it.label; dl.append(o); }
1017
+ root.append(dl);
1018
+ }
1019
+ const wire = (sel, kind) => { if (!lists[kind].length) return; for (const inp of root.querySelectorAll(sel)) { inp.setAttribute('list', `tp-known-${kind}`); inp.classList.add('tp-pick'); } };
1020
+ wire('.tp-edit-row[data-key="models.allowed"] .tp-add', 'models');
1021
+ wire('.tp-edit-row[data-key="models.steps"] .tp-step-model', 'models');
1022
+ wire('.tp-edit-row[data-key="plugins.required"] .tp-plugin-name', 'plugins');
1023
+ wire('.tp-edit-row[data-key="plugins.blocked"] .tp-add', 'plugins');
1024
+ wire('.tp-edit-row[data-key="plugins.required"] .tp-plugin-marketplace', 'marketplaces');
1025
+ wire('.tp-edit-row[data-key="plugins.marketplaces"] .tp-add', 'marketplaces');
1026
+ wire('.tp-edit-row[data-key="guardrails.default"] .tp-val', 'guardrails');
1027
+ wire('.tp-edit-row[data-key="workflows.default"] .tp-val', 'workflows');
1028
+ // The publish controls: the app moves them into the page header, next to Cancel editing, so the
1029
+ // controls sit together and nothing floats over the form (the bar is the pure form of that).
1030
+ // Cancel editing is the way out: a separate Discard said the same twice.
1031
+ const bar = h(doc, 'div', 'tp-publish-bar');
1032
+ const grow = h(doc, 'span', 'grow');
1033
+ const count = h(doc, 'span', 'tp-change-count', 'no changes');
1034
+ grow.append(count, ' · ');
1035
+ const vj = h(doc, 'button', 'linkish tp-view-json', 'View JSON'); vj.type = 'button';
1036
+ const cj = h(doc, 'button', 'linkish tp-copy-json', 'Copy for a pull request'); cj.type = 'button';
1037
+ grow.append(vj, ' · ', cj);
1038
+ const publish = h(doc, 'button', 'btn btn-primary btn-mini tp-publish', 'Publish to worca-policy'); publish.type = 'button'; publish.disabled = true;
1039
+ bar.append(grow, publish);
1040
+ root.append(bar);
1041
+ // The message and the JSON view sit at the top, right under the header that holds the controls.
1042
+ const pre = h(doc, 'pre', 'tp-json mono'); pre.hidden = true;
1043
+ const msg = h(doc, 'p', 'form-msg tp-msg'); msg.setAttribute('aria-live', 'polite');
1044
+ root.prepend(msg, pre);
1045
+ wireEditor(doc, root, registry);
1046
+ vj.addEventListener('click', () => { pre.hidden = !pre.hidden; if (!pre.hidden) pre.textContent = JSON.stringify(docFromEditor(root, { registry }), null, 2); });
1047
+ const refresh = () => {
1048
+ const dirty = editorDirty(root, src, { registry });
1049
+ count.textContent = dirty ? `${changedKeys(root, src, { registry })} change${changedKeys(root, src, { registry }) === 1 ? '' : 's'}` : 'no changes';
1050
+ publish.disabled = !dirty;
1051
+ if (!pre.hidden) pre.textContent = JSON.stringify(docFromEditor(root, { registry }), null, 2);
1052
+ for (const t of tabs) { const n = t.count(); t.badge.textContent = String(n); t.badge.hidden = !n; }
1053
+ };
1054
+ root.addEventListener('tp-change', refresh);
1055
+ refresh();
1056
+ return root;
1057
+ }
1058
+
1059
+ function rowValue(row, meta) {
1060
+ switch (meta.type) {
1061
+ case 'usd': case 'int': { const s = row.querySelector('.tp-val').value.trim(); if (s === '') return undefined; const n = Number(s); return Number.isFinite(n) ? (meta.type === 'int' ? Math.round(n) : n) : undefined; }
1062
+ case 'usd-or-null': { if (row.querySelector('.tp-null')?.checked) return null; const s = row.querySelector('.tp-val').value.trim(); if (s === '') return undefined; const n = Number(s); return Number.isFinite(n) ? n : undefined; }
1063
+ case 'bool': return !!row.querySelector('.tp-val').checked;
1064
+ case 'enum': return row.querySelector('.tp-val').value;
1065
+ case 'string': case 'semver': { const s = row.querySelector('.tp-val').value.trim(); return s === '' ? undefined : s; }
1066
+ case 'string[]': return readItems(row);
1067
+ case 'plugins': return readItems(row);
1068
+ case 'steps': { const out = {}; for (const it of readItems(row)) { const s = {}; if (it.model) s.model = it.model; if (it.effort) s.effort = it.effort; out[it.role] = s; } return out; }
1069
+ default: return undefined;
1070
+ }
1071
+ }
1072
+
1073
+ /** The DOM read back into a policy document. Rows without a kind are omitted; empty lists are omitted. */
1074
+ export function docFromEditor(root, { registry = [] } = {}) {
1075
+ const metaOf = (key) => registry.find((m) => m.key === key) || { type: root.querySelector(`.tp-edit-row[data-key="${key}"]`)?.dataset.type };
1076
+ let orig = {}; try { orig = JSON.parse(root.dataset.original || '{}') || {}; } catch { orig = {}; }
1077
+ const out = { schema: 1, title: root.querySelector('.tp-title')?.value.trim() || '', notes: root.querySelector('.tp-notes')?.value.trim() || '', fields: {}, workspaceRuns: {}, catalogs: { guardrailSets: [], models: [] } };
1078
+ for (const row of root.querySelectorAll('.tp-edit-row')) {
1079
+ const kind = row.querySelector('.tp-kind-seg .on')?.dataset.kind;
1080
+ if (!kind) continue;
1081
+ const meta = metaOf(row.dataset.key);
1082
+ const value = rowValue(row, meta);
1083
+ if (value === undefined) continue;
1084
+ if ((meta.type === 'string[]' || meta.type === 'plugins') && !value.length) continue;
1085
+ if (meta.type === 'steps' && !Object.keys(value).length) continue;
1086
+ const entry = { kind, value };
1087
+ // An attribute at its default is written only when the document already carried it: a fresh
1088
+ // editor over a document that omits "onBreach": "pause" must read as unchanged.
1089
+ const had = orig[row.dataset.scope === 'workspaceRuns' ? 'workspaceRuns' : 'fields']?.[row.dataset.key] || {};
1090
+ if (meta.cap && kind === 'soft') {
1091
+ const ob = row.querySelector('.tp-onbreach-sel')?.value; if (ob && (ob !== 'pause' || 'onBreach' in had)) entry.onBreach = ob;
1092
+ const rr = !!row.querySelector('.tp-require-reason')?.checked; if (rr || 'requireReason' in had) entry.requireReason = rr;
1093
+ }
1094
+ if (meta.advisory) { const w = row.querySelector('.tp-window-sel')?.value; if (w && (w !== 'monthly' || 'window' in had)) entry.window = w; }
1095
+ out[row.dataset.scope === 'workspaceRuns' ? 'workspaceRuns' : 'fields'][row.dataset.key] = entry;
1096
+ }
1097
+ for (const cat of ['guardrailSets', 'models']) {
1098
+ out.catalogs[cat] = [...root.querySelectorAll(`.tp-cat-item[data-cat="${cat}"]`)].map(readCatalogItem).filter(Boolean);
1099
+ }
1100
+ return out;
1101
+ }
1102
+
1103
+ function canonicalDoc(d) {
1104
+ return { title: d?.title || '', notes: d?.notes || '', fields: d?.fields || {}, workspaceRuns: d?.workspaceRuns || {}, catalogs: { guardrailSets: d?.catalogs?.guardrailSets || [], models: d?.catalogs?.models || [] } };
1105
+ }
1106
+ export function editorDirty(root, original, { registry = [] } = {}) {
1107
+ return JSON.stringify(canonicalDoc(docFromEditor(root, { registry }))) !== JSON.stringify(canonicalDoc(original));
1108
+ }
1109
+ function changedKeys(root, original, { registry = [] } = {}) {
1110
+ const a = canonicalDoc(docFromEditor(root, { registry })); const b = canonicalDoc(original);
1111
+ let n = 0;
1112
+ for (const scope of ['fields', 'workspaceRuns']) {
1113
+ const keys = new Set([...Object.keys(a[scope]), ...Object.keys(b[scope])]);
1114
+ for (const k of keys) if (JSON.stringify(a[scope][k]) !== JSON.stringify(b[scope][k])) n += 1;
1115
+ }
1116
+ if (a.title !== b.title) n += 1;
1117
+ if (a.notes !== b.notes) n += 1;
1118
+ if (JSON.stringify(a.catalogs) !== JSON.stringify(b.catalogs)) n += 1;
1119
+ return n;
1120
+ }
1121
+
1122
+ // ---- Workspace card line (board 6) ----------------------------------------------------------
1123
+ // Home first, then the members in step with it, then the ones that need a look.
1124
+ const WS_MEMBER_WORD = { // [one, several]
1125
+ home: ['is the policy home', 'are the policy home'], 'is-home': ['carries the policy', 'carry the policy'],
1126
+ 'follows-home': ['follows the home', 'follow the home'], 'follows-other': ['follows another home', 'follow another home'],
1127
+ own: ['carries its own policy', 'carry their own policy'], none: ['has no worca-policy branch', 'have no worca-policy branch'],
1128
+ 'no-origin': ['has no origin remote', 'have no origin remote'],
1129
+ };
1130
+ const WS_MEMBER_RANK = Object.fromEntries(Object.keys(WS_MEMBER_WORD).map((k, i) => [k, i]));
1131
+ const memberWord = (state, n) => (WS_MEMBER_WORD[state] ? WS_MEMBER_WORD[state][n === 1 ? 0 : 1] : state);
1132
+ /**
1133
+ * The workspace page's Team policy block (design board 6, reworked): a facts list — POLICY HOME,
1134
+ * WORKSPACE RUNS, MEMBERS — with the label column the Team policy page's header uses, so a label
1135
+ * never runs into its value; the actions sit on their own row above it. `w` = /api/policy/scopes
1136
+ * → workspaces[].
1137
+ */
1138
+ export function renderWsPolicyLine(w, { doc = globalThis.document } = {}) {
1139
+ const root = h(doc, 'div', 'ws-policy-inner');
1140
+ const home = w.home || { state: 'unset' };
1141
+ const members = w.members || [];
1142
+ root.append(h(doc, 'span', 'tm-label', 'Team policy'));
1143
+ const actions = h(doc, 'span', 'ws-tbl-actions');
1144
+ if (home.state === 'ok') actions.append(btn(doc, 'wsp-open', 'Open policy'));
1145
+ if (home.state === 'ok' && members.some((m) => m.state === 'none')) actions.append(btn(doc, 'wsp-route', 'Route all to policy home'));
1146
+ actions.append(btn(doc, 'wsp-home-change', home.state === 'unset' ? 'Choose policy home…' : 'Change policy home…'));
1147
+ root.append(actions);
1148
+
1149
+ const facts = h(doc, 'dl', 'tp-facts ws-policy-facts');
1150
+ const fact = (label, ...parts) => {
1151
+ const dt = h(doc, 'dt', null, label);
1152
+ const dd = h(doc, 'dd');
1153
+ dd.append(...parts);
1154
+ facts.append(dt, dd);
1155
+ return dd;
1156
+ };
1157
+ // POLICY HOME: the member whose policy governs workspace runs here, or why there is none.
1158
+ if (home.state === 'unset') fact('POLICY HOME', h(doc, 'span', 'muted', 'none chosen'), ' · ', h(doc, 'span', null, 'workspace runs use your local settings'));
1159
+ else if (home.state !== 'ok') fact('POLICY HOME', dot(doc, 'red'), ' ', h(doc, 'span', 'ws-sum-bad', home.detail || 'the policy home is stale'));
1160
+ else {
1161
+ const dd = fact('POLICY HOME', dot(doc, 'green'), ' ', ref(doc, home.slug));
1162
+ // The home is a member that itself follows another project: say through which one.
1163
+ if (home.follows) dd.append(' · follows ', ref(doc, home.follows));
1164
+ }
1165
+ // WORKSPACE RUNS: only what the workspaceRuns block changes, by name; every other field is the
1166
+ // same as for project runs, so it is not repeated here.
1167
+ if (home.state === 'ok') {
1168
+ const changed = Array.isArray(home.workspaceRuns) ? home.workspaceRuns : [];
1169
+ const LABEL = { 'cost.pipelineLimitUsd': 'pipeline cap', 'cost.totalLimitUsd': 'total cap', 'guardrails.default': 'guardrails' };
1170
+ if (changed.length) {
1171
+ const dd = fact('WORKSPACE RUNS');
1172
+ changed.forEach((x, i) => {
1173
+ if (i) dd.append(' · ');
1174
+ dd.append(`${LABEL[x.key] || x.label.replace(/ \(USD\)$/, '').toLowerCase()} `, h(doc, 'b', null, x.display));
1175
+ });
1176
+ } else fact('WORKSPACE RUNS', h(doc, 'span', 'muted', 'same values as project runs'));
1177
+ }
1178
+ // MEMBERS: where each member's policy comes from, as counts — the table on the Team policy page
1179
+ // has the rows. Only when the scopes payload carries members.
1180
+ if (members.length) {
1181
+ const counts = new Map();
1182
+ for (const m of members) counts.set(m.state, (counts.get(m.state) || 0) + 1);
1183
+ const parts = [...counts.entries()].sort((a, b) => (WS_MEMBER_RANK[a[0]] ?? 99) - (WS_MEMBER_RANK[b[0]] ?? 99)).map(([state, n]) => `${n} ${memberWord(state, n)}`);
1184
+ const dd = fact('MEMBERS', parts.join(' · '));
1185
+ if (counts.get('none')) dd.append(' — ', h(doc, 'span', 'ws-sum-warn', 'a member with no branch keeps its own settings for project runs'));
1186
+ }
1187
+ root.append(facts);
1188
+ if (home.state === 'unset') root.append(h(doc, 'small', 'hint ws-home-hint', 'Workspace runs use your local settings until a policy home is chosen. The home is a per-machine choice; it defaults to the metrics home.'));
1189
+ root.append(h(doc, 'div', 'ws-policy-results'));
1190
+ return root;
1191
+ }
1192
+
1193
+ // ---- Settings readout + chip (board 7) --------------------------------------------------------
1194
+ export function renderTeamCapsReadout(homes, { doc = globalThis.document } = {}) {
1195
+ const list = (homes || []).filter((x) => x && x.slug && x.caps && (x.caps.pipeline || x.caps.total || x.caps.resetPeriod));
1196
+ if (!list.length) return null;
1197
+ const root = h(doc, 'div', 'team-readout');
1198
+ const lead = h(doc, 'span'); lead.append(h(doc, 'b', null, 'Team caps also apply'), ', per project. The tighter value wins; you can continue past a team cap when a run pauses.');
1199
+ root.append(lead);
1200
+ for (const x of list) {
1201
+ const row = h(doc, 'span', 'team-readout-row');
1202
+ row.append(h(doc, 'span', 'badge blue', x.slug), ' ');
1203
+ const parts = [];
1204
+ if (x.caps.pipeline) { const b = h(doc, 'b', 'mono', usd(x.caps.pipeline.value)); const s = h(doc, 'span'); s.append('pipeline ', b, ` (${x.caps.pipeline.kind})`); parts.push(s); }
1205
+ if (x.caps.total) { const b = h(doc, 'b', 'mono', usd(x.caps.total.value)); const s = h(doc, 'span'); s.append('total ', b, `/${(x.caps.resetPeriod || 'monthly') === 'weekly' ? 'week' : 'month'} (${x.caps.total.kind})`); parts.push(s); }
1206
+ parts.forEach((p, i) => { if (i) row.append(' · '); row.append(p); });
1207
+ const others = (x.usedBy || []).filter((s) => s !== x.slug);
1208
+ if (others.length) { row.append(' · also used by '); others.forEach((o, i) => { if (i) row.append(', '); row.append(ref(doc, o)); }); }
1209
+ root.append(row);
1210
+ }
1211
+ const link = h(doc, 'a', 'linkish tp-open-page', 'Open Team policy →'); link.href = '#team-policy';
1212
+ const l = h(doc, 'span'); l.append(link); root.append(l);
1213
+ return root;
1214
+ }
1215
+
1216
+ export function renderTeamChip({ kind, display }, { doc = globalThis.document } = {}) {
1217
+ const c = h(doc, 'span', 'team-chip');
1218
+ c.append(h(doc, 'span', 'kind', kind), ` team ${display}`);
1219
+ return c;
1220
+ }
1221
+
1222
+ // ---- New pipeline notes line (board 8) ----------------------------------------------------------
1223
+ export function renderPolicyNotesLine({ policy, notes = [] }, { doc = globalThis.document } = {}) {
1224
+ const root = h(doc, 'div', 'policy-line');
1225
+ const head = h(doc, 'div', 'pl-head-row');
1226
+ head.append(h(doc, 'span', 'badge blue', 'team policy'), ref(doc, policy?.home || '—'));
1227
+ const n = notes.length;
1228
+ head.append(h(doc, 'span', 'muted', n ? ` · ${n} note${n === 1 ? '' : 's'} · nothing here blocks the run` : ' · nothing to note'));
1229
+ root.append(head);
1230
+ for (const note of notes) {
1231
+ const row = h(doc, 'div', `pl-note${note.level === 'warn' ? ' warn' : ''}`);
1232
+ row.append(dot(doc, note.level === 'warn' ? 'amber' : 'grey'), h(doc, 'span', null, note.text));
1233
+ root.append(row);
1234
+ }
1235
+ const caps = capText(policy?.caps);
1236
+ const pooled = policy?.caps?.pooled ? `pooled budget ${usd(policy.caps.pooled.value)} / ${policy.caps.pooled.window}` : '';
1237
+ if (caps || pooled) {
1238
+ const row = h(doc, 'div', 'pl-note');
1239
+ row.append(dot(doc, 'grey'), h(doc, 'span', null, [caps, pooled].filter(Boolean).join(' · ')));
1240
+ root.append(row);
1241
+ }
1242
+ return root;
1243
+ }
1244
+
1245
+ // ---- Team-cap pause banner (board 9) --------------------------------------------------------------
1246
+ export function renderTeamCapPauseBanner(rec, { doc = globalThis.document, budget = null } = {}) {
1247
+ const total = rec.pauseReason === 'cost_total_policy';
1248
+ const el = h(doc, 'div', `cost-banner cb-policy ${total ? 'cb-policy-total' : 'cb-policy-pipeline'}`);
1249
+ el.append(h(doc, 'b', null, total ? 'Paused — team total cap reached' : 'Paused — team cost cap reached'));
1250
+ const text = h(doc, 'div', 'cb-text');
1251
+ // The harness detail carries the numbers and the home: "team cost cap reached ($11.40 >= $10.00, acme/gateway)"
1252
+ // or "team total cap reached ($160.00 >= $150.00 this month, acme/gateway)".
1253
+ const m = /\(\$([\d.,]+) >= \$([\d.,]+)(?: this (week|month))?, ([^)]+)\)/.exec(String(rec.pauseDetail || ''));
1254
+ const mono = (v) => h(doc, 'span', 'mono', `$${v}`);
1255
+ const b = budget || {};
1256
+ if (m) {
1257
+ const [, spent, cap, period, home] = m;
1258
+ if (total) {
1259
+ text.append('Estimated spend is ', mono(spent), ` this ${period || 'period'}, past the `, mono(cap), ' total cap set by ', h(doc, 'span', 'cb-home ref', home), "'s team policy.");
1260
+ if (b.totalLimitUsd != null) text.append(' Your own total limit ', mono(Number(b.totalLimitUsd).toFixed(2)), ' still applies.');
1261
+ text.append(' You can continue past the team cap once for this period; the overshoot is recorded to team metrics.');
1262
+ } else {
1263
+ text.append("This pipeline's estimated cost hit ", mono(spent), ', the per-pipeline cap of ', mono(cap), ' set by ', h(doc, 'span', 'cb-home ref', home), "'s team policy.");
1264
+ if (b.pipelineLimitUsd != null) text.append(' Your own limit is ', mono(Number(b.pipelineLimitUsd).toFixed(2)), '.');
1265
+ text.append(' You can continue past the team cap for this pipeline; the overshoot is recorded to team metrics.');
1266
+ }
1267
+ } else {
1268
+ text.append(total
1269
+ ? 'This is the total cap set by the team policy for this period; your own limits still apply. You can continue past it once for this period; the overshoot is recorded to team metrics.'
1270
+ : "This is the per-pipeline cap set by the team policy; your own limits still apply. You can continue past it for this pipeline; the overshoot is recorded to team metrics.");
1271
+ }
1272
+ el.append(text);
1273
+ const actions = h(doc, 'div', 'cb-actions');
1274
+ const open = h(doc, 'button', 'btn btn-mini cb-policy-open', 'Open Team policy'); open.type = 'button';
1275
+ const past = h(doc, 'button', 'btn btn-primary btn-mini cb-past-team-cap', total ? 'Continue past team cap (this period)' : 'Continue past team cap (this pipeline)'); past.type = 'button';
1276
+ past.dataset.pipelineId = rec.pipelineId || '';
1277
+ actions.append(open, past);
1278
+ el.append(actions);
1279
+ return el;
1280
+ }
1281
+
1282
+ // ---- Plugins page strip + setup checklist (boards 10, 11) ---------------------------------------
1283
+ /**
1284
+ * The label of the one button that does everything the policy still asks for on this machine:
1285
+ * "Install all…" when plugins are missing, "Update all…" when installed ones are below the floor,
1286
+ * "Install & update all…" when both; null when nothing is left to do. Each item still gets its
1287
+ * own consent dialog (or update preview) in turn — the button only chains them.
1288
+ */
1289
+ export function requiredAllLabel(requirements = []) {
1290
+ const missing = (requirements || []).some((r) => r.state === 'missing');
1291
+ const outdated = (requirements || []).some((r) => r.state === 'outdated');
1292
+ if (missing && outdated) return 'Install & update all…';
1293
+ if (missing) return 'Install all…';
1294
+ if (outdated) return 'Update all…';
1295
+ return null;
1296
+ }
1297
+ /** The primary "all" button (when there is anything to do) and the ghost "Set up…" that opens the checklist. */
1298
+ function requiredActions(doc, requirements) {
1299
+ const frag = doc.createDocumentFragment();
1300
+ const label = requiredAllLabel(requirements);
1301
+ if (label) frag.append(btn(doc, 'pl-policy-all', label, true));
1302
+ frag.append(btn(doc, 'pl-policy-setup', 'Set up…'));
1303
+ return frag;
1304
+ }
1305
+
1306
+ export function renderRequiredStrip(requirements = [], blockedPlugins = [], { doc = globalThis.document } = {}) {
1307
+ const off = (requirements || []).filter((r) => r.state !== 'ok');
1308
+ const blocked = blockedPlugins || [];
1309
+ if (!off.length && !blocked.length) return null;
1310
+ // One panel, however many plugins the policy asks for: a compact row per item (a dot, the
1311
+ // name and floor, who expects it, its state, a small per-row action) and the mass action in
1312
+ // the head — the Team policy page's Plugins tab, in short form.
1313
+ const root = h(doc, 'section', 'card pl-required-card');
1314
+ const head = h(doc, 'div', 'card-head');
1315
+ head.append(h(doc, 'span', 'badge blue', 'team policy'), h(doc, 'b', null, 'Required by team policy'), h(doc, 'span', 'badge', String(off.length + blocked.length)));
1316
+ const actions = h(doc, 'div', 'pl-required-actions');
1317
+ actions.append(requiredActions(doc, requirements));
1318
+ head.append(actions);
1319
+ root.append(head);
1320
+ const list = h(doc, 'div', 'pl-required-list');
1321
+ for (const r of off) {
1322
+ const row = h(doc, 'div', 'pl-required');
1323
+ row.dataset.name = r.name;
1324
+ row.append(dot(doc, r.state === 'disabled' ? 'grey' : 'amber'));
1325
+ const text = h(doc, 'span', 'pl-required-text');
1326
+ const by = h(doc, 'small', 'hint'); by.append('expected by '); (r.homes || []).forEach((x, i) => { if (i) by.append(', '); by.append(ref(doc, x)); });
1327
+ text.append(h(doc, 'b', 'mono', r.name), r.minVersion ? ` ≥ ${r.minVersion}` : '', by);
1328
+ row.append(text);
1329
+ const state = h(doc, 'span', `badge ${r.state === 'disabled' ? 'grey' : 'amber'} pl-required-state`,
1330
+ r.state === 'missing' ? 'not installed' : r.state === 'disabled' ? 'disabled' : `installed ${r.installed?.version || '?'} · below the floor`);
1331
+ row.append(state);
1332
+ const act = h(doc, 'span', 'pl-required-act');
1333
+ if (r.state === 'missing') { const b = btn(doc, 'pl-policy-install', 'Install…'); b.dataset.name = r.name; b.dataset.marketplace = r.marketplace || ''; act.append(b); }
1334
+ else if (r.state === 'outdated') { const b = btn(doc, 'pl-policy-update', 'Update…'); b.dataset.name = r.name; act.append(b); }
1335
+ else if (r.state === 'disabled') act.append(h(doc, 'small', 'hint', 'enable it below'));
1336
+ row.append(act);
1337
+ list.append(row);
1338
+ }
1339
+ for (const b of blocked) {
1340
+ const row = h(doc, 'div', 'pl-required pl-blocked');
1341
+ row.dataset.name = b.name;
1342
+ row.append(dot(doc, 'red'));
1343
+ const text = h(doc, 'span', 'pl-required-text');
1344
+ const by = h(doc, 'small', 'hint'); by.append('blocked by ', ref(doc, b.home), ' · runs proceed and are recorded as off-policy');
1345
+ text.append(h(doc, 'b', 'mono', b.name), by);
1346
+ row.append(text, h(doc, 'span', 'badge red pl-required-state', 'enabled here'), h(doc, 'span', 'pl-required-act'));
1347
+ list.append(row);
1348
+ }
1349
+ root.append(list);
1350
+ return root;
1351
+ }
1352
+
1353
+ export function renderSetupChecklist({ home, requirements = [], seeds = [], trusted = false }, { doc = globalThis.document } = {}) {
1354
+ const root = h(doc, 'div', 'tp-setup');
1355
+ root.append(h(doc, 'div', 'hint', 'The policy expects the items below on this machine. Nothing here runs without your click, and nothing blocks a run while an item is open.'));
1356
+ const list = h(doc, 'div', 'tp-setup-list');
1357
+ const row = (tone, main, sub, action) => {
1358
+ const r = h(doc, 'div', 'tp-setup-row');
1359
+ r.append(dot(doc, tone));
1360
+ const t = h(doc, 'span', 'tp-setup-text'); t.append(main); if (sub) t.append(h(doc, 'small', 'hint', sub));
1361
+ r.append(t);
1362
+ if (action) r.append(action);
1363
+ list.append(r);
1364
+ return r;
1365
+ };
1366
+ for (const s of seeds) row(s.added ? 'green' : 'amber', (() => { const m = h(doc, 'span'); m.append(h(doc, 'b', null, s.added ? 'Marketplace added ' : 'Marketplace to add '), code(doc, s.url)); return m; })(), s.error || null, s.added ? h(doc, 'span', 'badge green', 'done') : null);
1367
+ for (const r of requirements) {
1368
+ const label = h(doc, 'span'); label.append(h(doc, 'b', null, r.state === 'outdated' ? `Update ${r.name}` : r.state === 'ok' ? `${r.name} installed` : `Install ${r.name}`), r.minVersion ? ` ≥ ${r.minVersion}` : '', r.marketplace ? ` from ${r.marketplace}` : '');
1369
+ let action = null;
1370
+ if (r.state === 'missing') { action = btn(doc, 'pl-policy-install', 'Install…', true); action.dataset.name = r.name; action.dataset.marketplace = r.marketplace || ''; }
1371
+ else if (r.state === 'outdated') { action = btn(doc, 'pl-policy-update', 'Update…'); action.dataset.name = r.name; }
1372
+ else if (r.state === 'ok') action = h(doc, 'span', 'badge green', 'done');
1373
+ else if (r.state === 'disabled') action = h(doc, 'span', 'badge amber', 'disabled');
1374
+ row(r.state === 'ok' ? 'green' : 'amber', label, r.state === 'outdated' ? `installed ${r.installed?.version || '?'} · opens the update preview: commits, diffstat, manifest changes` : r.state === 'missing' ? 'opens the consent inventory: source, SHA, agents, tools' : null, action);
1375
+ if (r.config) {
1376
+ const cfg = h(doc, 'span'); cfg.append(h(doc, 'b', null, `Configure ${r.name}`), ` · ${Object.keys(r.config).join(', ')} seeded by the policy; secrets are yours to enter`);
1377
+ const c = btn(doc, 'pl-policy-configure', 'Configure…'); c.dataset.name = r.name; c.disabled = r.state === 'missing';
1378
+ if (c.disabled) c.title = `Install ${r.name} first`;
1379
+ row('grey', cfg, r.state === 'missing' ? `available once ${r.name} is installed` : 'opens the plugin\'s settings', c);
1380
+ }
1381
+ }
1382
+ if (!seeds.length && !requirements.length) list.append(h(doc, 'div', 'hist-empty', 'Nothing to set up.'));
1383
+ root.append(list);
1384
+ const trust = h(doc, 'label', 'switch-row tp-trust-row');
1385
+ const cb = h(doc, 'input', 'sw-input tp-trust'); cb.type = 'checkbox'; cb.checked = !!trusted; cb.dataset.home = home || '';
1386
+ const txt = h(doc, 'span', 'txt'); txt.append(h(doc, 'b', null, 'Trust this policy home'), h(doc, 'small', 'hint', `Install and update required plugins from ${home || 'this home'} automatically on this machine, without this checklist. Plugins run with your user privileges. You can turn this off on the Plugins page at any time.`));
1387
+ trust.append(cb, h(doc, 'span', 'switch switch-sm'), txt);
1388
+ root.append(trust);
1389
+ // One footer action: the modal's own Close is the way out (a "Later" here said the same twice).
1390
+ const actions = h(doc, 'div', 'confirm-actions');
1391
+ const all = h(doc, 'button', 'btn btn-primary btn-mini tp-install-all', requiredAllLabel(requirements) || 'Install all…'); all.type = 'button';
1392
+ all.disabled = !requiredAllLabel(requirements);
1393
+ actions.append(all);
1394
+ root.append(actions);
1395
+ return root;
1396
+ }
1397
+
1398
+ export function renderPolicyBadgeFor(origin, { doc = globalThis.document } = {}) {
1399
+ const b = h(doc, 'span', 'badge blue tp-origin', 'policy');
1400
+ if (origin) b.title = `from the team policy on ${origin}`;
1401
+ return b;
1402
+ }