@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,408 @@
1
+ // src/shared/graph/script-meta.mjs
2
+ // Script sidecar v2 (`scripts/<key>.meta.json`, spec §3): ONE normalizer +
3
+ // validator for the registry loader (skip + warn), the plugin validator (hard
4
+ // error), the P2 store (400) and the inspector (live hints). Pure like
5
+ // agent-meta.mjs, and it REUSES agent-meta's port readers with `noPromptFields`:
6
+ // a script port is an agent port minus `as`, `directive` and `expands` — never a
7
+ // second port grammar.
8
+ import { PORT_ID_RE, PARAMS_PORT } from './constants.mjs';
9
+ import { readInputs, readOutputs, readVerdict, derivePortSummary, DEFAULT_ORDER } from './agent-meta.mjs';
10
+
11
+ /** Keys share ONE namespace with agents (D16), so the shape is the agent key's.
12
+ * Exported: the Scripts page derives a key from the name and gates Save on it. */
13
+ export const SCRIPT_KEY_RE = /^[A-Za-z][A-Za-z0-9_-]{0,63}$/;
14
+ /** `#scripts/new` is the create route, and `bench` / `runtimes` are the literal
15
+ * segments under `/api/scripts/` — a script keyed `runtimes` saves but its own
16
+ * GET answers with the runtime probe, so it can never be opened. Matched
17
+ * case-INSENSITIVELY by the store (express routing ignores case). Here, not in
18
+ * script-store.mjs, so the page can refuse the same keys without src/core. */
19
+ export const RESERVED_SCRIPT_KEYS = Object.freeze(['new', 'bench', 'runtimes']);
20
+ const DOMAIN_RE = /^[a-z][a-z0-9-]{0,31}$/;
21
+ /** The six agent colours, then six of the same lightness that are a SCRIPT's alone
22
+ * (script-wizard plan S10). Agent sidecars stay on the first six (agent-meta.mjs). */
23
+ export const SCRIPT_COLORS = Object.freeze(['green', 'peach', 'red', 'blue', 'violet', 'amber',
24
+ 'teal', 'pink', 'indigo', 'lime', 'cocoa', 'slate']);
25
+ const COLORS = new Set(SCRIPT_COLORS);
26
+ const PLATFORM_KEYS = new Set(['default', 'win32', 'darwin', 'linux']);
27
+
28
+ /** The three runtimes (base D2 + scripts-workbench W4). An unknown runtime is a
29
+ * sidecar error, never a run-time surprise. `python` additionally needs an
30
+ * interpreter at RUN time (workbench spec §7) — that is the runner's and the
31
+ * preflight's job, not the sidecar's: a python card on a python-less host is a
32
+ * perfectly valid card, it just cannot execute here. */
33
+ export const SCRIPT_RUNTIMES = Object.freeze(['node', 'shell', 'python']);
34
+ /** The languages a `code` param may declare: one per first-class program runtime. */
35
+ export const CODE_LANGUAGES = Object.freeze(['js', 'python']);
36
+ /** The ONE sentence every surface says when a python card cannot run on this host
37
+ * (workbench spec §7): the run preflight, the bench result, the composer's V4.
38
+ * It lives HERE because the validator is shared/isomorphic and cannot reach
39
+ * src/core, where the probe is. */
40
+ export const pythonMissingSentence = (key) =>
41
+ `script "${key}" needs python 3.8 or newer — none found on this machine (set WORCA_PYTHON)`;
42
+ export const PARAM_TYPES = Object.freeze(['string', 'number', 'boolean', 'enum', 'command', 'code']);
43
+ /** The param types an import shows the user before saving (D18). */
44
+ export const CONFIRM_PARAM_TYPES = Object.freeze(['command', 'code']);
45
+ export const MAX_PARAMS = 16;
46
+ export const DEFAULT_TIMEOUT_MS = 600000;
47
+ export const MIN_TIMEOUT_MS = 1000;
48
+ /** 24 h. A timer delay above 2^31-1 ms (24.8 days) overflows and fires after ONE millisecond, so a
49
+ * "practically never" timeout would kill the script at once (measured, v4 T3). The cap is far below that. */
50
+ export const MAX_TIMEOUT_MS = 86400000;
51
+ /** Shell exit mapping (D8): 0 clean, 1 blocking, anything else an execution error. */
52
+ export const DEFAULT_EXIT_CODES = Object.freeze({ clean: Object.freeze([0]), blocking: Object.freeze([1]) });
53
+
54
+ const isObject = (v) => Boolean(v) && typeof v === 'object' && !Array.isArray(v);
55
+ const plainBasename = (s) => typeof s === 'string' && s.trim() !== '' && !/[\\/]/.test(s) && !s.includes('..');
56
+ const nonEmpty = (s) => typeof s === 'string' && s.trim() !== '';
57
+
58
+ /** Attribution stamps (workbench W19): 'ui' | 'cli' | 'ask:<threadId>'. */
59
+ const MAX_BY = 80;
60
+
61
+ /** A per-platform value (`file`, `command`): a string, or `{ default, win32?, darwin?, linux? }`. */
62
+ export function resolvePlatformValue(value, platform) {
63
+ if (typeof value === 'string') return value;
64
+ if (!isObject(value)) return null;
65
+ return value[platform] ?? value.default ?? null;
66
+ }
67
+
68
+ function readPlatformValue(raw, field, err, check, what) {
69
+ if (raw === undefined || raw === null) return null;
70
+ if (typeof raw === 'string') {
71
+ if (!check(raw)) { err(`${field} ${what}`); return null; }
72
+ return raw.trim();
73
+ }
74
+ if (!isObject(raw)) { err(`${field} must be a string or a per-platform map { default, win32?, darwin?, linux? }`); return null; }
75
+ const out = {};
76
+ let bad = false;
77
+ for (const [k, v] of Object.entries(raw)) {
78
+ if (!PLATFORM_KEYS.has(k)) { err(`${field}: unknown platform "${k}" — expected default, win32, darwin or linux`); bad = true; continue; }
79
+ if (!check(v)) { err(`${field}.${k} ${what}`); bad = true; continue; }
80
+ out[k] = v.trim();
81
+ }
82
+ if (out.default === undefined) { err(`${field}: a per-platform map needs a default entry`); bad = true; }
83
+ return bad ? null : out;
84
+ }
85
+
86
+ /** '' when `value` fits the param's type, else the reason. Shared by the sidecar
87
+ * default check and by V22 (a placed card's `config.params`). */
88
+ export function paramValueError(param, value) {
89
+ switch (param?.type) {
90
+ case 'string': case 'command': case 'code':
91
+ return typeof value === 'string' ? '' : `must be a string (got ${JSON.stringify(value)})`;
92
+ case 'number':
93
+ return typeof value === 'number' && Number.isFinite(value) ? '' : `must be a finite number (got ${JSON.stringify(value)})`;
94
+ case 'boolean':
95
+ return typeof value === 'boolean' ? '' : `must be true or false (got ${JSON.stringify(value)})`;
96
+ case 'enum':
97
+ return typeof value === 'string' && (param.options || []).includes(value)
98
+ ? '' : `must be one of ${(param.options || []).join(', ')} (got ${JSON.stringify(value)})`;
99
+ default:
100
+ return `has an unknown param type ${JSON.stringify(param?.type)}`;
101
+ }
102
+ }
103
+
104
+ export function readParams(raw, err) {
105
+ if (raw === undefined) return [];
106
+ if (!Array.isArray(raw)) { err('params must be an array'); return []; }
107
+ if (raw.length > MAX_PARAMS) err(`params: at most ${MAX_PARAMS} params (got ${raw.length})`);
108
+ const seen = new Set();
109
+ const out = [];
110
+ for (const p of raw) {
111
+ if (!isObject(p)) { err('params: each param must be an object'); continue; }
112
+ const id = typeof p.id === 'string' ? p.id.trim() : '';
113
+ if (!PORT_ID_RE.test(id)) { err(`params: bad param id "${id}"`); continue; }
114
+ if (seen.has(id)) { err(`params: duplicate param id "${id}"`); continue; }
115
+ seen.add(id);
116
+ if (!PARAM_TYPES.includes(p.type)) { err(`params.${id}: type must be one of ${PARAM_TYPES.join(', ')}`); continue; }
117
+ const param = { id, type: p.type, required: !!p.required };
118
+ if (nonEmpty(p.label)) param.label = p.label.trim();
119
+ if (nonEmpty(p.description)) param.description = p.description.trim();
120
+ if (p.type === 'enum') {
121
+ const options = Array.isArray(p.options) ? p.options.filter(nonEmpty) : [];
122
+ if (!options.length) err(`params.${id}: enum params need a non-empty options list of strings`);
123
+ param.options = options;
124
+ }
125
+ if (p.type === 'code') {
126
+ if (!CODE_LANGUAGES.includes(p.language)) err(`params.${id}: code params need language ${CODE_LANGUAGES.map((l) => `"${l}"`).join(' or ')}`);
127
+ param.language = CODE_LANGUAGES.includes(p.language) ? p.language : CODE_LANGUAGES[0];
128
+ }
129
+ if (p.default !== undefined) {
130
+ const bad = paramValueError(param, p.default);
131
+ if (bad) err(`params.${id}: default ${bad}`);
132
+ else param.default = p.default;
133
+ }
134
+ out.push(param);
135
+ }
136
+ return out;
137
+ }
138
+
139
+ export function readExitCodes(raw, err) {
140
+ if (raw === undefined) return null;
141
+ if (!isObject(raw)) { err('exitCodes must be { clean: int[], blocking: int[] }'); return null; }
142
+ const list = (side) => {
143
+ const v = raw[side];
144
+ if (!Array.isArray(v) || !v.every((n) => Number.isInteger(n) && n >= 0 && n <= 255)) {
145
+ err(`exitCodes.${side} must be a list of integers 0..255`);
146
+ return null;
147
+ }
148
+ return [...new Set(v)];
149
+ };
150
+ const clean = list('clean');
151
+ const blocking = list('blocking');
152
+ if (!clean || !blocking) return null;
153
+ const both = clean.filter((n) => blocking.includes(n));
154
+ if (both.length) { err(`exitCodes: ${both.join(', ')} listed as both clean and blocking`); return null; }
155
+ return { clean, blocking };
156
+ }
157
+
158
+ /** The §3 `mock` shape against a list of declared output ports. Returns error strings. */
159
+ export function mockErrors(mock, outputs) {
160
+ if (!isObject(mock)) return ['mock must be an object { summary?, verdict?, outputs? }'];
161
+ const errors = [];
162
+ if (mock.summary !== undefined && typeof mock.summary !== 'string') errors.push('mock.summary must be a string');
163
+ if (mock.verdict !== undefined && (!isObject(mock.verdict) || !Array.isArray(mock.verdict.issues))) errors.push('mock.verdict must be { issues: [...] }');
164
+ if (mock.outputs !== undefined) {
165
+ if (!isObject(mock.outputs)) errors.push('mock.outputs must be an object keyed by output port id');
166
+ else {
167
+ const byId = new Map((outputs || []).filter(Boolean).map((p) => [p.id, p]));
168
+ for (const [id, spec] of Object.entries(mock.outputs)) {
169
+ const port = byId.get(id);
170
+ if (!port) errors.push(`mock.outputs.${id}: not a declared output port`);
171
+ else if (port.type === 'void') errors.push(`mock.outputs.${id}: void ports carry no text`);
172
+ else if (!isObject(spec) || typeof spec.text !== 'string') errors.push(`mock.outputs.${id}: must be { text: string }`);
173
+ }
174
+ }
175
+ }
176
+ return errors;
177
+ }
178
+
179
+ /** Two outputs may share one filename template only with an identical type (the agent rule). */
180
+ function sharedFilenameRule(outputs, err) {
181
+ const typeByTemplate = new Map();
182
+ for (const p of outputs) {
183
+ if (!p.filename) continue;
184
+ const prev = typeByTemplate.get(p.filename);
185
+ if (prev === undefined) typeByTemplate.set(p.filename, p.type);
186
+ else if (prev !== p.type) err(`outputs: filename template "${p.filename}" is shared by ports of different types`);
187
+ }
188
+ }
189
+
190
+ /**
191
+ * A placed node's `config.ports` for a `ports: "config"` sidecar (D14), or a
192
+ * sidecar's `defaultPorts` — read through the SAME readers a sidecar's own ports
193
+ * go through. The verdict is the SIDECAR's (never in config), so `hasVerdict`
194
+ * decides whether a conditional output is legal here.
195
+ * @returns {{ports: {inputs:Array, outputs:Array}|null, errors: string[]}}
196
+ */
197
+ export function readConfigPorts(raw, { hasVerdict = false } = {}) {
198
+ if (!isObject(raw)) return { ports: null, errors: ['ports config must be an object { inputs: [...], outputs: [...] }'] };
199
+ const errors = [];
200
+ const err = (m) => errors.push(m);
201
+ const inputs = readInputs(raw.inputs, err, () => {}, { noPromptFields: true });
202
+ const outputs = readOutputs(raw.outputs, hasVerdict, err, { allowEmptyOutputs: true, who: 'script' });
203
+ sharedFilenameRule(outputs, err);
204
+ return { ports: errors.length ? null : { inputs, outputs }, errors };
205
+ }
206
+
207
+ /** Pure validation for the plugin validator and the P2 store. Silent by design — the load path warns. */
208
+ export function validateScriptMetaV2(raw) {
209
+ return { errors: normalizeScriptMeta(raw, { warn: () => {} }).errors };
210
+ }
211
+
212
+ /**
213
+ * @param {object} raw parsed sidecar
214
+ * @param {{warn?:(msg:string)=>void}} [opts]
215
+ * @returns {{meta:object|null, errors:string[]}} meta is meaningful only when errors is empty
216
+ */
217
+ export function normalizeScriptMeta(raw, opts = {}) {
218
+ const errors = [];
219
+ const err = (msg) => errors.push(msg);
220
+ const warn = typeof opts.warn === 'function' ? opts.warn : () => {};
221
+ if (!isObject(raw)) return { errors: ['meta must be an object'], meta: null };
222
+
223
+ const key = typeof raw.key === 'string' ? raw.key.trim() : '';
224
+ if (!key) err('key is required');
225
+ else if (!SCRIPT_KEY_RE.test(key)) err(`key "${key}" is not a valid script key`);
226
+ if (raw.metaVersion !== 2) err('sidecar requires metaVersion 2');
227
+ const order = raw.order === undefined ? DEFAULT_ORDER : Number(raw.order);
228
+ if (!Number.isFinite(order)) err('order must be a number');
229
+ for (const field of ['createdBy', 'updatedBy']) {
230
+ if (raw[field] === undefined || raw[field] === null) continue;
231
+ if (typeof raw[field] !== 'string' || raw[field].trim().length > MAX_BY) {
232
+ err(`${field} must be a string of at most ${MAX_BY} characters`);
233
+ }
234
+ }
235
+
236
+ const runtime = SCRIPT_RUNTIMES.includes(raw.runtime) ? raw.runtime : null;
237
+ if (!runtime) err(`runtime must be one of ${SCRIPT_RUNTIMES.join(', ')}`);
238
+
239
+ const file = readPlatformValue(raw.file, 'file', err, plainBasename, 'must be a plain basename');
240
+ // Worded "to run" on purpose (v3 S1): the purity guard scans RAW source — comments included — and reads the
241
+ // module-loading keyword directly before a quote as a specifier. Keep that keyword out of every string and comment here.
242
+ // Both harnessed runtimes load a program FILE (node through its loader, python through importlib): both REQUIRE one.
243
+ if ((runtime === 'node' || runtime === 'python') && !file) err(`runtime "${runtime}" requires file: the program to run`);
244
+ const command = readPlatformValue(raw.command, 'command', err, nonEmpty, 'must be a non-empty string');
245
+ if (runtime && runtime !== 'shell' && command) err('command is only legal on the shell runtime');
246
+
247
+ const params = readParams(raw.params, err);
248
+ if (runtime === 'shell' && !file && !command && !params.some((p) => p.type === 'command')) {
249
+ err('runtime "shell" needs a command, a file, or a command-typed param');
250
+ }
251
+
252
+ let timeoutMs = DEFAULT_TIMEOUT_MS;
253
+ if (raw.timeoutMs !== undefined) {
254
+ if (!Number.isInteger(raw.timeoutMs) || raw.timeoutMs < MIN_TIMEOUT_MS) err(`timeoutMs must be an integer >= ${MIN_TIMEOUT_MS}`);
255
+ else if (raw.timeoutMs > MAX_TIMEOUT_MS) err(`timeoutMs must be at most ${MAX_TIMEOUT_MS} (24 h)`);
256
+ else timeoutMs = raw.timeoutMs;
257
+ }
258
+ const exitCodes = readExitCodes(raw.exitCodes, err);
259
+ if (exitCodes && runtime && runtime !== 'shell') err('exitCodes is only legal on the shell runtime');
260
+
261
+ const verdict = readVerdict(raw.verdict, err);
262
+ const configPorts = raw.ports === 'config';
263
+ if (raw.ports !== undefined && !configPorts) err('ports must be the literal "config" (or absent, with inputs/outputs declared)');
264
+ let inputs = null;
265
+ let outputs = null;
266
+ let defaultPorts = null;
267
+ if (configPorts) {
268
+ if (raw.inputs !== undefined || raw.outputs !== undefined) err('ports: "config" and inputs/outputs are mutually exclusive');
269
+ const dp = readConfigPorts(raw.defaultPorts, { hasVerdict: !!verdict });
270
+ for (const e of dp.errors) err(`defaultPorts: ${e}`);
271
+ defaultPorts = dp.ports;
272
+ } else {
273
+ inputs = readInputs(raw.inputs, err, warn, { noPromptFields: true });
274
+ outputs = readOutputs(raw.outputs, !!verdict, err, { allowEmptyOutputs: true, who: 'script' });
275
+ sharedFilenameRule(outputs, err);
276
+ }
277
+
278
+ let mock = null;
279
+ if (raw.mock !== undefined && raw.mock !== null) {
280
+ const me = mockErrors(raw.mock, configPorts ? (defaultPorts?.outputs || []) : outputs);
281
+ for (const e of me) err(e);
282
+ if (!me.length) mock = raw.mock;
283
+ }
284
+
285
+ const meta = {
286
+ metaVersion: 2,
287
+ key,
288
+ displayName: nonEmpty(raw.displayName) ? raw.displayName.trim() : key,
289
+ description: typeof raw.description === 'string' ? raw.description : '',
290
+ color: COLORS.has(raw.color) ? raw.color : 'amber',
291
+ icon: typeof raw.icon === 'string' ? raw.icon : '',
292
+ domain: typeof raw.domain === 'string' && DOMAIN_RE.test(raw.domain) ? raw.domain : 'general',
293
+ order: Number.isFinite(order) ? order : DEFAULT_ORDER,
294
+ runtime,
295
+ file,
296
+ command,
297
+ timeoutMs,
298
+ params,
299
+ portSummary: '',
300
+ };
301
+ if (exitCodes) meta.exitCodes = exitCodes;
302
+ if (configPorts) {
303
+ meta.ports = 'config';
304
+ meta.defaultPorts = defaultPorts || { inputs: [], outputs: [] };
305
+ } else {
306
+ meta.inputs = inputs;
307
+ meta.outputs = outputs;
308
+ }
309
+ meta.portSummary = derivePortSummary(configPorts ? meta.defaultPorts : meta);
310
+ if (verdict) meta.verdict = verdict;
311
+ if (mock) meta.mock = mock;
312
+ if (raw.placeable !== undefined && !raw.placeable) meta.placeable = false;
313
+ // Who wrote this sidecar (W19). Absent on a hand-written file and on every
314
+ // built-in; the store stamps both on create and `updatedBy` on every save.
315
+ if (nonEmpty(raw.createdBy)) meta.createdBy = raw.createdBy.trim();
316
+ if (nonEmpty(raw.updatedBy)) meta.updatedBy = raw.updatedBy.trim();
317
+ return { errors, meta };
318
+ }
319
+
320
+ /** The params a placed card runs with (spec §4.1): sidecar defaults overlaid by
321
+ * `node.config.params`. No project layer in v1 (D6). Pure; resolveGraph, the
322
+ * resume path, the import dry-run, the offline test runner and (P1b) the
323
+ * inspector all read it. */
324
+ export function effectiveScriptParams(meta, config) {
325
+ const out = {};
326
+ for (const p of Array.isArray(meta?.params) ? meta.params : []) if (p && p.default !== undefined) out[p.id] = p.default;
327
+ const given = isObject(config?.params) ? config.params : {};
328
+ for (const [id, v] of Object.entries(given)) if (v !== undefined) out[id] = v;
329
+ return out;
330
+ }
331
+
332
+ /** Param types a WIRE may set. `command` and `code` are what the card RUNS: they stay behind the
333
+ * inspector and the D18 import receipt, so no upstream node — an agent least of all — authors them. */
334
+ export const WIRABLE_PARAM_TYPES = Object.freeze(PARAM_TYPES.filter((t) => !CONFIRM_PARAM_TYPES.includes(t)));
335
+ /** cmd.exe expands %VAR% BEFORE it parses, so a wired string reaching a shell script is limited to
336
+ * characters no shell re-parses — on every OS, so a workflow behaves the same everywhere. */
337
+ const SHELL_SAFE_RE = /^[A-Za-z0-9 _.,:@\/\\+=~-]*$/;
338
+
339
+ export function wirableParams(meta) {
340
+ return (Array.isArray(meta?.params) ? meta.params : []).filter((p) => p && WIRABLE_PARAM_TYPES.includes(p.type));
341
+ }
342
+
343
+ /** True when the placed card carries the engine `params` input: the card opted in, the script has a
344
+ * param a wire may set, and no DECLARED input owns the id. `meta` is the REGISTRY entry — a resolved
345
+ * ports object already lists the port among its inputs and would read as a collision. */
346
+ export function hasParamsPort(meta, config) {
347
+ if (!isObject(config) || config.paramsPort !== true || !wirableParams(meta).length) return false;
348
+ const declared = meta?.ports === 'config'
349
+ ? (readConfigPorts(config.ports, { hasVerdict: !!meta?.verdict }).ports?.inputs || [])
350
+ : (Array.isArray(meta?.inputs) ? meta.inputs : []);
351
+ return !declared.some((p) => p && p.id === PARAMS_PORT.id);
352
+ }
353
+
354
+ /** Overlay the JSON a wire delivered on the engine `params` port over the card's effective params.
355
+ * Pure, never throws: every problem is a sentence in `errors` and the caller refuses the execution.
356
+ * A `null` value is "not provided" (how an agent says blank) and falls through to the card. */
357
+ export function overlayWiredParams(meta, params, wired) {
358
+ const base = isObject(params) ? { ...params } : {};
359
+ if (!isObject(wired)) return { params: base, wired: [], errors: ['must be a JSON object'] };
360
+ const byId = new Map((Array.isArray(meta?.params) ? meta.params : []).filter(Boolean).map((p) => [p.id, p]));
361
+ const allowed = wirableParams(meta).map((p) => p.id);
362
+ const out = { ...base };
363
+ const set = [];
364
+ const errors = [];
365
+ for (const [id, value] of Object.entries(wired)) {
366
+ const d = byId.get(id);
367
+ if (!d) { errors.push(`unknown param '${id}' — a wire can set ${allowed.join(', ') || 'nothing'}`); continue; }
368
+ if (!WIRABLE_PARAM_TYPES.includes(d.type)) { errors.push(`param '${id}' is a ${d.type} param — only the card itself may set it`); continue; }
369
+ if (value === null) continue;
370
+ const bad = paramValueError(d, value);
371
+ if (bad) { errors.push(`param '${id}': ${bad}`); continue; }
372
+ if (meta?.runtime === 'shell' && d.type === 'string' && !SHELL_SAFE_RE.test(value)) {
373
+ errors.push(`param '${id}': a wired value for a shell script may only contain letters, digits, space and _ . , : @ / \\ + = ~ - (got ${JSON.stringify(value)})`);
374
+ continue;
375
+ }
376
+ out[id] = value;
377
+ set.push(id);
378
+ }
379
+ for (const d of byId.values()) if (d.required && out[d.id] === undefined) errors.push(`missing required param '${d.id}'`);
380
+ return errors.length ? { params: base, wired: [], errors } : { params: out, wired: set, errors };
381
+ }
382
+
383
+ /** The run-time facts of ONE placed script card — what resolveGraph puts in `nodes[id]`, what the
384
+ * resume path rebuilds from a manifest cell, what the offline test runner and (P1c) the bench build for
385
+ * a synthetic node. ONE builder, so a fresh run, a resumed run and a bench run cannot drift apart.
386
+ * `meta` is the registry entry (with its `scriptPath` / `commandResolved` stamps); an absent meta
387
+ * yields a stub the script preflight refuses. Pure. */
388
+ export function scriptNodeCtx(node, meta) {
389
+ const cfg = isObject(node?.config) ? node.config : {};
390
+ const m = isObject(meta) ? meta : {};
391
+ return {
392
+ nodeId: node?.id,
393
+ kind: 'script',
394
+ key: node?.key,
395
+ authoredKey: node?.key,
396
+ meta: m,
397
+ runtime: m.runtime ?? null,
398
+ file: m.scriptPath ?? null, // absolute, host platform (registry stamp)
399
+ command: m.commandResolved ?? null, // the sidecar's, host platform; a command param overrides at run time
400
+ params: effectiveScriptParams(m, cfg), // D6: no project layer in v1
401
+ paramsPort: hasParamsPort(m, cfg), // the engine `params` input is on this card: the runner overlays its wire
402
+ timeoutMs: Number.isInteger(cfg.timeoutMs) ? cfg.timeoutMs : (m.timeoutMs ?? DEFAULT_TIMEOUT_MS),
403
+ mock: isObject(cfg.mock) ? cfg.mock : (m.mock ?? null),
404
+ config: { ...cfg },
405
+ awaitAll: !!cfg.awaitAll,
406
+ duplicateKey: false,
407
+ };
408
+ }
@@ -0,0 +1,201 @@
1
+ // src/shared/graph/script-templates.mjs
2
+ // The program and the sidecar a NEW script starts from. ONE source for the three
3
+ // scaffolders — the Scripts page's create flow (spec §5.2), `worca script new`
4
+ // and `worca plugin new-script` (§6) — so a plugin author's file and the page's
5
+ // file can never drift. Pure: strings and plain objects, no fs, no node builtins.
6
+ // ui/public/scripts-view.mjs imports and re-exports the first four names.
7
+
8
+ import { CASES_VERSION } from './script-cases.mjs';
9
+ import { iconSvgOf } from './script-icons.mjs';
10
+
11
+ /** What a fresh script of each runtime looks like on the canvas (script-wizard plan S9). */
12
+ export const RUNTIME_DEFAULTS = Object.freeze({
13
+ node: Object.freeze({ color: 'violet', icon: 'code' }),
14
+ python: Object.freeze({ color: 'blue', icon: 'flask' }),
15
+ shell: Object.freeze({ color: 'amber', icon: 'terminal' }),
16
+ });
17
+
18
+ /** The page's **Load example** (S14): one working gate per runtime, read by the
19
+ * inference exactly as the concept canvas shows it. `icon` is a SCRIPT_ICONS name. */
20
+ export const SCRIPT_EXAMPLES = Object.freeze({
21
+ node: Object.freeze({
22
+ name: 'Diff gate',
23
+ description: 'Blocks the review when the diff touches more files than allowed.',
24
+ color: 'violet',
25
+ icon: 'funnel',
26
+ source: [
27
+ '// Reads the plan and the diff, writes a report, blocks when the diff is too wide.',
28
+ 'export default async function ({ inputs, outputs, params, ctx, log }) {',
29
+ // The fs builtin is loaded dynamically, in BACKTICKS, on purpose: test/shared-graph-purity.test.mjs reads
30
+ // this module's RAW source (comments and strings included) and would take the usual static form, or a
31
+ // quoted specifier, as an import of THIS shared module. The program the user sees is ordinary ESM.
32
+ ' const { readFileSync, writeFileSync } = await import(`node:fs`);',
33
+ " const plan = readFileSync(inputs.plan.path, 'utf8');",
34
+ " const diff = readFileSync(inputs.diff.path, 'utf8');",
35
+ ' const limit = Number(params.maxFiles ?? 40);',
36
+ ' const files = diff.match(/^\\+\\+\\+ b\\/(.+)$/gm) ?? [];',
37
+ " log('info', `${files.length} files changed`);",
38
+ '',
39
+ ' writeFileSync(outputs.report.path,',
40
+ " `# Diff report\\n\\nPlan: ${plan.split('\\n')[0]}\\n${files.length} files changed, limit ${limit}\\n`);",
41
+ '',
42
+ ' const issues = files.length > limit',
43
+ " ? [{ severity: 'major', title: `${files.length} files exceed the limit of ${limit}` }]",
44
+ ' : [];',
45
+ ' return { summary: `${files.length} files`, verdict: { issues } };',
46
+ '}',
47
+ ].join('\n') + '\n',
48
+ }),
49
+ python: Object.freeze({
50
+ name: 'TODO gate',
51
+ description: 'Counts the TODOs a diff adds and blocks when there are too many.',
52
+ color: 'blue',
53
+ icon: 'flask',
54
+ source: [
55
+ '# Counts the TODOs a diff adds and blocks when the count passes a limit.',
56
+ 'import re',
57
+ '',
58
+ 'def main(api):',
59
+ " diff = open(api.inputs.diff.path, encoding='utf-8').read()",
60
+ ' limit = int(api.params.limit or 5)',
61
+ " todos = re.findall(r'^\\+.*\\bTODO\\b', diff, re.M)",
62
+ " api.log('info', f'{len(todos)} new TODOs')",
63
+ '',
64
+ " with open(api.outputs.report.path, 'w', encoding='utf-8') as f:",
65
+ " f.write(f'# TODO report\\n\\n{len(todos)} new TODOs, limit {limit}\\n')",
66
+ '',
67
+ " issues = [{'severity': 'major', 'title': f'{len(todos)} TODOs over the limit of {limit}'}] if len(todos) > limit else []",
68
+ " return {'summary': f'{len(todos)} TODOs', 'verdict': {'issues': issues}}",
69
+ ].join('\n') + '\n',
70
+ }),
71
+ shell: Object.freeze({
72
+ name: 'Run tests',
73
+ description: "Runs the project's test command in the checkout. Exit 0 passes, exit 1 fails.",
74
+ color: 'amber',
75
+ icon: 'terminal',
76
+ source: [
77
+ '#!/bin/sh',
78
+ "# Runs the test command in the run's checkout. Exit 0 routes to pass, exit 1 to fail.",
79
+ 'cd "$WORCA_CWD"',
80
+ 'CMD="${WORCA_PARAM_COMMAND:-npm test}"',
81
+ 'echo "running: $CMD"',
82
+ 'sh -c "$CMD" > "$WORCA_OUT_LOG" 2>&1',
83
+ ].join('\n') + '\n',
84
+ }),
85
+ });
86
+
87
+
88
+ // A new script must RUN before a character is typed: each template is the
89
+ // minimum its runtime's contract accepts (base spec §5.1, §5.2, §7), so the
90
+ // first bench run is green and the author edits from a working program.
91
+ export const SCRIPT_TEMPLATES = {
92
+ node: "export default async function ({ inputs, outputs, params, ctx, log }) {\n log('info', 'hello from a worca script');\n return { summary: 'ok' };\n}\n",
93
+ shell: '#!/bin/sh\nset -e\necho "hello from a worca script"\n',
94
+ python: "def main(api):\n api.log('info', 'hello from a worca script')\n return { 'summary': 'ok' }\n",
95
+ };
96
+ // Held with LF, like every editor value (a <textarea> cannot hold a CR). cmd.exe
97
+ // wants CRLF in a .cmd (spec §10), and that ending belongs to the WRITER: every
98
+ // path that puts this text on disk goes through script-store.mjs `programText`.
99
+ export const SCRIPT_WIN32_TEMPLATE = '@echo off\necho hello from a worca script\n';
100
+ export const SHELL_COMMAND_TEMPLATE = 'npm test';
101
+
102
+ /** The create page's starting sidecar: the runtime's colour and icon, everything else empty. */
103
+ export function blankScriptMeta(runtime = 'node') {
104
+ const d = RUNTIME_DEFAULTS[runtime] || RUNTIME_DEFAULTS.node;
105
+ return {
106
+ key: '', metaVersion: 2, displayName: '', description: '', domain: '', color: d.color,
107
+ icon: iconSvgOf(d.icon), order: 50, runtime, timeoutMs: 600000, params: [], inputs: [], outputs: [],
108
+ };
109
+ }
110
+
111
+ /**
112
+ * The source a fresh script of this runtime starts from.
113
+ * @param {string} runtime 'node' | 'shell' | 'python' (anything else -> node)
114
+ * @param {{win32?: boolean}} [opts] win32: the .cmd half of a shell pair (LF here —
115
+ * the writer applies `programText`, which gives a .cmd its CRLF).
116
+ * @returns {string}
117
+ */
118
+ export function scriptSourceTemplate(runtime, { win32 = false } = {}) {
119
+ if (win32) return SCRIPT_WIN32_TEMPLATE;
120
+ return SCRIPT_TEMPLATES[runtime] || SCRIPT_TEMPLATES.node;
121
+ }
122
+
123
+ /**
124
+ * The sidecar a CLI-scaffolded script starts from: the create page's blank meta
125
+ * with a key and the file(s) the runtime names. Ports and params stay empty —
126
+ * the templates write no output, and a declared non-void output nothing writes
127
+ * is an execution error (base spec §4.2 rule 2). The user layer's store
128
+ * recomputes `file` from key+runtime (spec §3.3); it is filled here for
129
+ * `worca plugin new-script`, which writes the files itself.
130
+ * @param {string} key @param {string} runtime
131
+ * @returns {object} a raw meta v2 object
132
+ */
133
+ export function scriptMetaTemplate(key, runtime) {
134
+ const k = String(key || 'script');
135
+ const file = runtime === 'shell'
136
+ ? { default: `${k}.sh`, win32: `${k}.cmd` }
137
+ : `${k}.${runtime === 'python' ? 'py' : 'mjs'}`;
138
+ return {
139
+ ...blankScriptMeta(runtime),
140
+ key: k,
141
+ displayName: k,
142
+ description: 'Scaffolded worca script — edit me',
143
+ file,
144
+ };
145
+ }
146
+
147
+ /** A typed empty value for a required param the sidecar gives no default for. */
148
+ function emptyParamValue(p) {
149
+ if (p.type === 'number') return 0;
150
+ if (p.type === 'boolean') return false;
151
+ if (p.type === 'enum') return (Array.isArray(p.options) && p.options.length ? p.options[0] : '');
152
+ return '';
153
+ }
154
+
155
+ /**
156
+ * One runnable sample case for a scaffolded script (W5): scratch cwd, every
157
+ * declared input bound, and the expectation a clean run satisfies — so
158
+ * `worca plugin validate --run-cases` is green on a fresh scaffold.
159
+ * @param {object} meta a NORMALIZED script meta
160
+ * @returns {{version: number, cases: object[]}}
161
+ */
162
+ export function sampleCasesTemplate(meta) {
163
+ const config = meta && meta.ports === 'config';
164
+ const ports = config
165
+ ? (meta.defaultPorts || { inputs: [], outputs: [] })
166
+ : { inputs: (meta && meta.inputs) || [], outputs: (meta && meta.outputs) || [] };
167
+
168
+ const inputs = {};
169
+ for (const p of ports.inputs || []) {
170
+ if (!p || !p.id || p.id === 'await') continue; // the gate is never bound (§4.1)
171
+ if (p.type === 'void') inputs[p.id] = { fired: true };
172
+ else if (p.type === 'json') inputs[p.id] = { text: '{}\n' };
173
+ else inputs[p.id] = { text: `# ${p.id}\n\nSample input.\n` };
174
+ }
175
+
176
+ const params = {};
177
+ for (const p of (meta && meta.params) || []) {
178
+ if (!p || !p.id) continue;
179
+ if (p.default !== undefined) params[p.id] = p.default;
180
+ else if (p.required) params[p.id] = emptyParamValue(p);
181
+ }
182
+
183
+ // A clean run fires `always` and `clean` outputs; `blocking` ones must not appear.
184
+ const fired = (ports.outputs || [])
185
+ .filter((p) => p && p.id && (!p.when || p.when === 'always' || p.when === 'clean'))
186
+ .map((p) => p.id);
187
+
188
+ return {
189
+ version: CASES_VERSION,
190
+ cases: [{
191
+ id: 'sample',
192
+ name: 'sample',
193
+ params,
194
+ ports: config ? { inputs: ports.inputs || [], outputs: ports.outputs || [] } : null,
195
+ inputs,
196
+ cwd: { kind: 'scratch' },
197
+ timeoutMs: null,
198
+ expect: { verdict: 'clean', fired },
199
+ }],
200
+ };
201
+ }
@@ -2,7 +2,7 @@
2
2
  // The editable v2 template: normalize/serialize, the node/wire factory and the
