@worca/app 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/README.md +403 -0
  2. package/agents/clarify.meta.json +19 -0
  3. package/agents/decomposer.meta.json +21 -0
  4. package/agents/implementer.meta.json +20 -0
  5. package/agents/manualTestsChecklist.meta.json +18 -0
  6. package/agents/manualWebUiTesting.meta.json +18 -0
  7. package/agents/planReviewer.meta.json +19 -0
  8. package/agents/planner.meta.json +20 -0
  9. package/agents/refiner.meta.json +19 -0
  10. package/agents/reviewer.meta.json +19 -0
  11. package/agents/worca-cc-clarify.md +67 -0
  12. package/agents/worca-cc-code-reviewer.md +66 -0
  13. package/agents/worca-cc-decomposer.md +84 -0
  14. package/agents/worca-cc-implementer.md +69 -0
  15. package/agents/worca-cc-manual-tests-checklist.md +63 -0
  16. package/agents/worca-cc-manual-web-ui-testing.md +64 -0
  17. package/agents/worca-cc-plan-refiner.md +69 -0
  18. package/agents/worca-cc-plan-reviewer.md +70 -0
  19. package/agents/worca-cc-planner.md +70 -0
  20. package/agents/worca-cc-workspace-reviewer.md +56 -0
  21. package/agents/worca-cc-workspace-scanner.md +55 -0
  22. package/agents/workspaceReviewer.meta.json +20 -0
  23. package/agents/workspaceScanner.meta.json +18 -0
  24. package/package.json +61 -0
  25. package/scripts/install.mjs +209 -0
  26. package/skills/worca/SKILL.md +66 -0
  27. package/src/cli/worca-cc.mjs +1520 -0
  28. package/src/core/agent-gen.mjs +206 -0
  29. package/src/core/agent-registry.mjs +417 -0
  30. package/src/core/agent-store.mjs +143 -0
  31. package/src/core/artifacts.mjs +2019 -0
  32. package/src/core/channels.mjs +302 -0
  33. package/src/core/chat/allowlist.mjs +27 -0
  34. package/src/core/chat/channel-host.mjs +562 -0
  35. package/src/core/chat/channel-protocol.mjs +117 -0
  36. package/src/core/chat/channel-worker-child.mjs +211 -0
  37. package/src/core/chat/chat-context.mjs +66 -0
  38. package/src/core/chat/command-router.mjs +343 -0
  39. package/src/core/chat/notifier.mjs +120 -0
  40. package/src/core/chat/parser.mjs +30 -0
  41. package/src/core/chat/rate-limiter.mjs +133 -0
  42. package/src/core/chat/redact.mjs +27 -0
  43. package/src/core/chat/renderers.mjs +136 -0
  44. package/src/core/claude-runner.mjs +1356 -0
  45. package/src/core/config.mjs +882 -0
  46. package/src/core/cost-budget.mjs +103 -0
  47. package/src/core/db.mjs +864 -0
  48. package/src/core/fanout.mjs +48 -0
  49. package/src/core/folder-dialog.mjs +138 -0
  50. package/src/core/fs-browse.mjs +49 -0
  51. package/src/core/git-info.mjs +200 -0
  52. package/src/core/guardrail-store.mjs +204 -0
  53. package/src/core/guardrails.mjs +302 -0
  54. package/src/core/marketplaces.mjs +267 -0
  55. package/src/core/migrate-fs-to-db.mjs +612 -0
  56. package/src/core/model-env.mjs +74 -0
  57. package/src/core/orchestrator.mjs +4279 -0
  58. package/src/core/overview-agent.mjs +124 -0
  59. package/src/core/phases.mjs +1279 -0
  60. package/src/core/pipeline-delete.mjs +428 -0
  61. package/src/core/plugin-api.mjs +13 -0
  62. package/src/core/plugin-config.mjs +100 -0
  63. package/src/core/plugin-inventory.mjs +50 -0
  64. package/src/core/plugin-manifest.mjs +447 -0
  65. package/src/core/plugin-models.mjs +130 -0
  66. package/src/core/plugin-repo.mjs +303 -0
  67. package/src/core/plugin-shim-child.mjs +76 -0
  68. package/src/core/plugin-shim.mjs +197 -0
  69. package/src/core/plugin-store.mjs +485 -0
  70. package/src/core/plugin-workflows.mjs +179 -0
  71. package/src/core/plugins-lock.mjs +49 -0
  72. package/src/core/preflight-node.mjs +122 -0
  73. package/src/core/preflight.mjs +341 -0
  74. package/src/core/projects.mjs +157 -0
  75. package/src/core/protocol.mjs +257 -0
  76. package/src/core/recoverable-error.mjs +51 -0
  77. package/src/core/results.mjs +188 -0
  78. package/src/core/run-context.mjs +1375 -0
  79. package/src/core/run-log.mjs +64 -0
  80. package/src/core/run-manifest.mjs +317 -0
  81. package/src/core/runners.mjs +167 -0
  82. package/src/core/settings.mjs +682 -0
  83. package/src/core/skills.mjs +210 -0
  84. package/src/core/sources.mjs +232 -0
  85. package/src/core/stats.mjs +182 -0
  86. package/src/core/store.mjs +67 -0
  87. package/src/core/title.mjs +64 -0
  88. package/src/core/workflow-validator.mjs +185 -0
  89. package/src/core/workflows.mjs +568 -0
  90. package/src/core/workspace-scan.mjs +420 -0
  91. package/src/core/workspaces.mjs +353 -0
  92. package/src/core/worktree.mjs +708 -0
  93. package/src/feature.mjs +9 -0
  94. package/ui/public/app.js +10647 -0
  95. package/ui/public/assets/worca-favicon.png +0 -0
  96. package/ui/public/assets/worca-logo.png +0 -0
  97. package/ui/public/chat-settings-view.mjs +89 -0
  98. package/ui/public/composer-core.mjs +211 -0
  99. package/ui/public/fonts/jetbrains-mono-latin-400-normal.woff2 +0 -0
  100. package/ui/public/fonts/poppins-latin-400-normal.woff2 +0 -0
  101. package/ui/public/fonts/poppins-latin-500-normal.woff2 +0 -0
  102. package/ui/public/fonts/poppins-latin-600-normal.woff2 +0 -0
  103. package/ui/public/fonts/poppins-latin-700-normal.woff2 +0 -0
  104. package/ui/public/guardrails-view.mjs +244 -0
  105. package/ui/public/index.html +1145 -0
  106. package/ui/public/log-filter.mjs +81 -0
  107. package/ui/public/log-line.mjs +86 -0
  108. package/ui/public/models-view.mjs +433 -0
  109. package/ui/public/plugins-view.mjs +430 -0
  110. package/ui/public/results-view.mjs +121 -0
  111. package/ui/public/source-pane.mjs +156 -0
  112. package/ui/public/stats-view.mjs +523 -0
  113. package/ui/public/style.css +1557 -0
  114. package/ui/server.mjs +3573 -0
