@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
@@ -91,3 +91,27 @@ export function collectChatSettings(root) {
91
91
  }
92
92
  return { notify, channels };
93
93
  }
94
+
95
+ /**
96
+ * The Settings → Ask Worca "Create and run scripts" row (scripts-workbench W20). Lives here
97
+ * with the other chat-pref controls because the value is one of `chatPrefs()`; it is mounted
98
+ * in the Ask Worca card, not in Chat notifications, and saves with that card's Save button.
99
+ * Default ON: only a stored `false` switches the chat's save_script / test_script off.
100
+ */
101
+ export function renderScriptToolsToggle({ prefs } = {}, { doc = globalThis.document } = {}) {
102
+ const row = h(doc, 'label', 'check-row');
103
+ row.setAttribute('for', 'askScriptTools');
104
+ const cb = h(doc, 'input', 'ask-script-tools');
105
+ cb.type = 'checkbox';
106
+ cb.id = 'askScriptTools';
107
+ cb.checked = prefs?.scriptTools !== false;
108
+ row.appendChild(cb);
109
+ row.appendChild(doc.createTextNode(' Create and run scripts'));
110
+ return row;
111
+ }
112
+
113
+ /** collectScriptToolsToggle(root) -> the POST /api/settings {chat} patch of the Ask Worca card. */
114
+ export function collectScriptToolsToggle(root) {
115
+ const cb = root && typeof root.querySelector === 'function' ? root.querySelector('input.ask-script-tools') : null;
116
+ return { scriptTools: cb ? cb.checked : true };
117
+ }
@@ -0,0 +1,181 @@
1
+ // ui/public/code-editor.mjs
2
+ // The app's one code editor (scripts-workbench design §W16): a transparent
3
+ // <textarea> laid over a highlighted <pre><code>. The browser keeps the caret,
4
+ // IME and selection of a plain textarea — and its undo ring for TYPED text (a Tab
5
+ // indent assigns `.value`, which resets that ring: the price of no dependency);
6
+ // the colours are painted underneath. No contenteditable, no dependency, no build step.
7
+ //
8
+ // The highlighter is INJECTED (C11): app.js passes one built on the vendored
9
+ // hljs loader, tests pass an escaping stub, and the DEFAULT escapes HTML — so a
10
+ // script's own source can never reach innerHTML unescaped. That injected
11
+ // function is the ONLY thing in this module allowed near innerHTML.
12
+ //
13
+ // The textarea keeps the caller's `name` as data-field, so an editor dropped
14
+ // into the composer's inspector routes through the same delegated change
15
+ // listener a plain textarea did (P1b Task 6).
16
+
17
+ const ESCAPES = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
18
+
19
+ /** Escape the five HTML metacharacters. Pure; the default highlighter. */
20
+ export function escapeHtml(s) {
21
+ return String(s ?? '').replace(/[&<>"']/g, (c) => ESCAPES[c]);
22
+ }
23
+
24
+ // Trailing-edge: a burst of keystrokes costs ONE highlight pass. 60 ms is below
25
+ // the threshold where a repaint reads as lag and well above a fast typist's
26
+ // inter-key gap.
27
+ export const HIGHLIGHT_DEBOUNCE_MS = 60;
28
+ // Two spaces, matching the repo's own indentation; a literal tab would render at
29
+ // the browser's default width and fight the highlighted layer's alignment.
30
+ export const TAB_SPACES = ' ';
31
+
32
+ /**
33
+ * @param {object} opts
34
+ * @param {Document} [opts.doc]
35
+ * @param {string} [opts.value] initial text
36
+ * @param {string} [opts.language] hljs language id, mirrored on data-language
37
+ * @param {boolean} [opts.readOnly]
38
+ * @param {number} [opts.rows]
39
+ * @param {string} [opts.name] data-field on the textarea ('' = none)
40
+ * @param {?function} [opts.onInput] called with the new text on every keystroke
41
+ * @param {function} [opts.highlight] (text, language) -> Promise<string html>
42
+ * @returns {{el:HTMLElement, getValue:()=>string, setValue:(v:string)=>void,
43
+ * setLanguage:(l:string)=>void, setReadOnly:(b:boolean)=>void,
44
+ * focus:()=>void, destroy:()=>void}}
45
+ */
46
+ export function createCodeEditor({
47
+ doc = globalThis.document, value = '', language = 'javascript', readOnly = false,
48
+ rows = 18, name = '', onInput = null, highlight = async (text) => escapeHtml(text),
49
+ } = {}) {
50
+ const win = doc.defaultView || globalThis;
51
+ let lang = String(language || '');
52
+ let seq = 0; // paint token: only the newest result may be applied
53
+ let timer = null;
54
+ let dead = false;
55
+
56
+ const el = doc.createElement('div');
57
+ el.className = `code-editor${readOnly ? ' ro' : ''}`;
58
+ el.dataset.language = lang;
59
+
60
+ const pre = doc.createElement('pre');
61
+ pre.className = 'code-editor-hl';
62
+ pre.setAttribute('aria-hidden', 'true'); // the textarea is the accessible control
63
+ const code = doc.createElement('code');
64
+ pre.appendChild(code);
65
+
66
+ const ta = doc.createElement('textarea');
67
+ ta.className = 'code-editor-ta mono';
68
+ ta.spellcheck = false;
69
+ ta.rows = rows;
70
+ ta.readOnly = Boolean(readOnly);
71
+ ta.value = String(value ?? '');
72
+ if (name) ta.dataset.field = name;
73
+ el.append(pre, ta);
74
+
75
+ /** The one innerHTML write. `html` comes from the injected highlighter (or the
76
+ * escaping default); anything that is not a string falls back to escaped text.
77
+ * The extra '\n' keeps the last row visible — <pre> swallows one trailing
78
+ * newline, which would otherwise scroll the layers out of step. */
79
+ function apply(html, text) {
80
+ code.innerHTML = (typeof html === 'string' ? html : escapeHtml(text)) + '\n';
81
+ }
82
+
83
+ async function paint() {
84
+ const my = ++seq;
85
+ const text = ta.value;
86
+ const at = lang;
87
+ let html = null;
88
+ try { html = await highlight(text, at); } catch { html = null; }
89
+ if (dead || my !== seq) return; // destroyed, or a newer paint won
90
+ apply(html, text);
91
+ }
92
+
93
+ function schedule() {
94
+ if (timer != null) win.clearTimeout(timer);
95
+ timer = win.setTimeout(() => { timer = null; void paint(); }, HIGHLIGHT_DEBOUNCE_MS);
96
+ }
97
+
98
+ function onInputEvent() {
99
+ if (typeof onInput === 'function') onInput(ta.value);
100
+ schedule();
101
+ }
102
+
103
+ function onScroll() {
104
+ pre.scrollTop = ta.scrollTop;
105
+ pre.scrollLeft = ta.scrollLeft;
106
+ }
107
+
108
+ // Escape arms the NEXT Tab to move focus instead of indenting. The textarea is
109
+ // the accessible control here (the <pre> is aria-hidden), and an indent handler
110
+ // that swallows every Tab and Shift+Tab would make it a keyboard trap — no
111
+ // keyboard user could ever leave it. Escape-then-Tab is the editor convention.
112
+ let tabOut = false;
113
+ /** Leaving the field cancels a pending Escape: coming back and pressing Tab must
114
+ * indent again, the way it does on a field nobody armed. */
115
+ function onBlur() { tabOut = false; }
116
+
117
+ /** Tab / Shift+Tab. A caret with no multi-line selection inserts two spaces;
118
+ * anything spanning a newline (and every Shift+Tab) re-indents whole lines,
119
+ * which is what an editor user expects from a code field. Read-only lets Tab
120
+ * through so focus still moves on. */
121
+ function onKeyDown(ev) {
122
+ if (ev.key === 'Escape') { tabOut = true; return; }
123
+ const armed = tabOut;
124
+ tabOut = false;
125
+ if (ev.key !== 'Tab' || ev.ctrlKey || ev.metaKey || ev.altKey || ta.readOnly || armed) return;
126
+ const v = ta.value;
127
+ const start = ta.selectionStart;
128
+ const end = ta.selectionEnd;
129
+ ev.preventDefault();
130
+ if (!ev.shiftKey && !v.slice(start, end).includes('\n')) {
131
+ ta.value = v.slice(0, start) + TAB_SPACES + v.slice(end);
132
+ ta.selectionStart = start + TAB_SPACES.length;
133
+ ta.selectionEnd = ta.selectionStart;
134
+ ta.dispatchEvent(new win.Event('input', { bubbles: true }));
135
+ return;
136
+ }
137
+ const lineStart = v.lastIndexOf('\n', start - 1) + 1;
138
+ const nl = v.indexOf('\n', end);
139
+ const blockEnd = nl === -1 ? v.length : nl;
140
+ let first = 0;
141
+ let total = 0;
142
+ const lines = v.slice(lineStart, blockEnd).split('\n').map((line, i) => {
143
+ if (ev.shiftKey) {
144
+ const cut = line.startsWith(TAB_SPACES) ? TAB_SPACES.length : (line.startsWith(' ') ? 1 : 0);
145
+ if (i === 0) first = -cut;
146
+ total -= cut;
147
+ return line.slice(cut);
148
+ }
149
+ if (i === 0) first = TAB_SPACES.length;
150
+ total += TAB_SPACES.length;
151
+ return TAB_SPACES + line;
152
+ });
153
+ ta.value = v.slice(0, lineStart) + lines.join('\n') + v.slice(blockEnd);
154
+ ta.selectionStart = Math.max(lineStart, start + first);
155
+ ta.selectionEnd = Math.max(ta.selectionStart, end + total);
156
+ ta.dispatchEvent(new win.Event('input', { bubbles: true }));
157
+ }
158
+
159
+ ta.addEventListener('input', onInputEvent);
160
+ ta.addEventListener('scroll', onScroll);
161
+ ta.addEventListener('keydown', onKeyDown);
162
+ ta.addEventListener('blur', onBlur);
163
+ void paint();
164
+
165
+ return {
166
+ el,
167
+ getValue: () => ta.value,
168
+ setValue(v) { ta.value = String(v ?? ''); void paint(); },
169
+ setLanguage(l) { lang = String(l || ''); el.dataset.language = lang; void paint(); },
170
+ setReadOnly(b) { ta.readOnly = Boolean(b); el.classList.toggle('ro', Boolean(b)); },
171
+ focus() { if (typeof ta.focus === 'function') ta.focus(); },
172
+ destroy() {
173
+ dead = true;
174
+ if (timer != null) { win.clearTimeout(timer); timer = null; }
175
+ ta.removeEventListener('input', onInputEvent);
176
+ ta.removeEventListener('scroll', onScroll);
177
+ ta.removeEventListener('keydown', onKeyDown);
178
+ ta.removeEventListener('blur', onBlur);
179
+ },
180
+ };
181
+ }
@@ -13,12 +13,19 @@ export const GETTING_STARTED_STEPS = Object.freeze([
13
13
  { id: 'project', label: 'Add your first project', vig: 'project' },
14
14
  { id: 'run', label: 'Watch a run end to end', vig: 'run' },
15
15
  { id: 'ask', label: 'Ask Worca about a run', vig: 'ask' },
16
- { id: 'workflows', label: 'Explore the built-in workflows', vig: 'workflow' },
17
16
  { id: 'realRun', label: 'Run a real pipeline', vig: 'realRun' },
18
- { id: 'workspace', label: 'Group projects into a workspace', vig: 'workspace' },
19
- { id: 'teamMetrics', label: 'Turn on team metrics', vig: 'teamMetrics' },
17
+ { id: 'workflows', label: 'Explore the built-in workflows', vig: 'workflow', level: 'advanced' },
18
+ { id: 'workspace', label: 'Group projects into a workspace', vig: 'workspace', level: 'advanced' },
19
+ { id: 'teamMetrics', label: 'Turn on team metrics', vig: 'teamMetrics', level: 'expert' },
20
+ { id: 'teamPolicy', label: 'Set a team policy', vig: 'teamPolicy', level: 'expert' },
20
21
  ]);
21
22
 
23
+ // `level` (docs/ui-levels.md) is the interface mode a step's controls live in; absent = simple.
24
+ // Every tile shows at every mode — the count stays "n of 9" — but a step above the current mode
25
+ // wears its level, and its guide opens by ringing the mode switch.
26
+ const LEVEL_ORDER = ['simple', 'advanced', 'expert'];
27
+ const LEVEL_LABEL = { advanced: 'Advanced', expert: 'Expert' };
28
+
22
29
  /** Seconds a tile holds the reveal sequence before the next arrives: an
23
30
  * unfinished tile paints its drawing first, a finished one arrives already
24
31
  * drawn and still, so it only needs its own fade. */
@@ -110,6 +117,18 @@ const VIGNETTES = {
110
117
  wire(0.65, 'M64 35h30'),
111
118
  shape(0.85, 'path', 'gs-chart gs-accent', 'd="M84 46l6-8 5 4 7-10"'),
112
119
  ],
120
+ // One document, read by every teammate: a page with its rules ticked, wired to three nodes.
121
+ teamPolicy: () => [
122
+ shape(0.05, 'rect', 'gs-frame', 'x="12" y="12" width="40" height="46" rx="6"'),
123
+ shape(0.25, 'path', 'gs-glyph', 'd="M22 24h20M22 33h20"'),
124
+ shape(0.4, 'path', 'gs-chart gs-accent', 'd="M22 45l4 4 8-8"'),
125
+ wire(0.5, 'M52 35 C 70 35, 76 20, 94 20'),
126
+ wire(0.6, 'M52 35h42'),
127
+ wire(0.7, 'M52 35 C 70 35, 76 50, 94 50'),
128
+ node(0.85, 100, 20, 5),
129
+ node(0.9, 100, 35, 5),
130
+ node(0.95, 100, 50, 5),
131
+ ],
113
132
  };
114
133
 
115
134
  /**
@@ -138,9 +157,9 @@ export function vignette(doc, id, { animate = true, delay = 0 } = {}) {
138
157
  * they are done, so any guide can be replayed.
139
158
  * @param {Element} host
140
159
  * @param {{steps?:Record<string,boolean>, hidden?:boolean}|null} status
141
- * @param {{onStep?:(id:string)=>void, onHide?:()=>void, animate?:boolean, hideLabel?:string}} [handlers]
160
+ * @param {{onStep?:(id:string)=>void, onHide?:()=>void, animate?:boolean, hideLabel?:string, level?:string}} [handlers] `level`: the current interface mode
142
161
  */
143
- export function renderGettingStarted(host, status, { onStep, onHide, animate = true, hideLabel = 'Hide' } = {}) {
162
+ export function renderGettingStarted(host, status, { onStep, onHide, animate = true, hideLabel = 'Hide', level = 'expert' } = {}) {
144
163
  if (!host) return;
145
164
  const doc = host.ownerDocument;
146
165
  host.replaceChildren();
@@ -173,7 +192,7 @@ export function renderGettingStarted(host, status, { onStep, onHide, animate = t
173
192
  const tiles = doc.createElement('div');
174
193
  tiles.className = 'gs-tiles';
175
194
  // Tiles arrive one at a time IN ORDER, so a new user reads one idea at a
176
- // time instead of eight at once. A finished tile takes its turn but arrives
195
+ // time instead of nine at once. A finished tile takes its turn but arrives
177
196
  // already drawn and still, so it holds the sequence only briefly.
178
197
  let at = 0;
179
198
  for (const s of GETTING_STARTED_STEPS) {
@@ -197,6 +216,14 @@ export function renderGettingStarted(host, status, { onStep, onHide, animate = t
197
216
  label.className = 'gs-label';
198
217
  label.textContent = s.label;
199
218
  tile.append(mark, vig, label);
219
+ if (s.level && LEVEL_ORDER.indexOf(s.level) > LEVEL_ORDER.indexOf(level)) {
220
+ const lv = doc.createElement('span');
221
+ lv.className = 'lv-pill gs-level';
222
+ lv.dataset.lv = s.level;
223
+ lv.textContent = LEVEL_LABEL[s.level];
224
+ tile.appendChild(lv);
225
+ tile.setAttribute('aria-label', `${tile.getAttribute('aria-label')} — part of ${LEVEL_LABEL[s.level]} mode`);
226
+ }
200
227
  tile.addEventListener('click', () => onStep && onStep(s.id));
201
228
  tiles.appendChild(tile);
202
229
  }
@@ -207,7 +234,7 @@ export function renderGettingStarted(host, status, { onStep, onHide, animate = t
207
234
  /* ------------------------------------------------------------------- pill */
208
235
 
209
236
  /**
210
- * The sidebar row under New pipeline: "Getting started · 2/8". It follows the
237
+ * The sidebar row under New pipeline: "Getting started · 2/9". It follows the
211
238
  * user to every view (the guides leave the shelf behind) and vanishes once the
212
239
  * checklist is hidden or complete — nothing left to nag about.
213
240
  * @param {Element} host
@@ -13,10 +13,12 @@
13
13
  import { createGraphView } from './view.mjs';
14
14
  import { renderPalette, applyFilter, FLOW_GROUP } from './palette.mjs';
15
15
  import { renderNodeInspector, renderWireInspector, renderEmptyInspector } from './inspector.mjs';
16
+ import { paramEditorHook } from '../script-forms.mjs';
16
17
  import { renderSaveDialog, openDialog, closeDialog } from './save-dialog.mjs';
17
18
  import { PORT_HIT_R, SNAP, ZOOM_MIN, ZOOM_MAX, ZOOM_K, ZOOM_STEP, NODE_W, snap }
18
19
  from '../../../src/shared/graph/geometry.mjs';
19
20
  import { hitRoute } from '../../../src/shared/graph/route.mjs';
21
+ import { PARAMS_PORT } from '../../../src/shared/graph/constants.mjs';
20
22
  import { canWire, newNode, newWire, normalizeTemplate, serializeTemplate }
21
23
  from '../../../src/shared/graph/template.mjs';
22
24
  import { validateGraph } from '../../../src/shared/graph/validate.mjs';
@@ -47,7 +49,7 @@ export const pluginOriginName = (origin) => (typeof origin === 'string' && origi
47
49
  ? origin.slice('plugin:'.length) : '');
48
50
  const isTyping = (t) => !!t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT' || t.isContentEditable);
49
51
 
50
- export function createComposer(hostEls, { doc = globalThis.document, api, raf = null, viewport = null, storage = null, portsFn } = {}) {
52
+ export function createComposer(hostEls, { doc = globalThis.document, api, raf = null, viewport = null, storage = null, portsFn, highlight = null } = {}) {
51
53
  const win = doc.defaultView || globalThis;
52
54
  const schedule = raf || ((fn) => win.requestAnimationFrame(fn));
53
55
  const stats = { rectReads: 0, frames: 0, pointerMoves: 0, validations: 0 };
@@ -65,7 +67,9 @@ export function createComposer(hostEls, { doc = globalThis.document, api, raf =
65
67
  let lastReport = { ok: true, errors: [], warnings: [] };
66
68
  const undoStack = [];
67
69
  const redoStack = [];
68
- let agents = {}; // key -> meta (palette + header tint)
70
+ let agents = {}; // key -> agent meta (palette domain groups)
71
+ let scripts = {}; // key -> script meta (palette Scripts group); the view gets the MERGED index
72
+ const headerIndex = () => ({ ...agents, ...scripts }); // keys never collide (D16)
69
73
 
70
74
  const view = createGraphView(hostEls.canvas, {
71
75
  doc, mode: 'edit', portsFn, agents, viewport,
@@ -512,7 +516,7 @@ export function createComposer(hostEls, { doc = globalThis.document, api, raf =
512
516
 
513
517
  function paintPalette() {
514
518
  renderPalette(hostEls.palette, {
515
- agents: Object.values(agents), placedKinds: tpl.nodes.map((n) => n.kind), collapsed, query, doc,
519
+ agents: Object.values(agents), scripts: Object.values(scripts), placedKinds: tpl.nodes.map((n) => n.kind), collapsed, query, doc,
516
520
  });
517
521
  }
518
522
  /** Diagonal de-stacker: 24 tries, SNAP*2 per try so each attempt lands one
@@ -535,10 +539,15 @@ export function createComposer(hostEls, { doc = globalThis.document, api, raf =
535
539
  const p = at || freeSlot(centerWorld());
536
540
  let node = null;
537
541
  commit('add', () => {
538
- node = entry.kind
539
- ? newNode(entry.kind, null, snap(p.x), snap(p.y))
540
- : newNode('agent', entry.key, snap(p.x), snap(p.y));
542
+ node = entry.kind === 'script'
543
+ ? newNode('script', entry.key, snap(p.x), snap(p.y))
544
+ : entry.kind
545
+ ? newNode(entry.kind, null, snap(p.x), snap(p.y))
546
+ : newNode('agent', entry.key, snap(p.x), snap(p.y));
541
547
  if (entry.kind === 'and' || entry.kind === 'or' || entry.kind === 'combine') node.config.arity = 2;
548
+ // D14: a config-ported script starts from the sidecar's defaultPorts, deep-copied — the card owns its ports from here.
549
+ const meta = entry.kind === 'script' ? scripts[entry.key] : null;
550
+ if (meta && meta.ports === 'config' && meta.defaultPorts) node.config.ports = JSON.parse(JSON.stringify(meta.defaultPorts));
542
551
  tpl.nodes.push(node);
543
552
  });
544
553
  select({ kind: 'node', id: node.id });
@@ -551,7 +560,9 @@ export function createComposer(hostEls, { doc = globalThis.document, api, raf =
551
560
  function onPalDown(ev) {
552
561
  const btn = ev.target.closest && ev.target.closest('.ap');
553
562
  if (!btn || btn.disabled || ev.button !== 0) return;
554
- drag = { entry: btn.dataset.kind ? { kind: btn.dataset.kind } : { key: btn.dataset.key },
563
+ const entry = btn.dataset.kind === 'script' ? { kind: 'script', key: btn.dataset.key }
564
+ : btn.dataset.kind ? { kind: btn.dataset.kind } : { key: btn.dataset.key };
565
+ drag = { entry,
555
566
  sx: ev.clientX, sy: ev.clientY, id: ev.pointerId, label: btn.querySelector('.n').textContent, ghost: null, btn };
556
567
  try { btn.setPointerCapture?.(ev.pointerId); } catch { /* synthetic */ }
557
568
  doc.addEventListener('pointermove', onPalMove);
@@ -618,15 +629,28 @@ export function createComposer(hostEls, { doc = globalThis.document, api, raf =
618
629
  paintInspector();
619
630
  }
620
631
 
632
+ // Code editors mounted into the inspector. Each owns a debounce timer and three
633
+ // listeners, so every repaint destroys the previous set before the tree goes —
634
+ // a pending highlight against a detached node is a leak, not a crash, and this
635
+ // is the only place that sees both sides of the swap. With no `highlight`
636
+ // injected the hook is null and renderParamsForm keeps P1b's plain textarea.
637
+ const insEditors = [];
638
+ const insEditorFor = paramEditorHook({ doc, highlight, editors: insEditors });
639
+ function disposeInsEditors() {
640
+ for (const ed of insEditors) ed.destroy();
641
+ insEditors.length = 0;
642
+ }
643
+
621
644
  function paintInspector() {
622
645
  const hostBody = hostEls.insBody;
623
646
  if (!hostBody) return;
647
+ disposeInsEditors();
624
648
  if (!sel) return void hostBody.replaceChildren(renderEmptyInspector({ doc }));
625
649
  if (sel.kind === 'node') {
626
650
  const node = nodeById(sel.id);
627
651
  if (!node) return void hostBody.replaceChildren(renderEmptyInspector({ doc }));
628
- const meta = node.kind === 'agent' ? (agents[node.key] || null) : null;
629
- return void hostBody.replaceChildren(renderNodeInspector(node, { template: tpl, portsFn, meta, models, efforts, subagentModels, doc }));
652
+ const meta = node.kind === 'agent' ? (agents[node.key] || null) : (node.kind === 'script' ? (scripts[node.key] || null) : null);
653
+ return void hostBody.replaceChildren(renderNodeInspector(node, { template: tpl, portsFn, meta, models, efforts, subagentModels, editorFor: insEditorFor, doc }));
630
654
  }
631
655
  const wire = wireById(sel.id);
632
656
  if (!wire) return void hostBody.replaceChildren(renderEmptyInspector({ doc }));
@@ -664,6 +688,22 @@ export function createComposer(hostEls, { doc = globalThis.document, api, raf =
664
688
  setTab(btn.dataset.tab, { persist: true });
665
689
  }
666
690
 
691
+ /** node.config.ports, cloned one level deep so a commit never mutates the undo ring's copy. */
692
+ const clonePorts = (raw) => ({
693
+ inputs: (Array.isArray(raw && raw.inputs) ? raw.inputs : []).map((p) => ({ ...p })),
694
+ outputs: (Array.isArray(raw && raw.outputs) ? raw.outputs : []).map((p) => ({ ...p })),
695
+ });
696
+ 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; } };
697
+ /** A param control's value in the declared type; undefined = delete the key. */
698
+ function coerceParam(decl, target) {
699
+ if (!decl) return target.value === '' ? undefined : target.value;
700
+ if (decl.type === 'boolean') return Boolean(target.checked);
701
+ if (decl.type === 'number') { const n = Number(target.value); return target.value === '' || !Number.isFinite(n) ? undefined : n; }
702
+ // A command or code value of blanks alone is no value: the runner trims it away, so V22 must see it as missing.
703
+ if ((decl.type === 'command' || decl.type === 'code') && target.value.trim() === '') return undefined;
704
+ return target.value === '' ? undefined : target.value;
705
+ }
706
+
667
707
  function onInspectorChange(ev) {
668
708
  const name = ev.target.dataset && ev.target.dataset.field;
669
709
  if (!name || !sel) return;
@@ -680,12 +720,72 @@ export function createComposer(hostEls, { doc = globalThis.document, api, raf =
680
720
  }
681
721
  const node = nodeById(sel.id);
682
722
  if (!node) return;
723
+ if (node.kind === 'script' && name.startsWith('param:')) {
724
+ const id = name.slice('param:'.length);
725
+ const decl = ((scripts[node.key] && scripts[node.key].params) || []).find((p) => p.id === id);
726
+ commit(name, () => {
727
+ const params = { ...(node.config.params || {}) };
728
+ const v = coerceParam(decl, ev.target);
729
+ if (v === undefined) delete params[id]; else params[id] = v;
730
+ if (Object.keys(params).length) node.config.params = params; else delete node.config.params;
731
+ });
732
+ paintInspector();
733
+ return;
734
+ }
735
+ if (node.kind === 'script' && name === 'timeoutMs') {
736
+ const secs = Number(ev.target.value);
737
+ commit(name, () => {
738
+ if (ev.target.value !== '' && Number.isFinite(secs) && secs >= 1) node.config.timeoutMs = Math.round(secs * 1000);
739
+ else delete node.config.timeoutMs;
740
+ });
741
+ paintInspector();
742
+ return;
743
+ }
744
+ if (node.kind === 'script' && name.startsWith('port:')) {
745
+ const [, dir, idx, fieldName] = name.split(':');
746
+ if (fieldName === 'id') {
747
+ // Wires follow a port BY ID (below), so an id that is blank, reserved or already taken on this side is
748
+ // refused and the box snaps back: passing through '' would strand the wires on a port that cannot be
749
+ // drawn, and two ports sharing an id would hand one port's wires to the other on the next rename.
750
+ const next = ev.target.value.trim();
751
+ const side = node.config.ports && Array.isArray(node.config.ports[dir]) ? node.config.ports[dir] : [];
752
+ const engineOwned = next === 'await' || (dir === 'inputs' && next === PARAMS_PORT.id && node.config.paramsPort === true);
753
+ if (!next || engineOwned || side.some((q, j) => j !== Number(idx) && q && q.id === next)) return void paintInspector();
754
+ }
755
+ commit(name, () => {
756
+ const ports = clonePorts(node.config.ports);
757
+ const p = ports[dir] && ports[dir][Number(idx)];
758
+ if (!p) return;
759
+ if (fieldName === 'required') p.required = Boolean(ev.target.checked);
760
+ else if (fieldName === 'loop') { if (ev.target.checked) p.loop = true; else delete p.loop; }
761
+ else if (fieldName === 'id') {
762
+ // A rename carries the port's wires with it: a wire into a port id that no longer exists is
763
+ // never drawn, so it could be neither selected nor deleted and the graph would stay invalid.
764
+ const old = p.id;
765
+ p.id = ev.target.value.trim();
766
+ if (old && old !== p.id) for (const w of tpl.wires) { const end = dir === 'inputs' ? w.to : w.from; if (end.node === node.id && end.port === old) end.port = p.id; }
767
+ }
768
+ else if (ev.target.value === '') delete p[fieldName];
769
+ else p[fieldName] = ev.target.value;
770
+ if (fieldName === 'type' && ev.target.value === 'void') delete p.filename;
771
+ node.config.ports = ports;
772
+ });
773
+ paintInspector();
774
+ return;
775
+ }
683
776
  commit(name, () => {
684
777
  if (name === 'arity') {
685
778
  const n = Number.parseInt(ev.target.value, 10);
686
779
  node.config.arity = Number.isInteger(n) && n >= 2 ? n : 2; // V12 floor
687
780
  } else if (ev.target.type === 'checkbox') {
688
781
  if (ev.target.checked) node.config[name] = true; else delete node.config[name];
782
+ // The params port leaves with its toggle, and its wires with it — a wire into a port that no longer
783
+ // exists is never drawn, so it could be neither selected nor deleted (same reason as a removed config port).
784
+ // Resolved AFTER the delete: an input still called `params` is the script's OWN (a stuck opt-in V22
785
+ // refuses), and the wires into it are the user's.
786
+ if (name === 'paramsPort' && !ev.target.checked && !(portsFn(node)?.inputs || []).some((p) => p && p.id === PARAMS_PORT.id)) {
787
+ tpl.wires = tpl.wires.filter((w) => !(w.to.node === node.id && w.to.port === PARAMS_PORT.id));
788
+ }
689
789
  } else if (ev.target.value === '') {
690
790
  delete node.config[name];
691
791
  } else {
@@ -695,6 +795,30 @@ export function createComposer(hostEls, { doc = globalThis.document, api, raf =
695
795
  paintInspector(); // re-read the committed value
696
796
  }
697
797
 
798
+ /** The port editor's add/remove buttons (script cards only). */
799
+ function onInspectorClick(ev) {
800
+ const add = ev.target.closest && ev.target.closest('[data-port-add]');
801
+ const rm = ev.target.closest && ev.target.closest('[data-port-remove]');
802
+ if ((!add && !rm) || !sel || sel.kind !== 'node') return;
803
+ const node = nodeById(sel.id);
804
+ if (!node || node.kind !== 'script') return;
805
+ commit('ports', () => {
806
+ const ports = clonePorts(node.config.ports);
807
+ if (add) {
808
+ const dir = add.dataset.portAdd;
809
+ if (dir === 'inputs') ports.inputs.push({ id: nextPortId(ports.inputs, 'in'), type: 'md', required: false });
810
+ else { const id = nextPortId(ports.outputs, 'out'); ports.outputs.push({ id, type: 'md', when: 'always', filename: `${id}-cycle{cycle}.md` }); }
811
+ } else {
812
+ const [dir, idx] = rm.dataset.portRemove.split(':');
813
+ const gone = ports[dir] ? ports[dir].splice(Number(idx), 1)[0] : null;
814
+ // The removed port's wires go with it (same reason as the rename above).
815
+ if (gone && gone.id) tpl.wires = tpl.wires.filter((w) => { const end = dir === 'inputs' ? w.to : w.from; return !(end.node === node.id && end.port === gone.id); });
816
+ }
817
+ node.config.ports = ports;
818
+ });
819
+ paintInspector();
820
+ }
821
+
698
822
  let dialog = null;
699
823
  let savedDomains = [];
700
824
 
@@ -812,6 +936,7 @@ export function createComposer(hostEls, { doc = globalThis.document, api, raf =
812
936
  hostEls.palette?.addEventListener('click', onPalClick);
813
937
  hostEls.filter?.addEventListener('input', onFilterInput);
814
938
  hostEls.insBody?.addEventListener('change', onInspectorChange);
939
+ hostEls.insBody?.addEventListener('click', onInspectorClick);
815
940
  hostEls.insToggle?.addEventListener('click', onRailToggle);
816
941
  hostEls.insTabs?.addEventListener('click', onTabClick);
817
942
  hostEls.saveBtn?.addEventListener('click', onSaveClick);
@@ -852,6 +977,8 @@ export function createComposer(hostEls, { doc = globalThis.document, api, raf =
852
977
  hostEls.palette?.removeEventListener('click', onPalClick);
853
978
  hostEls.filter?.removeEventListener('input', onFilterInput);
854
979
  hostEls.insBody?.removeEventListener('change', onInspectorChange);
980
+ hostEls.insBody?.removeEventListener('click', onInspectorClick);
981
+ disposeInsEditors();
855
982
  hostEls.insToggle?.removeEventListener('click', onRailToggle);
856
983
  hostEls.insTabs?.removeEventListener('click', onTabClick);
857
984
  hostEls.saveBtn?.removeEventListener('click', onSaveClick);
@@ -922,7 +1049,8 @@ export function createComposer(hostEls, { doc = globalThis.document, api, raf =
922
1049
  setReady(v) { ready = v; paintChrome(); },
923
1050
  // A registry reload (MAJ-16) must re-validate the OPEN canvas: a wire into a
924
1051
  // port the Agents view just deleted is an error now, not at the next edit.
925
- setAgents(map) { agents = map || {}; view.setAgents(agents); if (tpl.nodes.length) scheduleValidate(); },
1052
+ setAgents(map) { agents = map || {}; view.setAgents(headerIndex()); if (tpl.nodes.length) scheduleValidate(); },
1053
+ setScripts(map) { scripts = map || {}; view.setAgents(headerIndex()); if (tpl.nodes.length) scheduleValidate(); },
926
1054
  /** Rename: an unsaved edit, so it sets `dirty` — it never clears it. */
927
1055
  setName(name) { tpl.name = String(name || ''); metaDirty = true; dirty = true; paintChrome(); },
928
1056
  /** 'plugin:<name>' of the loaded row, '' when user-created. */