@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
Binary file
@@ -0,0 +1,89 @@
1
+ // ui/public/chat-settings-view.mjs
2
+ // Pure DOM renderers for the Settings "Chat notifications" card
3
+ // (chat-connectivity-design.md §4.8). Same contract as plugins-view.mjs:
4
+ // detached elements, no fetch, no listeners — app.js owns I/O and mounting;
5
+ // 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 EVENTS = [
15
+ ['question', 'Approval / question needed', 'a pipeline is blocked waiting on you — the one to keep on'],
16
+ ['done', 'Run finished (done / stopped)', ''],
17
+ ['error', 'Run failed', ''],
18
+ ['paused', 'Run paused (incl. cost limits)', ''],
19
+ ];
20
+
21
+ /**
22
+ * renderChatSettings({prefs, channels}) -> detached card body.
23
+ * prefs = chatPrefs() shape {notify, channels}; channels = /api/chat/status
24
+ * rows. Inputs carry data-ev / data-channel-key; the Test button carries
25
+ * data-plugin + data-channel-id + .chat-test for app.js's delegated listener.
26
+ */
27
+ export function renderChatSettings({ prefs, channels } = {}, { doc = globalThis.document } = {}) {
28
+ const p = prefs || { notify: {}, channels: {} };
29
+ const root = h(doc, 'div', 'chat-settings');
30
+
31
+ const evBox = h(doc, 'div', 'chat-events');
32
+ evBox.appendChild(h(doc, 'div', 'label-row', 'Notify on'));
33
+ for (const [key, label, hint] of EVENTS) {
34
+ const row = h(doc, 'label', 'chat-event-row');
35
+ const cb = h(doc, 'input', 'chat-ev');
36
+ cb.type = 'checkbox';
37
+ cb.dataset.ev = key;
38
+ cb.checked = p.notify?.[key] !== false;
39
+ row.appendChild(cb);
40
+ row.appendChild(h(doc, 'span', '', label));
41
+ if (hint) row.appendChild(h(doc, 'small', 'hint', hint));
42
+ evBox.appendChild(row);
43
+ }
44
+ root.appendChild(evBox);
45
+
46
+ const chBox = h(doc, 'div', 'chat-channels');
47
+ chBox.appendChild(h(doc, 'div', 'label-row', 'Channels'));
48
+ const rows = channels || [];
49
+ if (!rows.length) {
50
+ chBox.appendChild(h(doc, 'small', 'hint chat-none', 'No chat channels installed. Install a chat plugin (e.g. telegram-chat) in the Plugins view.'));
51
+ }
52
+ for (const c of rows) {
53
+ const key = `${c.plugin}/${c.channelId}`;
54
+ const row = h(doc, 'div', 'chat-channel-row');
55
+ row.dataset.channelKey = key;
56
+ const toggle = h(doc, 'label', 'chat-channel-toggle');
57
+ const cb = h(doc, 'input', 'chat-ch');
58
+ cb.type = 'checkbox';
59
+ cb.dataset.channelKey = key;
60
+ cb.checked = p.channels?.[key]?.enabled !== false;
61
+ toggle.appendChild(cb);
62
+ toggle.appendChild(h(doc, 'span', '', `${c.displayName || c.channelId} (${c.platform})`));
63
+ row.appendChild(toggle);
64
+ const stateCls = { connected: 'green', degraded: 'waiting', connecting: 'waiting', unconfigured: 'waiting' }[c.state] || 'red';
65
+ const badge = h(doc, 'span', `badge ${stateCls} chat-state`, c.state);
66
+ badge.dataset.channelKey = key;
67
+ if (c.detail) badge.title = c.detail;
68
+ row.appendChild(badge);
69
+ const test = h(doc, 'button', 'btn-ghost btn-mini chat-test', 'Test');
70
+ test.type = 'button';
71
+ test.dataset.plugin = c.plugin;
72
+ test.dataset.channelId = c.channelId;
73
+ row.appendChild(test);
74
+ chBox.appendChild(row);
75
+ }
76
+ root.appendChild(chBox);
77
+ return root;
78
+ }
79
+
80
+ /** collectChatSettings(root) -> the POST /api/settings {chat} patch. */
81
+ export function collectChatSettings(root) {
82
+ const notify = {};
83
+ for (const cb of root.querySelectorAll('input.chat-ev[data-ev]')) notify[cb.dataset.ev] = cb.checked;
84
+ const channels = {};
85
+ for (const cb of root.querySelectorAll('input.chat-ch[data-channel-key]')) {
86
+ channels[cb.dataset.channelKey] = { enabled: cb.checked };
87
+ }
88
+ return { notify, channels };
89
+ }
@@ -0,0 +1,211 @@
1
+ // ui/public/composer-core.mjs
2
+ // Framework-free, DOM-free helpers for the Pipeline Composer. Imported by
3
+ // ui/public/app.js (browser, type="module") AND by test/composer-ui.test.mjs
4
+ // (node:test, no jsdom). KEEP THIS FILE FREE OF document/window references so it
5
+ // stays unit-testable in isolation — DOM wiring lives in app.js.
6
+
7
+ // ---------------------------------------------------------------------------
8
+ // topology(steps, feedbacks) -> WorkflowTemplate {steps,feedbacks} body.
9
+ // Canvas model uses throwaway local ids (n1, n7…). The persisted contract uses
10
+ // stable instance ids "s{stepIndex}_{memberIndex}" (e.g. "s0_0"); feedback
11
+ // from/to reference those instance ids. We rebuild the id map and remap edges,
12
+ // dropping any edge whose endpoint is gone (defensive; the UI prunes these too).
13
+ // ---------------------------------------------------------------------------
14
+ export function topology(steps, feedbacks) {
15
+ const idMap = {}; // localId -> "sI_J"
16
+ const outSteps = steps.map((col, i) =>
17
+ col.map((node, j) => {
18
+ const id = `s${i}_${j}`;
19
+ idMap[node.id] = id;
20
+ return { id, key: node.key };
21
+ }),
22
+ );
23
+ const outFeedbacks = [];
24
+ (feedbacks || []).forEach((fb) => {
25
+ const from = idMap[fb.from];
26
+ const to = idMap[fb.to];
27
+ if (from && to) outFeedbacks.push({ id: `fb_${outFeedbacks.length}`, from, to });
28
+ });
29
+ return { steps: outSteps, feedbacks: outFeedbacks };
30
+ }
31
+
32
+ // metaLine(steps, feedbacks) -> "N steps · M agents[ · K feedback loop(s)]"
33
+ // (saved-pipelines card meta line). Mirrors the mockup's renderList meta string.
34
+ export function metaLine(steps, feedbacks) {
35
+ const nSteps = steps.length;
36
+ const nAgents = steps.reduce((sum, col) => sum + col.length, 0);
37
+ const nLoops = (feedbacks || []).length;
38
+ let s = `${nSteps} steps · ${nAgents} agents`;
39
+ if (nLoops) s += ` · ${nLoops} feedback loop${nLoops > 1 ? 's' : ''}`;
40
+ return s;
41
+ }
42
+
43
+ // distinctAgents(steps) -> ordered unique role keys (for the chip row).
44
+ export function distinctAgents(steps) {
45
+ const seen = [];
46
+ steps.forEach((col) => col.forEach((node) => {
47
+ if (!seen.includes(node.key)) seen.push(node.key);
48
+ }));
49
+ return seen;
50
+ }
51
+
52
+ // Embedded agent registry — fallback for the palette when /api/agents is
53
+ // unavailable (e.g. a sibling phase's endpoint not yet wired). Keys are the
54
+ // canonical camelCase agent keys; icon = inner SVG markup, viewBox "0 0 24 24"
55
+ // (glyphs copied from the standalone mockup's ICON map). The live registry from
56
+ // GET /api/agents overrides this whenever present (see mergePalette).
57
+ export const EMBEDDED_AGENTS = {
58
+ clarify: {
59
+ key: 'clarify', displayName: 'Clarify', description: 'Turns hidden decisions into questions before planning. Multiple-choice, so later steps never guess.',
60
+ color: 'red', order: 0, connectsTo: ['planner'],
61
+ icon: '<circle cx="12" cy="12" r="9"/><path d="M9.4 9.3a2.7 2.7 0 0 1 5.2 1c0 1.8-2.6 2.1-2.6 3.6" stroke-linecap="round" fill="none"/><circle cx="12" cy="17" r="0.7" fill="currentColor" stroke="none"/>',
62
+ },
63
+ planner: {
64
+ key: 'planner', displayName: 'Plan', description: 'Explores the codebase and writes the implementation plan. Architecture, task breakdown, concrete code snippets; can ask clarifying questions first.',
65
+ color: 'violet', order: 1, connectsTo: ['refiner', 'implementer', 'decomposer'],
66
+ icon: '<path d="M8 6h11M8 12h11M8 18h8" stroke-linecap="round"/><circle cx="4" cy="6" r="1.1"/><circle cx="4" cy="12" r="1.1"/><circle cx="4" cy="18" r="1.1"/>',
67
+ },
68
+ refiner: {
69
+ key: 'refiner', displayName: 'Refine Plan', description: 'Rewrites the latest plan into a tighter version. Fixes structure, correctness, and code snippets until no blocking issues remain.',
70
+ color: 'green', order: 2, connectsTo: ['implementer', 'refiner', 'decomposer'],
71
+ icon: '<path d="M12 3v3M12 18v3M4.5 7.5l2 1M17.5 15.5l2 1M4.5 16.5l2-1M17.5 8.5l2-1" stroke-linecap="round"/><path d="M12 8.2l1.2 2.6L16 12l-2.8 1.2L12 15.8l-1.2-2.6L8 12l2.8-1.2L12 8.2Z" stroke-linejoin="round"/>',
72
+ },
73
+ decomposer: {
74
+ key: 'decomposer', displayName: 'Decompose', description: 'Splits an approved plan into vertical-slice tasks. Each task gets its own implementer.',
75
+ color: 'blue', order: 2.5, connectsTo: ['implementer'],
76
+ },
77
+ implementer: {
78
+ key: 'implementer', displayName: 'Implementation', description: 'Writes the code from the approved plan, strict TDD. In fix mode, addresses only the issues a review flagged.',
79
+ color: 'peach', order: 3, connectsTo: ['reviewer', 'manualTestsChecklist'],
80
+ icon: '<path d="M9 8l-4 4 4 4M15 8l4 4-4 4" stroke-linecap="round" stroke-linejoin="round"/>',
81
+ },
82
+ reviewer: {
83
+ key: 'reviewer', displayName: 'Review Implementation', description: 'Reviews the implementation diff against the plan. Honest verdict; blocking findings loop back to the implementer.',
84
+ color: 'blue', order: 4, connectsTo: ['implementer', 'manualTestsChecklist'],
85
+ icon: '<path d="M12 3l7 3v5c0 4.4-3 7.6-7 9-4-1.4-7-4.6-7-9V6l7-3Z" stroke-linejoin="round"/><path d="M9 12l2 2 4-4" stroke-linecap="round" stroke-linejoin="round"/>',
86
+ },
87
+ manualTestsChecklist: {
88
+ key: 'manualTestsChecklist', displayName: 'Manual Tests Checklist', description: 'Drafts a manual test checklist for the change. User-visible flows, edge cases, regressions worth clicking through.',
89
+ color: 'blue', order: 5, connectsTo: ['manualWebUiTesting'],
90
+ icon: '<rect x="6" y="4" width="12" height="17" rx="2"/><path d="M9.5 4V2.8h5V4" stroke-linejoin="round"/><path d="M8.8 12l1.6 1.6L13.4 10" stroke-linecap="round" stroke-linejoin="round"/>',
91
+ },
92
+ manualWebUiTesting: {
93
+ key: 'manualWebUiTesting', displayName: 'Manual web UI testing', description: 'Runs the manual checklist in the live web UI via Playwright. Reports what passed, failed, or blocked.',
94
+ color: 'violet', order: 6, connectsTo: ['implementer'],
95
+ icon: '<circle cx="12" cy="12" r="9"/><path d="M10 8.5l5 3.5-5 3.5V8.5Z" fill="currentColor" stroke="none"/>',
96
+ },
97
+ planReviewer: {
98
+ key: 'planReviewer', displayName: 'Plan Review', description: 'Reviews the plan against the request and the codebase. Blocking issues bounce it back for a cold re-plan.',
99
+ color: 'amber', order: 7, connectsTo: ['planner', 'implementer', 'decomposer'],
100
+ icon: '<path d="M10.5 4a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13Z"/><path d="M15.5 15.5L21 21" stroke-linecap="round"/><path d="M7.6 10.3l2 2 3.3-3.6" stroke-linecap="round" stroke-linejoin="round"/>',
101
+ },
102
+ };
103
+
104
+ // mergePalette(agentsResponse) -> ordered Array<{key,displayName,description,color,icon,origin,order}>.
105
+ // Prefers the live registry (GET /api/agents -> { agents:[…] } or a bare array);
106
+ // falls back to EMBEDDED_AGENTS. Always sorted by .order so the palette is stable.
107
+ export function mergePalette(agentsResponse) {
108
+ let list = null;
109
+ if (Array.isArray(agentsResponse)) list = agentsResponse;
110
+ else if (agentsResponse && Array.isArray(agentsResponse.agents)) list = agentsResponse.agents;
111
+ if (!list || !list.length) list = Object.values(EMBEDDED_AGENTS);
112
+ return list
113
+ .map((a) => ({
114
+ key: a.key,
115
+ displayName: a.displayName || a.key,
116
+ description: a.description || '',
117
+ color: a.color || 'blue',
118
+ icon: a.icon || '',
119
+ // Trusted-icon gate: only 'user' is untrusted; the EMBEDDED_AGENTS
120
+ // fallback has no origin and is repo-shipped -> 'builtin' is correct.
121
+ origin: a.origin === 'user' ? 'user' : 'builtin',
122
+ order: typeof a.order === 'number' ? a.order : 99,
123
+ domain: typeof a.domain === 'string' && a.domain ? a.domain : 'general',
124
+ connectsTo: a.connectsTo === undefined ? '*' : a.connectsTo,
125
+ produces: Array.isArray(a.produces) ? a.produces : [],
126
+ consumes: Array.isArray(a.consumes) ? a.consumes : [],
127
+ optionalConsumes: Array.isArray(a.optionalConsumes) ? a.optionalConsumes : [],
128
+ }))
129
+ .sort((x, y) => x.order - y.order);
130
+ }
131
+
132
+ // groupPaletteByDomain(palette, domains) -> ordered [{domain, agents:[...]}].
133
+ // Each group = that domain's own agents PLUS every `shared` agent prepended,
134
+ // then sorted by .order. Pure: no DOM. `domains` is the ordered header list
135
+ // (general last, shared excluded) — see collectDomains / paletteDomains.
136
+ export function groupPaletteByDomain(palette, domains) {
137
+ const list = Array.isArray(palette) ? palette : [];
138
+ const shared = list.filter((a) => a.domain === 'shared');
139
+ const byOrder = (x, y) => x.order - y.order;
140
+ return (Array.isArray(domains) ? domains : []).map((domain) => ({
141
+ domain,
142
+ agents: [...shared, ...list.filter((a) => a.domain === domain)].sort(byOrder),
143
+ }));
144
+ }
145
+
146
+ // defaultTopologyFromTemplate(tpl, mk) -> canvas model {steps,feedbacks} with
147
+ // FRESH local ids (mk(key) -> {id,key}). The server template's instance ids
148
+ // (s*_*) are deliberately discarded: once on the canvas, nodes get throwaway
149
+ // local ids and topology() re-stamps contract ids on save. Feedback edges are
150
+ // rewired from server ids to the new local ids by walking the same order.
151
+ export function defaultTopologyFromTemplate(tpl, mk) {
152
+ if (!tpl || !Array.isArray(tpl.steps) || !tpl.steps.length) {
153
+ return { steps: [], feedbacks: [] };
154
+ }
155
+ const remap = {}; // serverId -> localId
156
+ const steps = tpl.steps.map((col) =>
157
+ col.map((node) => {
158
+ const local = mk(node.key);
159
+ remap[node.id] = local.id;
160
+ return local;
161
+ }),
162
+ );
163
+ const feedbacks = (tpl.feedbacks || [])
164
+ .filter((fb) => remap[fb.from] && remap[fb.to])
165
+ .map((fb) => ({ from: remap[fb.from], to: remap[fb.to] }));
166
+ return { steps, feedbacks };
167
+ }
168
+
169
+ // Channels a pipeline gets without any producing NODE adjacent on the canvas: the
170
+ // user prompt, the shared worktree, the frozen workspace snapshot, and the clarify
171
+ // pre-step. Module-local on purpose — this is NOT channels.mjs PRESEEDED_CHANNELS
172
+ // (that one lists the validator's value-bearing bus seeds and includes
173
+ // plan/checklist; here plan/checklist stay checkable so a producer-less consumer
174
+ // of real content still warns, while this no-build browser module keeps zero
175
+ // imports from src/core).
176
+ const SOFT_PRESEEDED = ['userPrompt', 'code', 'workspace', 'clarify'];
177
+
178
+ // canConnect(fromKey, toKey, agents) -> { ok, reason, warn? }.
179
+ // Governance (connectsTo) is the HARD gate, exactly as before — and an explicit
180
+ // allowlist that admits the target is treated as AUTHOR-CURATED: the soft channel
181
+ // check is skipped (e.g. decomposer -> implementer, where the plan flows through
182
+ // from upstream via the bus). Only wildcard links get the soft pairwise check:
183
+ // when the source produces nothing the target consumes AND the target's required
184
+ // inputs are not all pre-seeded, we return ok:true with a `warn` string the UI
185
+ // surfaces as a toast.
186
+ export function canConnect(fromKey, toKey, agents) {
187
+ const from = agents && agents[fromKey];
188
+ const ct = from ? from.connectsTo : '*';
189
+ if (Array.isArray(ct)) {
190
+ if (!ct.includes(toKey)) {
191
+ const fn = (from && from.displayName) || fromKey;
192
+ const tn = (agents[toKey] && agents[toKey].displayName) || toKey;
193
+ return { ok: false, reason: `${fn} can’t connect to ${tn}` };
194
+ }
195
+ return { ok: true, reason: '' }; // curated allowlist: author vetted this link
196
+ }
197
+ const to = agents && agents[toKey];
198
+ if (from && to && Array.isArray(from.produces) && Array.isArray(to.consumes)
199
+ && from.produces.length && to.consumes.length) {
200
+ const feeds = from.produces.some((c) => to.consumes.includes(c));
201
+ const required = to.consumes.filter((c) =>
202
+ !(Array.isArray(to.optionalConsumes) && to.optionalConsumes.includes(c)) &&
203
+ !SOFT_PRESEEDED.includes(c));
204
+ if (!feeds && required.length) {
205
+ const fn = from.displayName || fromKey;
206
+ const tn = to.displayName || toKey;
207
+ return { ok: true, reason: '', warn: `${fn} produces [${from.produces.join(', ')}] but ${tn} needs [${required.join(', ')}]` };
208
+ }
209
+ }
210
+ return { ok: true, reason: '' };
211
+ }
@@ -0,0 +1,244 @@
1
+ // ui/public/guardrails-view.mjs
2
+ // Pure DOM renderers for the Guardrails 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
+ // Interactive elements carry data-id / data-value / data-list + a routing class
7
+ // (grv-edit, grv-delete, grv-back, grv-save, grv-discard, gr-rm, gr-add-btn) so
8
+ // app.js wires ONE delegated listener on the list container.
9
+
10
+ function h(doc, tag, cls, text) {
11
+ const n = doc.createElement(tag);
12
+ if (cls) n.className = cls;
13
+ if (text != null) n.textContent = text;
14
+ return n;
15
+ }
16
+
17
+ // settings -> "2 deny · 1 paths · scrub on" (raw 5-key counts, NOT the Read/Edit expansion).
18
+ export function guardrailSummary(s) {
19
+ const n = (v) => (Array.isArray(v) ? v.length : 0);
20
+ return `${n(s && s.deny)} deny · ${n(s && s.protectedPaths)} paths · scrub ${s && s.envScrub ? 'on' : 'off'}`;
21
+ }
22
+
23
+ function originBadge(doc, origin) {
24
+ if (origin === 'builtin') return h(doc, 'span', 'badge waiting grv-origin', 'built-in');
25
+ if (typeof origin === 'string' && origin.startsWith('plugin:')) return h(doc, 'span', 'badge violet grv-origin', origin);
26
+ return h(doc, 'span', 'badge green grv-origin', 'user');
27
+ }
28
+
29
+ // renderGuardrailList(sets) -> <div.grv-list> of cards; built-ins get View + no Delete.
30
+ export function renderGuardrailList(sets, { doc = globalThis.document } = {}) {
31
+ const root = h(doc, 'div', 'grv-list');
32
+ for (const s of sets || []) {
33
+ const card = h(doc, 'section', 'card grv-card');
34
+ card.dataset.id = s.id;
35
+ const body = h(doc, 'div', 'grv-body');
36
+ const head = h(doc, 'div', 'grv-head');
37
+ head.appendChild(h(doc, 'b', 'grv-name', s.name));
38
+ head.appendChild(originBadge(doc, s.origin));
39
+ body.appendChild(head);
40
+ body.appendChild(h(doc, 'small', 'grv-summary hint', guardrailSummary(s.settings)));
41
+ card.appendChild(body);
42
+ if (s.origin !== 'builtin') {
43
+ const del = h(doc, 'button', 'btn-ghost grv-delete', 'Delete');
44
+ del.type = 'button';
45
+ del.dataset.id = s.id;
46
+ card.appendChild(del);
47
+ }
48
+ // Open affordance: a "Details" button on the card's right edge. Keeps the
49
+ // .grv-edit routing class so app.js's one delegated listener still opens
50
+ // the editor. Built-ins open a read-only view; user sets open for editing
51
+ // (the title reflects that; the visible label is the accessible name).
52
+ const open = h(doc, 'button', 'btn-ghost grv-edit grv-details', 'Details');
53
+ open.type = 'button';
54
+ open.dataset.id = s.id;
55
+ open.title = s.origin === 'builtin' ? 'View' : 'Edit';
56
+ card.appendChild(open);
57
+ root.appendChild(card);
58
+ }
59
+ if (!sets || !sets.length) {
60
+ root.appendChild(h(doc, 'div', 'hist-empty', 'No guardrail sets.'));
61
+ }
62
+ return root;
63
+ }
64
+
65
+ // Wizard Step 1: choose a starting point (Blank, a built-in, or a saved set).
66
+ // sources: [{id, name, origin}]. Returns detached DOM; app.js wires .grv-next/.grv-cancel.
67
+ export function renderStartStep(sources, { doc = globalThis.document, selectedId = '' } = {}) {
68
+ const root = h(doc, 'div', 'grv-wizard grv-step1');
69
+ root.appendChild(h(doc, 'p', 'hint', 'Choose a starting point'));
70
+ const list = h(doc, 'div', 'grv-source-list');
71
+ list.setAttribute('role', 'radiogroup');
72
+ list.setAttribute('aria-label', 'Starting point');
73
+ const addRow = (id, label, builtin) => {
74
+ const row = h(doc, 'label', 'grv-source-row');
75
+ const radio = h(doc, 'input', 'grv-source');
76
+ radio.type = 'radio';
77
+ radio.name = 'grv-source';
78
+ radio.value = id;
79
+ if (id === selectedId) radio.checked = true;
80
+ row.appendChild(radio);
81
+ row.appendChild(h(doc, 'span', 'grv-source-name', label));
82
+ if (builtin) row.appendChild(h(doc, 'span', 'badge waiting', 'built-in'));
83
+ list.appendChild(row);
84
+ };
85
+ addRow('', 'Blank (custom)', false);
86
+ for (const s of sources || []) addRow(s.id, s.name, s.origin === 'builtin');
87
+ root.appendChild(list);
88
+ const actions = h(doc, 'div', 'actions grv-wizard-actions');
89
+ const cancel = h(doc, 'button', 'btn btn-ghost btn-mini grv-cancel', 'Cancel');
90
+ cancel.type = 'button';
91
+ const next = h(doc, 'button', 'btn btn-primary btn-mini grv-next', 'Next →');
92
+ next.type = 'button';
93
+ actions.appendChild(cancel);
94
+ actions.appendChild(next);
95
+ root.appendChild(actions);
96
+ return root;
97
+ }
98
+
99
+ export function collectStartStep(rootEl) {
100
+ const sel = rootEl.querySelector('.grv-source:checked');
101
+ return sel ? sel.value : '';
102
+ }
103
+
104
+ function listEditor(doc, cls, entries, placeholder, readOnly = false) {
105
+ const wrap = h(doc, 'div');
106
+ const list = h(doc, 'div', `gr-list ${cls}`);
107
+ for (const v of entries || []) {
108
+ const row = h(doc, 'div', 'gr-row');
109
+ row.appendChild(h(doc, 'span', 'mono', v));
110
+ if (!readOnly) {
111
+ const rm = h(doc, 'button', 'gr-rm', '✕');
112
+ rm.type = 'button';
113
+ rm.dataset.value = v;
114
+ rm.title = 'Remove';
115
+ rm.setAttribute('aria-label', 'Remove');
116
+ row.appendChild(rm);
117
+ }
118
+ list.appendChild(row);
119
+ }
120
+ if (!entries || !entries.length) list.appendChild(h(doc, 'div', 'gr-empty', 'none'));
121
+ wrap.appendChild(list);
122
+ if (readOnly) return wrap; // no add-row in read-only view
123
+ const add = h(doc, 'div', 'path-row gr-add');
124
+ add.dataset.list = cls;
125
+ const input = h(doc, 'input', 'input');
126
+ input.type = 'text';
127
+ input.placeholder = placeholder;
128
+ input.spellcheck = false;
129
+ add.appendChild(input);
130
+ const btn = h(doc, 'button', 'btn btn-ghost btn-mini gr-add-btn', '+ add');
131
+ btn.type = 'button';
132
+ add.appendChild(btn);
133
+ wrap.appendChild(add);
134
+ return wrap;
135
+ }
136
+
137
+ function switchRow(doc, cls, on, label, readOnly = false) {
138
+ const row = h(doc, 'div', 'switch-row');
139
+ const sw = h(doc, 'div', `switch ${cls}${on ? ' on' : ''}${readOnly ? ' disabled' : ''}`);
140
+ sw.setAttribute('role', 'switch');
141
+ sw.setAttribute('aria-checked', String(!!on));
142
+ if (readOnly) sw.setAttribute('aria-disabled', 'true');
143
+ else sw.tabIndex = 0;
144
+ row.appendChild(sw);
145
+ row.appendChild(h(doc, 'span', 'txt', label));
146
+ return row;
147
+ }
148
+
149
+ function field(doc, label, child) {
150
+ const f = h(doc, 'div', 'field');
151
+ f.appendChild(h(doc, 'label', null, label));
152
+ f.appendChild(child);
153
+ return f;
154
+ }
155
+
156
+ // renderGuardrailEditor(set, {mode}) -> detached editor card (wizard Step 2).
157
+ // mode: 'create' (Back-to-step-1 + "Create set") | 'edit' ("Save") | 'view'
158
+ // (read-only built-in + "Save as new set"). app.js owns state; mutations re-render.
159
+ export function renderGuardrailEditor(set, { doc = globalThis.document, mode = 'edit', dirty = false, msg = '', msgErr = false } = {}) {
160
+ const s = set.settings || {};
161
+ const readOnly = mode === 'view';
162
+ const root = h(doc, 'section', 'card grv-editor');
163
+ root.dataset.id = set.id || '';
164
+ root.dataset.mode = mode;
165
+ const head = h(doc, 'div', 'grv-head');
166
+ if (mode === 'create') {
167
+ const back = h(doc, 'button', 'btn btn-mini grv-back', '← Back');
168
+ back.type = 'button';
169
+ head.appendChild(back);
170
+ }
171
+ if (readOnly) {
172
+ head.appendChild(h(doc, 'b', 'grv-name', set.name));
173
+ head.appendChild(h(doc, 'span', 'badge waiting grv-origin', 'built-in'));
174
+ } else {
175
+ const name = h(doc, 'input', 'input grv-name-input');
176
+ name.type = 'text';
177
+ name.value = set.name || '';
178
+ name.placeholder = 'Set name';
179
+ name.spellcheck = false;
180
+ head.appendChild(name);
181
+ }
182
+ root.appendChild(head);
183
+ root.appendChild(switchRow(doc, 'gr-honor', s.honorProjectSettings, 'Honor project .claude/settings.json', readOnly));
184
+ root.appendChild(switchRow(doc, 'gr-scrub', s.envScrub, 'Scrub environment on agent spawn', readOnly));
185
+ root.appendChild(field(doc, 'Env allowlist (passed through when scrub is on)',
186
+ listEditor(doc, 'gr-allow', s.envAllowlist, 'NPM_TOKEN', readOnly)));
187
+ root.appendChild(field(doc, 'Protected paths (denies Read/Edit)',
188
+ listEditor(doc, 'gr-paths', s.protectedPaths, '.env* | **/secrets/**', readOnly)));
189
+ root.appendChild(field(doc, 'Deny rules (pairs: Bash(git push) + Bash(git push:*))',
190
+ listEditor(doc, 'gr-deny', s.deny, 'Bash(curl:*)', readOnly)));
191
+ const msgEl = h(doc, 'p', `hint grv-msg${msgErr ? ' err' : ''}`, msg || '');
192
+ msgEl.setAttribute('role', 'status');
193
+ msgEl.setAttribute('aria-live', 'polite');
194
+ root.appendChild(msgEl);
195
+ const actions = h(doc, 'div', 'actions grv-editor-actions');
196
+ if (!readOnly) {
197
+ const discard = h(doc, 'button', 'btn btn-ghost btn-mini grv-discard', 'Discard');
198
+ discard.type = 'button';
199
+ discard.disabled = !dirty;
200
+ actions.appendChild(discard);
201
+ }
202
+ const save = h(doc, 'button', 'btn btn-primary btn-mini grv-save',
203
+ mode === 'create' ? 'Create set' : (mode === 'view' ? 'Save as new set' : 'Save'));
204
+ save.type = 'button';
205
+ save.disabled = readOnly ? false : !dirty;
206
+ actions.appendChild(save);
207
+ root.appendChild(actions);
208
+ return root;
209
+ }
210
+
211
+ // The inverse of renderGuardrailEditor: read the live DOM back into {name, settings}.
212
+ export function collectGuardrailEditor(rootEl) {
213
+ const nameInput = rootEl.querySelector('.grv-name-input');
214
+ const nameEl = rootEl.querySelector('.grv-name');
215
+ const listVals = (cls) =>
216
+ [...rootEl.querySelectorAll(`.gr-list.${cls} .gr-row .mono`)].map((n) => n.textContent);
217
+ return {
218
+ name: nameInput ? nameInput.value.trim() : ((nameEl && nameEl.textContent) || ''),
219
+ settings: {
220
+ honorProjectSettings: !!(rootEl.querySelector('.gr-honor') || {}).classList?.contains('on'),
221
+ envScrub: !!(rootEl.querySelector('.gr-scrub') || {}).classList?.contains('on'),
222
+ envAllowlist: listVals('gr-allow'),
223
+ protectedPaths: listVals('gr-paths'),
224
+ deny: listVals('gr-deny'),
225
+ },
226
+ };
227
+ }
228
+
229
+ // 409 body: who still pins the set ([{id, referencedBy: string[]}] flattened —
230
+ // referencedBy entries are "pipeline <id>" resume-point pins).
231
+ export function renderGuardrailReferences409(references, { doc = globalThis.document } = {}) {
232
+ const root = h(doc, 'div', 'grv-refs409');
233
+ root.appendChild(h(doc, 'p', 'hint err', 'Cannot delete: still referenced by'));
234
+ const list = h(doc, 'div', 'gr-list');
235
+ for (const r of references || []) {
236
+ for (const by of (r && Array.isArray(r.referencedBy)) ? r.referencedBy : []) {
237
+ const row = h(doc, 'div', 'gr-row');
238
+ row.appendChild(h(doc, 'span', 'mono', by));
239
+ list.appendChild(row);
240
+ }
241
+ }
242
+ root.appendChild(list);
243
+ return root;
244
+ }