@@ -0,0 +1,430 @@
1
+ // ui/public/plugins-view.mjs
2
+ // Pure DOM renderers for the Plugins view. Every function takes the target
3
+ // `document` via opts (defaults to the browser global) and returns DETACHED
4
+ // elements — no fetch, no listeners outside the returned tree. app.js owns
5
+ // endpoint calls, the modal shell, and mounting; node:test drives these via jsdom.
6
+
7
+ function h(doc, tag, cls, text) {
8
+ const n = doc.createElement(tag);
9
+ if (cls) n.className = cls;
10
+ if (text != null) n.textContent = text;
11
+ return n;
12
+ }
13
+
14
+ const sha7 = (sha) => (typeof sha === 'string' ? sha.slice(0, 7) : '');
15
+
16
+ // Inline feather-style icons for card action buttons (stroke follows currentColor).
17
+ const ICONS = {
18
+ bin: '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 6h18M8 6V4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v2m2 0v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"></path><line x1="10" y1="11" x2="10" y2="17"></line><line x1="14" y1="11" x2="14" y2="17"></line></svg>',
19
+ refresh: '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="23 4 23 10 17 10"></polyline><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"></path></svg>',
20
+ };
21
+
22
+ // icon button: <button class=cls>[svg] label</button>. innerHTML is a fixed
23
+ // ICONS constant (never caller data), so no injection surface.
24
+ function iconBtn(doc, cls, icon, label) {
25
+ const b = h(doc, 'button', cls);
26
+ b.type = 'button';
27
+ b.innerHTML = ICONS[icon];
28
+ b.appendChild(doc.createTextNode(label));
29
+ return b;
30
+ }
31
+
32
+ // contributions -> "2 agents · 1 source · 1 skill". Arrays (listInstalledPlugins)
33
+ // or plain counts are both tolerated.
34
+ function contribSummary(c) {
35
+ const n = (v) => (Array.isArray(v) ? v.length : (Number.isFinite(v) ? v : 0));
36
+ const parts = [
37
+ [n(c && c.agents), 'agent'], [n(c && c.taskSources), 'source'],
38
+ [n(c && c.chatChannels), 'chat channel'], [n(c && c.models), 'model'],
39
+ [n(c && c.skills), 'skill'], [n(c && c.workflows), 'workflow'],
40
+ ].filter(([k]) => k > 0).map(([k, w]) => `${k} ${w}${k > 1 ? 's' : ''}`);
41
+ return parts.join(' · ') || 'no contributions';
42
+ }
43
+
44
+ // renderPluginList(plugins, {channelStatus}) -> <div.pl-list> of cards.
45
+ // Action buttons + the enable checkbox carry data-name and a pl-* class so
46
+ // app.js can wire ONE delegated listener on the list container. channelStatus
47
+ // rows (GET /api/chat/status) add per-channel live badges; the badge carries
48
+ // data-channel-key="<plugin>/<id>" so a channel-status WS event can patch it
49
+ // in place.
50
+ export function renderPluginList(plugins, { doc = globalThis.document, channelStatus = [] } = {}) {
51
+ const root = h(doc, 'div', 'pl-list');
52
+ for (const p of plugins || []) {
53
+ const card = h(doc, 'section', 'card plugin-card');
54
+ card.dataset.name = p.name;
55
+ if (p.enabled === false) card.classList.add('pl-disabled');
56
+ const head = h(doc, 'div', 'pl-head');
57
+ head.appendChild(h(doc, 'b', 'pl-name', p.name));
58
+ head.appendChild(h(doc, 'span', 'pl-version mono', p.version || sha7(p.pinnedSha)));
59
+ if (p.linked) head.appendChild(h(doc, 'span', 'badge waiting pl-linked', 'linked'));
60
+ if (p.broken) head.appendChild(h(doc, 'span', 'badge red pl-broken', 'broken'));
61
+ const toggle = h(doc, 'label', 'pl-enable');
62
+ const cb = h(doc, 'input', 'pl-toggle');
63
+ cb.type = 'checkbox';
64
+ cb.checked = p.enabled !== false;
65
+ cb.dataset.name = p.name;
66
+ toggle.appendChild(cb);
67
+ toggle.appendChild(h(doc, 'span', '', p.enabled !== false ? 'enabled' : 'disabled'));
68
+ head.appendChild(toggle);
69
+ card.appendChild(head);
70
+ card.appendChild(h(doc, 'small', 'pl-contrib hint', contribSummary(p.contributions)));
71
+ if (p.repo || p.marketplaceName) {
72
+ const prov = [p.marketplaceName, p.repo].filter(Boolean).join(' · ');
73
+ card.appendChild(h(doc, 'small', 'pl-provenance hint mono',
74
+ p.pinnedSha ? `${prov} @ ${sha7(p.pinnedSha)}` : prov));
75
+ }
76
+ const chRows = (channelStatus || []).filter((c) => c.plugin === p.name);
77
+ if (chRows.length) {
78
+ const chans = h(doc, 'div', 'pl-channels');
79
+ for (const c of chRows) chans.appendChild(channelBadge(doc, c));
80
+ card.appendChild(chans);
81
+ }
82
+ const actions = h(doc, 'div', 'pl-actions');
83
+ for (const [cls, label] of [['pl-settings', 'Settings'], ['pl-doctor', 'Doctor'], ['pl-update', 'Update'], ['pl-remove', 'Remove']]) {
84
+ const b = h(doc, 'button', `btn-ghost ${cls}`, label);
85
+ b.type = 'button';
86
+ b.dataset.name = p.name;
87
+ actions.appendChild(b);
88
+ }
89
+ card.appendChild(actions);
90
+ root.appendChild(card);
91
+ }
92
+ if (!plugins || !plugins.length) {
93
+ root.appendChild(h(doc, 'div', 'hist-empty', 'No plugins installed. Browse Available below or add a marketplace.'));
94
+ }
95
+ return root;
96
+ }
97
+
98
+ // One live channel badge: dot color by connection state + platform label.
99
+ // Exported so app.js can re-render a single badge on a channel-status event.
100
+ export function channelBadge(doc, c) {
101
+ const stateCls = { connected: 'green', degraded: 'waiting', connecting: 'waiting' }[c.state] || 'red';
102
+ const b = h(doc, 'span', `badge ${stateCls} pl-channel`, `${c.displayName || c.channelId} · ${c.state}`);
103
+ b.dataset.channelKey = `${c.plugin}/${c.channelId}`;
104
+ if (c.detail) b.title = c.detail;
105
+ return b;
106
+ }
107
+
108
+ // renderOrphanList(orphans) -> <div.pl-orphans> of "leftover data" rows.
109
+ // Purge buttons carry data-name + .pl-purge-orphan for app.js's delegated
110
+ // listener. Empty/nullish input -> childless container (app.js skips mounting).
111
+ export function renderOrphanList(orphans, { doc = globalThis.document } = {}) {
112
+ const root = h(doc, 'div', 'pl-orphans');
113
+ if (!orphans || !orphans.length) return root;
114
+ root.appendChild(h(doc, 'h3', 'pl-orphans-title', 'Leftover data'));
115
+ for (const o of orphans) {
116
+ const row = h(doc, 'div', 'card pl-orphan-row');
117
+ row.dataset.name = o.name;
118
+ row.appendChild(h(doc, 'b', 'pl-name', o.name));
119
+ row.appendChild(h(doc, 'small', 'hint', 'uninstalled — config/secrets remain'));
120
+ const btn = h(doc, 'button', 'btn-ghost pl-purge-orphan', 'Purge');
121
+ btn.type = 'button';
122
+ btn.dataset.name = o.name;
123
+ row.appendChild(btn);
124
+ root.appendChild(row);
125
+ }
126
+ return root;
127
+ }
128
+
129
+ // renderInstallConsent(entry, inventory) — spec §6.1 "Will install" ceremony.
130
+ // entry: { name, repoUrl, sha } (a marketplace snapshot plugin + the marketplace's url/lastSync.sha);
131
+ // inventory: buildInstallInventory shape. Secrets render red; setup commands verbatim.
132
+ export function renderInstallConsent(entry, inventory, { doc = globalThis.document } = {}) {
133
+ const inv = inventory || {};
134
+ const root = h(doc, 'div', 'pl-consent');
135
+ root.appendChild(h(doc, 'div', 'pl-consent-src mono', `${entry.repoUrl} @ ${sha7(entry.sha)}`));
136
+ const section = (label) => {
137
+ const s = h(doc, 'div', 'pl-consent-sec');
138
+ s.appendChild(h(doc, 'div', 'pl-consent-h', label));
139
+ root.appendChild(s);
140
+ return s;
141
+ };
142
+ const agents = section(`Agents (${(inv.agents || []).length})`);
143
+ for (const a of inv.agents || []) {
144
+ agents.appendChild(h(doc, 'div', 'pl-consent-row mono',
145
+ `${a.key} — tools: ${(a.tools || []).join(', ') || 'none declared'}`));
146
+ }
147
+ const sources = section(`Task sources (${(inv.taskSources || []).length})`);
148
+ for (const s of inv.taskSources || []) {
149
+ const row = h(doc, 'div', 'pl-consent-row', s.displayName || s.id);
150
+ for (const key of s.secrets || []) row.appendChild(h(doc, 'span', 'pl-secret', `requests secret: ${key}`));
151
+ sources.appendChild(row);
152
+ }
153
+ if ((inv.chatChannels || []).length) {
154
+ const channels = section(`Chat channels (${inv.chatChannels.length})`);
155
+ for (const ch of inv.chatChannels) {
156
+ const dirs = [ch.inbound && 'inbound', ch.outbound && 'outbound'].filter(Boolean).join(' + ');
157
+ const row = h(doc, 'div', 'pl-consent-row',
158
+ `${ch.displayName || ch.id} (${ch.platform}, ${dirs}) — runs a persistent worker`);
159
+ for (const key of ch.secrets || []) row.appendChild(h(doc, 'span', 'pl-secret', `requests secret: ${key}`));
160
+ channels.appendChild(row);
161
+ }
162
+ channels.appendChild(h(doc, 'div', 'pl-consent-row pl-channel-warn',
163
+ 'Inbound chat can pause/stop/approve runs: anyone holding the bot token or in an allowed chat controls worca-cc.'));
164
+ }
165
+ // Models (design §9.4): the base URL renders VERBATIM — a model's env can
166
+ // redirect all API traffic for that model, so the reviewer must see where.
167
+ if ((inv.models || []).length) {
168
+ const models = section(`Models (${inv.models.length})`);
169
+ for (const m of inv.models) {
170
+ const row = h(doc, 'div', 'pl-consent-row', `${m.label || m.id} `);
171
+ row.appendChild(h(doc, 'span', 'mono', `(${m.id})`));
172
+ if (m.baseUrl) row.appendChild(h(doc, 'span', 'pl-secret pl-baseurl', ` routes to: ${m.baseUrl}`));
173
+ if ((m.envKeys || []).length) row.appendChild(h(doc, 'small', 'hint', ` env: ${m.envKeys.join(', ')}`));
174
+ models.appendChild(row);
175
+ }
176
+ for (const s of inv.modelSecrets || []) {
177
+ models.appendChild(h(doc, 'div', 'pl-consent-row')).appendChild(
178
+ h(doc, 'span', 'pl-secret', `requests model secret: ${s.key}${s.label && s.label !== s.key ? ` (${s.label})` : ''}`));
179
+ }
180
+ }
181
+ const skills = section(`Skills (${(inv.skills || []).length})`);
182
+ for (const s of inv.skills || []) skills.appendChild(h(doc, 'div', 'pl-consent-row mono', s));
183
+ const wfs = section(`Workflows (${(inv.workflows || []).length})`);
184
+ for (const w of inv.workflows || []) wfs.appendChild(h(doc, 'div', 'pl-consent-row mono', w));
185
+ const setup = section('Setup');
186
+ setup.appendChild(h(doc, 'div', 'pl-consent-row',
187
+ inv.depCount == null ? 'no dependencies' : `${inv.depCount} npm dependencies (from lockfile)`));
188
+ for (const cmd of inv.setupCommands || []) setup.appendChild(h(doc, 'div', 'pl-consent-row mono pl-setup-cmd', cmd));
189
+ root.appendChild(h(doc, 'p', 'hint', 'Plugins run with your user privileges. Install only sources you trust.'));
190
+ return root;
191
+ }
192
+
193
+ // renderUpdatePreview(preview) — fetchCandidate result: pinned→candidate shas,
194
+ // commit log, diffstat, confirm button (.pl-confirm-update; app.js wires it).
195
+ // No new commits -> a plain up-to-date state: badge + hint, no shas/diffstat/button.
196
+ export function renderUpdatePreview(preview, { doc = globalThis.document } = {}) {
197
+ const p = preview || {};
198
+ const root = h(doc, 'div', 'pl-update');
199
+ if (!(p.commits || []).length) {
200
+ const row = h(doc, 'div', 'pl-uptodate');
201
+ row.appendChild(h(doc, 'span', 'badge green', 'up to date'));
202
+ const at = sha7(p.pinnedSha || p.candidateSha);
203
+ row.appendChild(h(doc, 'span', 'hint', `You are on the latest version${at ? ` (${at})` : ''}.`));
204
+ root.appendChild(row);
205
+ return root;
206
+ }
207
+ root.appendChild(h(doc, 'div', 'pl-update-shas mono', `${sha7(p.pinnedSha)} → ${sha7(p.candidateSha)}`));
208
+ const list = h(doc, 'div', 'pl-commits');
209
+ for (const c of p.commits) list.appendChild(h(doc, 'div', 'pl-commit mono', `${sha7(c.sha)} ${c.subject}`));
210
+ root.appendChild(list);
211
+ if (p.diffstat) root.appendChild(h(doc, 'pre', 'pl-diffstat mono', p.diffstat));
212
+ // Manifest delta — the §6.2 red-flag review lines: new secrets/agents/sources.
213
+ const d = p.manifestDelta || {};
214
+ const flags = [
215
+ ...(d.newSecrets || []).map((k) => ['pl-delta-secret', `NEW SECRET requested: ${k}`]),
216
+ ...(d.newTaskSources || []).map((s) => ['pl-delta', `new task source: ${s}`]),
217
+ ...(d.newAgents || []).map((a) => ['pl-delta', `new agent: ${a}`]),
218
+ ...(d.setupChanged ? [['pl-delta', 'setup commands changed']] : []),
219
+ // Model delta (design §9.4): an env change can silently reroute API traffic
220
+ // — red-flag it like a new secret.
221
+ ...(d.envChangedModels || []).map((m) => ['pl-delta-secret', `MODEL ENV CHANGED (check its base URL): ${m}`]),
222
+ ...(d.newModelSecrets || []).map((k) => ['pl-delta-secret', `NEW MODEL SECRET requested: ${k}`]),
223
+ ...(d.newModels || []).map((m) => ['pl-delta', `new model: ${m}`]),
224
+ ...(d.removedModels || []).map((m) => ['pl-delta', `removed model: ${m}`]),
225
+ ];
226
+ if (flags.length) {
227
+ const box = h(doc, 'div', 'pl-manifest-delta');
228
+ for (const [cls, text] of flags) box.appendChild(h(doc, 'div', cls, text));
229
+ root.appendChild(box);
230
+ }
231
+ const btn = h(doc, 'button', 'btn btn-primary btn-mini pl-confirm-update', 'Apply update');
232
+ btn.type = 'button';
233
+ root.appendChild(btn);
234
+ return root;
235
+ }
236
+
237
+ // renderConfigForm(sections) — one <form.pl-config-form>
238
+ // per task source. secret:true fields (text-only per normalizeManifest) render
239
+ // type=password, NEVER prefilled; a stored value arrives redacted as {set:true}
240
+ // -> placeholder '(set)' + data-set="1" so collect can skip it untouched.
241
+ // Accepts the legacy array of sources OR the full { sources, channels } config
242
+ // payload. Channel forms carry data-channel-id (collectConfigForm routes the
243
+ // PUT accordingly) and a small platform heading so mixed plugins stay legible.
244
+ export function renderConfigForm(sections, { doc = globalThis.document } = {}) {
245
+ const root = h(doc, 'div', 'pl-config');
246
+ const sources = Array.isArray(sections) ? sections : (sections?.sources || []);
247
+ const channels = Array.isArray(sections) ? [] : (sections?.channels || []);
248
+ const rows = [
249
+ ...sources.map((x) => ({ ...x, _kind: 'source' })),
250
+ ...channels.map((x) => ({ ...x, _kind: 'channel' })),
251
+ ];
252
+ for (const src of rows) {
253
+ const form = h(doc, 'form', 'pl-config-form');
254
+ if (src._kind === 'channel') {
255
+ form.dataset.channelId = src.id || '';
256
+ form.appendChild(h(doc, 'div', 'pl-config-h', `${src.displayName || src.id} (${src.platform || 'chat'} channel)`));
257
+ } else {
258
+ form.dataset.sourceId = src.id || '';
259
+ }
260
+ for (const f of src.schema || []) {
261
+ const field = h(doc, 'div', 'field');
262
+ field.appendChild(h(doc, 'label', '', f.label || f.key));
263
+ let input;
264
+ const val = (src.values || {})[f.key];
265
+ if (f.type === 'select') {
266
+ input = h(doc, 'select', 'select');
267
+ for (const o of f.options || []) {
268
+ const opt = h(doc, 'option', '', typeof o === 'object' ? (o.label ?? o.value) : String(o));
269
+ opt.value = typeof o === 'object' ? String(o.value) : String(o);
270
+ input.appendChild(opt);
271
+ }
272
+ if (typeof val === 'string') input.value = val;
273
+ } else if (f.secret) {
274
+ input = h(doc, 'input', 'input');
275
+ input.type = 'password';
276
+ input.value = '';
277
+ if (val && val.set === true) { input.placeholder = '(set)'; input.dataset.set = '1'; }
278
+ } else {
279
+ input = h(doc, 'input', 'input');
280
+ input.type = 'text';
281
+ input.value = typeof val === 'string' ? val : (f.default != null ? String(f.default) : '');
282
+ }
283
+ input.dataset.key = f.key;
284
+ if (f.required) input.dataset.required = '1';
285
+ field.appendChild(input);
286
+ if (f.help) field.appendChild(h(doc, 'small', 'hint', f.help));
287
+ form.appendChild(field);
288
+ }
289
+ root.appendChild(form);
290
+ }
291
+ return root;
292
+ }
293
+
294
+ // collectConfigForm(formEl) -> { sourceId | channelId, values }. An untouched
295
+ // {set:true} secret (data-set="1", still empty) is OMITTED — saving never
296
+ // wipes a secret. Channel forms carry data-channel-id instead of data-source-id.
297
+ export function collectConfigForm(formEl) {
298
+ const values = {};
299
+ for (const input of formEl.querySelectorAll('[data-key]')) {
300
+ if (input.dataset.set === '1' && input.value === '') continue;
301
+ values[input.dataset.key] = input.value;
302
+ }
303
+ if (formEl.dataset.channelId) return { channelId: formEl.dataset.channelId, values };
304
+ return { sourceId: formEl.dataset.sourceId || '', values };
305
+ }
306
+
307
+ // renderDoctorReport(report: {ok, checks:[{id,ok,detail}]}) — row per check.
308
+ export function renderDoctorReport(report, { doc = globalThis.document } = {}) {
309
+ const r = report || {};
310
+ const root = h(doc, 'div', 'pl-doctor-report');
311
+ root.appendChild(h(doc, 'div', `badge ${r.ok ? 'green' : 'red'}`, r.ok ? 'healthy' : 'problems found'));
312
+ for (const c of r.checks || []) {
313
+ const row = h(doc, 'div', 'pl-doc-row');
314
+ row.appendChild(h(doc, 'span', `badge ${c.ok ? 'green' : 'red'}`, c.ok ? 'ok' : 'fail'));
315
+ row.appendChild(h(doc, 'span', 'mono', c.id));
316
+ if (c.detail) row.appendChild(h(doc, 'span', 'hint', c.detail));
317
+ root.appendChild(row);
318
+ }
319
+ return root;
320
+ }
321
+
322
+ // renderReferences409(refs) — uninstall guard: who still references the plugin.
323
+ export function renderReferences409(refs, { doc = globalThis.document } = {}) {
324
+ const root = h(doc, 'div', 'pl-refs');
325
+ root.appendChild(h(doc, 'p', 'hint err', 'Cannot uninstall: still referenced by'));
326
+ const ul = h(doc, 'ul', 'pl-refs-list');
327
+ for (const ref of refs || []) {
328
+ let text;
329
+ if (typeof ref === 'string') {
330
+ text = ref;
331
+ } else if (Array.isArray(ref.nodes) || Array.isArray(ref.steps)) {
332
+ // A model-guard reference (design §9.4): { id, steps, nodes }.
333
+ const count = (ref.nodes || []).length + (ref.steps || []).length;
334
+ text = `model: ${ref.id} (${count} pipeline selection${count === 1 ? '' : 's'})`;
335
+ } else {
336
+ text = `${ref.type || 'workflow'}: ${ref.name || ref.id || JSON.stringify(ref)}`;
337
+ }
338
+ ul.appendChild(h(doc, 'li', 'mono', text));
339
+ }
340
+ root.appendChild(ul);
341
+ return root;
342
+ }
343
+
344
+ // relTime(iso) -> "just now"/"5m ago"/"3h ago"/"2d ago"/ISO date fallback. Pure,
345
+ // jsdom-safe (tests pass a fixed `now`). Exported for reuse + unit tests (C4).
346
+ export function relTime(iso, now = Date.now()) {
347
+ const t = Date.parse(iso);
348
+ if (!Number.isFinite(t)) return String(iso || 'unknown');
349
+ const s = Math.max(0, Math.round((now - t) / 1000));
350
+ if (s < 45) return 'just now';
351
+ const m = Math.round(s / 60); if (m < 60) return `${m}m ago`;
352
+ const h = Math.round(m / 60); if (h < 24) return `${h}h ago`;
353
+ const d = Math.round(h / 24); if (d < 30) return `${d}d ago`;
354
+ return String(iso).slice(0, 10);
355
+ }
356
+
357
+ // renderAvailableList(marketplaces) -> <div.pl-available> of installable cards
358
+ // across every marketplace snapshot (GET /api/marketplaces). Install buttons
359
+ // carry data-name + data-marketplace for app.js's delegated listener; installed
360
+ // plugins render a badge instead; a never-synced marketplace renders no button
361
+ // (there is no sha to pin yet — Refresh first).
362
+ export function renderAvailableList(marketplaces, { doc = globalThis.document } = {}) {
363
+ const root = h(doc, 'div', 'pl-available');
364
+ const mkts = marketplaces || [];
365
+ let cards = 0;
366
+ for (const m of mkts) {
367
+ for (const p of m.plugins || []) {
368
+ cards++;
369
+ const card = h(doc, 'section', 'card pl-avail-card');
370
+ card.dataset.name = p.name;
371
+ card.dataset.marketplace = m.id;
372
+ const head = h(doc, 'div', 'pl-head');
373
+ head.appendChild(h(doc, 'b', 'pl-name', p.name));
374
+ head.appendChild(h(doc, 'span', 'pl-version mono', p.version || sha7(m.lastSync && m.lastSync.sha)));
375
+ head.appendChild(h(doc, 'span', 'badge waiting pl-mkt-badge', m.name || m.id));
376
+ if (p.installed) {
377
+ head.appendChild(h(doc, 'span', 'badge green pl-installed', 'Installed'));
378
+ } else if (m.lastSync) {
379
+ const b = h(doc, 'button', 'btn btn-primary btn-mini pl-install-avail', 'Install…');
380
+ b.type = 'button';
381
+ b.dataset.name = p.name;
382
+ b.dataset.marketplace = m.id;
383
+ head.appendChild(b);
384
+ }
385
+ card.appendChild(head);
386
+ if (p.description) card.appendChild(h(doc, 'small', 'hint', p.description));
387
+ card.appendChild(h(doc, 'small', 'pl-avail-src hint mono', m.url)); // C10: unspoofable source url
388
+ root.appendChild(card);
389
+ }
390
+ }
391
+ if (!mkts.length) root.appendChild(h(doc, 'div', 'hist-empty', 'No marketplaces yet — add one below.'));
392
+ else if (!cards) root.appendChild(h(doc, 'div', 'hist-empty', 'No plugins discovered in your marketplaces.'));
393
+ return root;
394
+ }
395
+
396
+ // renderMarketplaceList(marketplaces) -> <div.pl-mkts> of registry rows with
397
+ // Refresh/Remove buttons (data-id). Sync failures surface as .pl-mkt-warning
398
+ // lines; the snapshot stays usable (stale) per spec §4.6.
399
+ export function renderMarketplaceList(marketplaces, { doc = globalThis.document, now = Date.now() } = {}) {
400
+ const root = h(doc, 'div', 'pl-mkts');
401
+ for (const m of marketplaces || []) {
402
+ const row = h(doc, 'div', 'card pl-mkt-row');
403
+ row.dataset.id = m.id;
404
+ const head = h(doc, 'div', 'pl-head');
405
+ head.appendChild(h(doc, 'b', 'pl-name', m.name || m.id));
406
+ if (m.builtin) head.appendChild(h(doc, 'span', 'badge waiting pl-mkt-builtin', 'built-in'));
407
+ const actions = h(doc, 'div', 'pl-mkt-actions');
408
+ for (const [cls, icon, label] of [
409
+ ['btn-ghost pl-mkt-refresh', 'refresh', 'Refresh'],
410
+ ['pl-remove pl-mkt-remove', 'bin', 'Remove'],
411
+ ]) {
412
+ const b = iconBtn(doc, cls, icon, label);
413
+ b.dataset.id = m.id;
414
+ actions.appendChild(b);
415
+ }
416
+ head.appendChild(actions);
417
+ row.appendChild(head);
418
+ row.appendChild(h(doc, 'small', 'pl-mkt-url hint mono', m.url));
419
+ const n = (m.plugins || []).length;
420
+ row.appendChild(h(doc, 'small', 'pl-mkt-sync hint', m.lastSync
421
+ ? `${sha7(m.lastSync.sha)} · synced ${relTime(m.lastSync.at, now)} · ${n} plugin${n === 1 ? '' : 's'}`
422
+ : 'never synced — refresh to discover plugins'));
423
+ for (const w of m.warnings || []) row.appendChild(h(doc, 'div', 'pl-mkt-warning hint err', w));
424
+ root.appendChild(row);
425
+ }
426
+ if (!(marketplaces || []).length) {
427
+ root.appendChild(h(doc, 'div', 'hist-empty', 'No marketplaces registered.'));
428
+ }
429
+ return root;
430
+ }
@@ -0,0 +1,121 @@
1
+ // ui/public/results-view.mjs
2
+ // Pure shaping helpers for the run Results view. ESM so both the browser (app.js)
3
+ // and node:test can import them. DOM only via the explicit doc param — keep it
4
+ // unit-testable.
5
+
6
+ /** Human-readable summary chips from a Layer-1 results object. */
7
+ export function summaryChips(results) {
8
+ const s = (results && results.summary) || {};
9
+ const chips = [];
10
+ if (s.filesNew) chips.push(`${s.filesNew} new`);
11
+ if (s.filesChanged) chips.push(`${s.filesChanged} changed`);
12
+ if (s.filesDeleted) chips.push(`${s.filesDeleted} deleted`);
13
+ chips.push(`+${s.linesAdded || 0} / −${s.linesRemoved || 0}`);
14
+ chips.push(s.blockingIssues ? `${s.blockingIssues} to check` : 'Clean');
15
+ return chips;
16
+ }
17
+
18
+ /**
19
+ * Merge Layer-1 review checks with Layer-2 agent diffFindings. Review checks come
20
+ * first and are never dropped; agent findings are tagged origin:'agent' (+ isNew).
21
+ */
22
+ export function mergeFindings(checks, diffFindings) {
23
+ const reviewSide = (checks || []).map((c) => ({ ...c, origin: c.origin || 'review' }));
24
+ const agentSide = (diffFindings || []).map((f) => ({
25
+ severity: f.severity, title: f.title, detail: f.detail,
26
+ location: f.file ? `${f.file}${f.line != null ? ':' + f.line : ''}` : '',
27
+ origin: 'agent', isNew: f.newVsReview === true,
28
+ }));
29
+ return [...reviewSide, ...agentSide];
30
+ }
31
+
32
+ /** The single status chip kept in the results header: "Clean" or "N to check". */
33
+ export function statusChip(results) {
34
+ const s = (results && results.summary) || {};
35
+ return s.blockingIssues ? `${s.blockingIssues} to check` : 'Clean';
36
+ }
37
+
38
+ /**
39
+ * Always-on header badges for the Diff dropdown: "N changed" + "N removed".
40
+ * Rendered even when zero (product spec). `changed` = modified files
41
+ * (summary.filesChanged); `removed` = deleted files (summary.filesDeleted).
42
+ * NOTE: filesChanged already includes deleted rows (bucketFiles routes 'D' into
43
+ * changedFiles); we intentionally preserve today's counting. `n` lets the caller
44
+ * grey a zero badge.
45
+ */
46
+ export function diffBadges(results) {
47
+ const s = (results && results.summary) || {};
48
+ const changed = s.filesChanged || 0;
49
+ const removed = s.filesDeleted || 0;
50
+ return [
51
+ { kind: 'changed', n: changed, text: `${changed} changed` },
52
+ { kind: 'removed', n: removed, text: `${removed} removed` },
53
+ ];
54
+ }
55
+
56
+ // ── Plugin provenance (spec §7.5, §9.3, §11) ───────────────────────────────────
57
+
58
+ function _el(doc, tag, cls, text) {
59
+ const n = doc.createElement(tag);
60
+ if (cls) n.className = cls;
61
+ if (text != null) n.textContent = text;
62
+ return n;
63
+ }
64
+
65
+ /** Badge for a plugin-sourced pipeline row; null for prompt/markdown or a
66
+ * corrupt source_ref (old rows render statically, never throw — §11). */
67
+ export function sourceBadge(row, { doc = globalThis.document } = {}) {
68
+ if ((row?.source_type || 'prompt') !== 'plugin' || !row.source_ref) return null;
69
+ let ref;
70
+ try { ref = JSON.parse(row.source_ref); } catch { return null; }
71
+ if (!ref || !ref.plugin) return null;
72
+ const badge = _el(doc, 'span', 'src-badge');
73
+ badge.appendChild(_el(doc, 'span', 'src-badge-plugin', ref.plugin));
74
+ if (ref.url) {
75
+ const a = _el(doc, 'a', 'src-badge-task', ref.taskId || ref.title || ref.url);
76
+ a.href = ref.url;
77
+ a.target = '_blank';
78
+ a.rel = 'noreferrer';
79
+ badge.appendChild(a);
80
+ } else {
81
+ badge.appendChild(_el(doc, 'span', 'src-badge-task', ref.taskId || ref.title || ''));
82
+ }
83
+ return badge;
84
+ }
85
+
86
+ /** "Report result" retry (§7.5): posts to the report-result endpoint, shows the
87
+ * outcome. `post(url)` is injected (app.js wraps fetch); the in-flight promise
88
+ * is kept on el._pending for deterministic tests. */
89
+ export function reportResultControl(pipelineId, { doc = globalThis.document, post } = {}) {
90
+ const wrap = _el(doc, 'span', 'src-report-wrap');
91
+ const btn = _el(doc, 'button', 'btn btn-ghost btn-mini src-report', 'Report result');
92
+ btn.type = 'button';
93
+ const status = _el(doc, 'span', 'src-report-status hint', '');
94
+ btn.addEventListener('click', () => {
95
+ btn.disabled = true;
96
+ status.textContent = 'reporting…';
97
+ wrap._pending = (async () => {
98
+ try {
99
+ const out = await post(`/api/pipelines/${encodeURIComponent(pipelineId)}/report-result`);
100
+ status.textContent = out && out.ok ? (out.skipped ? 'nothing to report' : 'reported ✓') : `failed: ${out?.error || 'unknown'}`;
101
+ } catch (e) {
102
+ status.textContent = `failed: ${e.message}`;
103
+ } finally {
104
+ btn.disabled = false;
105
+ }
106
+ })();
107
+ });
108
+ wrap.append(btn, status);
109
+ return wrap;
110
+ }
111
+
112
+ /** Workflow-picker label (§9.3 badge, §6.5 disabled flag). enabledPluginNames =
113
+ * names of ENABLED installed plugins (from GET /api/plugins client-side); null
114
+ * = plugin list not known yet — show the plugin badge but skip the disabled flag. */
115
+ export function workflowPickerLabel(wf, enabledPluginNames = []) {
116
+ const origin = String(wf?.origin || '');
117
+ if (!origin.startsWith('plugin:')) return wf?.name || '';
118
+ const plugin = origin.slice('plugin:'.length);
119
+ const disabled = Array.isArray(enabledPluginNames) && !enabledPluginNames.includes(plugin);
120
+ return `${wf.name} [plugin: ${plugin}${disabled ? ' — disabled' : ''}]`;
121
+ }