@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,282 @@
1
+ // ui/public/script-forms.mjs
2
+ // The two forms a script's ports and params are edited with (scripts-workbench
3
+ // design §5.3, C3): the params form and the port editor. ONE implementation, used
4
+ // by the composer's inspector (a placed card) and the bench's setup (a request). Every function takes the target
5
+ // `document` via opts and returns DETACHED DOM — no fetch, no listeners outside
6
+ // the returned tree (the memory-view.mjs / plugins-view.mjs posture); the host
7
+ // binds ONE delegated listener and routes on data-field / data-port-* / data-pdef-*.
8
+ //
9
+ // The field NAMES are the contract: `param:<id>`, `port:<dir>:<i>:<field>`,
10
+ // `data-port-add`, `data-port-remove` are exactly P1b's, so the composer's
11
+ // routing and its tests hold after the move.
12
+ import { effectiveScriptParams, paramValueError, readConfigPorts } from '../../src/shared/graph/script-meta.mjs';
13
+ import { createCodeEditor } from './code-editor.mjs';
14
+
15
+ // ---- DOM primitives (moved out of graph/inspector.mjs; it imports them) -----
16
+
17
+ export const h = (doc, tag, cls, text) => { const n = doc.createElement(tag); if (cls) n.className = cls; if (text != null) n.textContent = text; return n; };
18
+ export const field = (doc, cls, label) => { const w = h(doc, 'div', `ins-f ${cls}`); w.appendChild(h(doc, 'label', 'ins-label', label)); return w; };
19
+
20
+ export function select(doc, cls, name, label, items, value, { disabled = false, title = '' } = {}) {
21
+ const wrap = field(doc, cls, label);
22
+ if (title) wrap.title = title;
23
+ const sel = h(doc, 'select', 'ins-select');
24
+ sel.dataset.field = name;
25
+ sel.disabled = Boolean(disabled);
26
+ for (const opt of items) {
27
+ const o = doc.createElement('option');
28
+ o.value = opt.value; o.textContent = opt.text;
29
+ if (opt.value === (value == null ? '' : String(value))) o.selected = true;
30
+ sel.appendChild(o);
31
+ }
32
+ const shell = h(doc, 'span', 'ins-select-wrap'); // the product's .select-wrap idea: the chevron is a token-coloured ::after on a wrapper
33
+ shell.appendChild(sel);
34
+ wrap.appendChild(shell);
35
+ return wrap;
36
+ }
37
+
38
+ export function toggle(doc, cls, name, label, hint, { checked = false, disabled = false, title = '' } = {}) {
39
+ const row = h(doc, 'div', `ins-tog ${cls}`);
40
+ if (title) row.title = title;
41
+ const box = doc.createElement('input');
42
+ box.type = 'checkbox'; box.dataset.field = name; box.checked = Boolean(checked); box.disabled = Boolean(disabled);
43
+ const body = h(doc, 'span', 'ins-tog-b');
44
+ body.appendChild(h(doc, 'span', 'ins-tog-t', label));
45
+ if (hint) body.appendChild(h(doc, 'small', 'ins-tog-h', hint));
46
+ row.append(box, body);
47
+ return row;
48
+ }
49
+
50
+ export function number(doc, cls, name, label, value, min) {
51
+ const wrap = field(doc, cls, label);
52
+ const input = doc.createElement('input');
53
+ input.type = 'number'; input.className = 'ins-number'; input.dataset.field = name;
54
+ input.min = String(min); input.step = '1'; input.value = String(value);
55
+ wrap.appendChild(input);
56
+ return wrap;
57
+ }
58
+
59
+ export function text(doc, cls, name, label, value, { type = 'text', step = null } = {}) {
60
+ const wrap = field(doc, cls, label);
61
+ const input = doc.createElement('input');
62
+ input.type = type; input.className = 'ins-number'; input.dataset.field = name; input.value = value == null ? '' : String(value);
63
+ if (step) input.step = step;
64
+ wrap.appendChild(input);
65
+ return wrap;
66
+ }
67
+
68
+ export function textarea(doc, cls, name, label, value, rows) {
69
+ const wrap = field(doc, cls, label);
70
+ const ta = doc.createElement('textarea');
71
+ ta.className = 'ins-textarea mono'; ta.dataset.field = name; ta.rows = rows; ta.spellcheck = false; ta.value = value == null ? '' : String(value);
72
+ wrap.appendChild(ta);
73
+ return wrap;
74
+ }
75
+
76
+ // ---- params -----------------------------------------------------------------
77
+
78
+ /** The ONE sentence the Scripts page carries (D23 / spec §5): no other prose. */
79
+ export const PARAMS_CAPTION = "Runs with worca's privileges.";
80
+
81
+ /** A param's AUTHORING language -> the hljs grammar id the editor loads. It lives
82
+ * here, not in scripts-view.mjs: that module's EDITOR_LANGUAGE is keyed by
83
+ * RUNTIME (there is no `js` runtime), and the composer must not import the whole
84
+ * Scripts page to colour one textarea. */
85
+ export const PARAM_EDITOR_LANGUAGE = Object.freeze({ command: 'bash', js: 'javascript', python: 'python' });
86
+
87
+ export function paramEditorLanguage(param) {
88
+ if (param && param.type === 'command') return 'bash';
89
+ return PARAM_EDITOR_LANGUAGE[String((param && param.language) || '')] || 'javascript';
90
+ }
91
+
92
+ /** renderParamsForm's `editorFor`, built once and shared by the composer's
93
+ * inspector and the bench's setup column so a `code` param looks and behaves the
94
+ * same in both. Handles are pushed onto `editors`; the CALLER destroys them
95
+ * before the tree goes (a pending highlight debounce on a detached node is a
96
+ * leak). No `highlight` means no editor: the plain textarea stays. */
97
+ export function paramEditorHook({ doc = globalThis.document, highlight = null, editors = [] } = {}) {
98
+ if (typeof highlight !== 'function') return null;
99
+ return (param, value) => {
100
+ const ed = createCodeEditor({
101
+ doc,
102
+ value: value == null ? '' : String(value),
103
+ name: `param:${param.id}`,
104
+ language: paramEditorLanguage(param),
105
+ rows: param.type === 'code' ? 8 : 3,
106
+ highlight,
107
+ });
108
+ editors.push(ed);
109
+ return ed.el;
110
+ };
111
+ }
112
+
113
+ /** One control per sidecar param (base spec §10.3). The EFFECTIVE value (sidecar
114
+ * default ⊕ card) is what the control shows; a required param with neither is
115
+ * flagged `.ins-missing` (V22's `incomplete`). `editorFor` lets a host swap the
116
+ * command/code textarea for a richer editor (Task 12's code-editor.mjs); the
117
+ * element it returns must carry the data-field control itself. */
118
+ export function renderParamsForm(meta, config, { doc = globalThis.document, editorFor = null } = {}) {
119
+ const frag = doc.createDocumentFragment();
120
+ const declared = Array.isArray(meta && meta.params) ? meta.params : [];
121
+ if (!declared.length) return frag;
122
+ frag.appendChild(h(doc, 'div', 'ins-zone', 'Params'));
123
+ const values = effectiveScriptParams(meta, config || {});
124
+ let captioned = false;
125
+ for (const p of declared) {
126
+ const name = `param:${p.id}`;
127
+ const label = p.label || p.id;
128
+ const value = values[p.id];
129
+ const isCode = p.type === 'command' || p.type === 'code';
130
+ let row;
131
+ if (p.type === 'boolean') row = toggle(doc, 'ins-param', name, label, p.description || '', { checked: value === true });
132
+ // No effective value: a blank first option, so the control never shows a choice the config does not hold.
133
+ else if (p.type === 'enum') row = select(doc, 'ins-param', name, label, [...(value == null ? [{ value: '', text: '' }] : []), ...(p.options || []).map((o) => ({ value: o, text: o }))], value == null ? '' : value);
134
+ else if (p.type === 'number') row = text(doc, 'ins-param', name, label, value, { type: 'number', step: 'any' });
135
+ else if (isCode) row = textarea(doc, 'ins-param ins-param-code', name, label, value, p.type === 'code' ? 8 : 3);
136
+ else row = text(doc, 'ins-param', name, label, value);
137
+ if (isCode && typeof editorFor === 'function') {
138
+ const custom = editorFor(p, value, { doc });
139
+ if (custom) row.replaceChild(custom, row.querySelector('textarea'));
140
+ }
141
+ if (p.required && (value === undefined || value === '')) row.classList.add('ins-missing');
142
+ if (p.description && p.type !== 'boolean') row.title = p.description;
143
+ frag.appendChild(row);
144
+ if (isCode && !captioned) {
145
+ frag.appendChild(h(doc, 'small', 'ins-caption', PARAMS_CAPTION));
146
+ captioned = true;
147
+ }
148
+ }
149
+ return frag;
150
+ }
151
+
152
+ /** Read a rendered params form back. `paramValueError` (V22's own check) is the
153
+ * authority for a typed value; the required-and-blank sentence below is this
154
+ * form's instant feedback, and the server's validator stays the gate. */
155
+ export function collectParams(root, meta) {
156
+ const declared = Array.isArray(meta && meta.params) ? meta.params : [];
157
+ const values = {};
158
+ const errors = [];
159
+ for (const p of declared) {
160
+ const node = root.querySelector(`[data-field="param:${p.id}"]`);
161
+ if (!node) continue;
162
+ let v;
163
+ if (p.type === 'boolean') v = Boolean(node.checked);
164
+ else if (p.type === 'number') { const n = Number(node.value); v = node.value === '' || !Number.isFinite(n) ? undefined : n; }
165
+ // A command or code value of blanks alone is no value: the runner trims it away,
166
+ // so this form must see it as missing — exactly the composer's coerceParam rule.
167
+ else if (p.type === 'command' || p.type === 'code') v = node.value.trim() === '' ? undefined : node.value;
168
+ else v = node.value === '' ? undefined : node.value;
169
+ if (v === undefined) {
170
+ if (p.required && (p.default === undefined || p.default === '')) errors.push(`${p.label || p.id} is required.`);
171
+ continue;
172
+ }
173
+ values[p.id] = v;
174
+ const why = paramValueError(p, v);
175
+ if (why) errors.push(why);
176
+ }
177
+ return { values, errors };
178
+ }
179
+
180
+ // ---- ports ------------------------------------------------------------------
181
+
182
+ /** The port editor for a `ports: "config"` script (D14) and for the Scripts
183
+ * page's sidecar form: one row per port, the shared reader's errors under it.
184
+ * `readOnly` OMITS the buttons rather than hiding them — an author `display`
185
+ * rule beats the `hidden` attribute, and a "disabled" Add nobody can see is
186
+ * worse than no Add. */
187
+ export function renderPortEditor(rawPorts, { doc = globalThis.document, hasVerdict = false, readOnly = false } = {}) {
188
+ const wrap = h(doc, 'div', 'ins-ports ins-port-editor');
189
+ const raw = rawPorts && typeof rawPorts === 'object' ? rawPorts : { inputs: [], outputs: [] };
190
+ const { errors } = readConfigPorts(raw, { hasVerdict });
191
+ const sel = (name, items, value, opts) => {
192
+ const s = select(doc, 'ins-pf', name, '', items.map((v) => ({ value: v, text: v })), value, opts);
193
+ s.querySelector('label').remove();
194
+ return s;
195
+ };
196
+ const check = (name, label, checked) => {
197
+ const l = h(doc, 'label', 'ins-pcheck');
198
+ const box = doc.createElement('input'); box.type = 'checkbox'; box.dataset.field = name; box.checked = Boolean(checked); box.disabled = readOnly;
199
+ l.append(box, doc.createTextNode(label));
200
+ return l;
201
+ };
202
+ const zone = (label, dir, list) => {
203
+ wrap.appendChild(h(doc, 'div', 'ins-zone', label));
204
+ list.forEach((p, i) => {
205
+ const row = h(doc, 'div', 'ins-prow');
206
+ row.dataset.dir = dir; row.dataset.index = String(i);
207
+ const id = doc.createElement('input');
208
+ id.type = 'text'; id.className = 'ins-pid mono'; id.dataset.field = `port:${dir}:${i}:id`; id.value = p.id || ''; id.placeholder = 'id'; id.disabled = readOnly;
209
+ row.append(id, sel(`port:${dir}:${i}:type`, ['md', 'json', 'void'], p.type || 'md', readOnly ? { disabled: true } : {}));
210
+ if (dir === 'inputs') {
211
+ row.append(check(`port:${dir}:${i}:required`, 'required', p.required !== false), check(`port:${dir}:${i}:loop`, 'loop', p.loop === true));
212
+ } else {
213
+ row.appendChild(sel(`port:${dir}:${i}:when`, ['always', 'blocking', 'clean'], p.when || 'always',
214
+ hasVerdict ? (readOnly ? { disabled: true } : {}) : { disabled: true, title: 'needs a sidecar verdict' }));
215
+ const fn = doc.createElement('input');
216
+ fn.type = 'text'; fn.className = 'ins-pfile mono'; fn.dataset.field = `port:${dir}:${i}:filename`; fn.value = p.filename || ''; fn.placeholder = 'name-cycle{cycle}.md';
217
+ fn.hidden = p.type === 'void';
218
+ fn.disabled = readOnly;
219
+ row.appendChild(fn);
220
+ }
221
+ if (!readOnly) {
222
+ const rm = h(doc, 'button', 'ins-prm', '×');
223
+ rm.type = 'button'; rm.dataset.portRemove = `${dir}:${i}`; rm.title = 'Remove';
224
+ row.appendChild(rm);
225
+ }
226
+ wrap.appendChild(row);
227
+ });
228
+ if (readOnly) return;
229
+ const add = h(doc, 'button', 'ins-padd', dir === 'inputs' ? '+ input' : '+ output');
230
+ add.type = 'button'; add.dataset.portAdd = dir;
231
+ wrap.appendChild(add);
232
+ };
233
+ zone('Inputs', 'inputs', Array.isArray(raw.inputs) ? raw.inputs : []);
234
+ zone('Outputs', 'outputs', Array.isArray(raw.outputs) ? raw.outputs : []);
235
+ for (const e of errors) wrap.appendChild(h(doc, 'div', 'ins-perr', e));
236
+ return wrap;
237
+ }
238
+
239
+ /** Read a rendered port editor back, with P1b's storage rules so a round trip
240
+ * through the UI never changes a stored graph. */
241
+ export function collectPorts(root) {
242
+ const out = { inputs: [], outputs: [] };
243
+ for (const dir of ['inputs', 'outputs']) {
244
+ const rows = [...root.querySelectorAll(`.ins-prow[data-dir="${dir}"]`)]
245
+ .sort((a, b) => Number(a.dataset.index) - Number(b.dataset.index));
246
+ for (const row of rows) {
247
+ const val = (f) => { const n = row.querySelector(`[data-field$=":${f}"]`); return n ? n.value : ''; };
248
+ const on = (f) => { const n = row.querySelector(`[data-field$=":${f}"]`); return !!(n && n.checked); };
249
+ const p = { id: val('id'), type: val('type') || 'md' };
250
+ if (dir === 'inputs') {
251
+ p.required = on('required');
252
+ if (on('loop')) p.loop = true;
253
+ } else {
254
+ const when = val('when');
255
+ if (when) p.when = when;
256
+ const filename = val('filename');
257
+ if (filename && p.type !== 'void') p.filename = filename;
258
+ }
259
+ out[dir].push(p);
260
+ }
261
+ }
262
+ return out;
263
+ }
264
+
265
+ const nextPortId = (list, base) => { let i = 0; for (;;) { const id = i ? `${base}${i + 1}` : base; if (!list.some((p) => p.id === id)) return id; i += 1; } };
266
+
267
+ /** Add or remove one port. Pure: a NEW object with one-level-deep clones, so a
268
+ * commit never mutates the undo ring's copy (the composer's clonePorts rule). */
269
+ export function applyPortEdit(rawPorts, { add = '', remove = '' } = {}) {
270
+ const raw = rawPorts && typeof rawPorts === 'object' ? rawPorts : {};
271
+ const ports = {
272
+ inputs: (Array.isArray(raw.inputs) ? raw.inputs : []).map((p) => ({ ...p })),
273
+ outputs: (Array.isArray(raw.outputs) ? raw.outputs : []).map((p) => ({ ...p })),
274
+ };
275
+ if (add === 'inputs') ports.inputs.push({ id: nextPortId(ports.inputs, 'in'), type: 'md', required: false });
276
+ else if (add === 'outputs') { const id = nextPortId(ports.outputs, 'out'); ports.outputs.push({ id, type: 'md', when: 'always', filename: `${id}-cycle{cycle}.md` }); }
277
+ if (remove) {
278
+ const [dir, idx] = String(remove).split(':');
279
+ if (ports[dir] && Number.isInteger(Number(idx)) && ports[dir][Number(idx)]) ports[dir].splice(Number(idx), 1);
280
+ }
281
+ return ports;
282
+ }