3
3
  // drop-legality check the composer runs on the POINTER PATH (so it must stay a
4
4
  // Map lookup plus a type check — never a Tarjan walk).
5
- import { TEMPLATE_VERSION } from './constants.mjs';
5
+ import { TEMPLATE_VERSION, KEYED_KINDS } from './constants.mjs';
6
6
  import { portsOf, findPort, resolveOrOutType, inboundWires } from './ports.mjs';
7
7
 
8
8
  const isObject = (v) => Boolean(v) && typeof v === 'object' && !Array.isArray(v);
@@ -34,7 +34,7 @@ export function serializeTemplate(template) {
34
34
 
35
35
  function normalizeNode(node) {
36
36
  const out = { id: String(node.id ?? ''), kind: String(node.kind ?? '') };
37
- if (out.kind === 'agent' && node.key !== undefined) out.key = String(node.key);
37
+ if (KEYED_KINDS.includes(out.kind) && node.key !== undefined) out.key = String(node.key);
38
38
  out.x = Number(node.x);
39
39
  out.y = Number(node.y);
40
40
  out.config = isObject(node.config) ? { ...node.config } : {};
@@ -71,10 +71,10 @@ export function mintId(prefix, taken) {
71
71
  return `${prefix}${Date.now().toString(36).slice(-8)}`; // unreachable in practice
72
72
  }
73
73
 
74
- /** @param {'agent'|'task'|'end'|'and'|'or'|'combine'} kind @param {string|null} key */
74
+ /** @param {'agent'|'script'|'task'|'end'|'and'|'or'|'combine'} kind @param {string|null} key */
75
75
  export function newNode(kind, key, x = 0, y = 0, taken) {
76
76
  const node = { id: mintId('n_', taken), kind };
77
- if (kind === 'agent' && key) node.key = String(key); // V3: only agent nodes carry a key
77
+ if (KEYED_KINDS.includes(kind) && key) node.key = String(key); // V3: only keyed kinds carry a key
78
78
  node.x = Number(x) || 0;
79
79
  node.y = Number(y) || 0;
80
80
  node.config = ARITY_SET.has(kind) ? { arity: 2 } : {};