@worca/app 1.3.0-rc.2 → 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
@@ -4,6 +4,7 @@
4
4
  // and agent-gen's read-back check. Pure — shared code cannot import
5
5
  // claude-runner.mjs, so the mock-role vocabulary is INJECTED.
6
6
  import { PORT_TYPES, MAX_PORTS_PER_SIDE, PORT_ID_RE } from './constants.mjs';
7
+ import { normalizeAskBlock } from '../forms/form-def.mjs';
7
8
 
8
9
  const AGENT_KEY_RE = /^[A-Za-z][A-Za-z0-9_-]{0,63}$/;
9
10
  // PORT_ID_RE is P1's, NOT a local copy: spec §3 puts it in constants.mjs
@@ -64,6 +65,21 @@ export function validateMetaV2(raw, opts = {}) {
64
65
  return { errors: normalizeAgentMeta(raw, { ...opts, warn: () => {} }).errors };
65
66
  }
66
67
 
68
+ /** money-saved design §5: `{ factor?: >=0, hours?: >=0 }`; anything else is dropped with a warning. */
69
+ export function readHumanEffort(raw, warn = () => {}) {
70
+ if (raw === undefined) return null;
71
+ const ok = (v) => v === undefined || (typeof v === 'number' && Number.isFinite(v) && v >= 0);
72
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw) || !ok(raw.factor) || !ok(raw.hours)
73
+ || (raw.factor === undefined && raw.hours === undefined)) {
74
+ warn(`humanEffort must be { factor?: number >= 0, hours?: number >= 0 }; ignored: ${JSON.stringify(raw)}`);
75
+ return null;
76
+ }
77
+ const out = {};
78
+ if (raw.factor !== undefined) out.factor = raw.factor;
79
+ if (raw.hours !== undefined) out.hours = raw.hours;
80
+ return out;
81
+ }
82
+
67
83
  /**
68
84
  * @param {object} raw parsed sidecar
69
85
  * @param {{mockWriterRoles?:Set<string>, warn?:(msg:string)=>void}} [opts]
@@ -137,6 +153,19 @@ export function normalizeAgentMeta(raw, opts = {}) {
137
153
  else warn(`[agent-registry] ${key || '<unkeyed>'}.mockRole: unknown mock role "${role}"; ignored (the generic mock chain applies)`);
138
154
  }
139
155
 
156
+ // Sidecar ask forms (spec §3). Gate 1 lives in the shared forms core; a form
157
+ // that fails it is DROPPED, never fatal — the agent stays usable with generic
158
+ // questions (§5). The drop needs its OWN channel to the registry's diagnostics
159
+ // sink, because `warn`'s last line is how scanMetaLayer names the reason a
160
+ // WHOLE sidecar was skipped, and a dropped form skips nothing.
161
+ const askBlock = raw.ask === undefined ? null : normalizeAskBlock(raw.ask);
162
+ const onDropForm = typeof opts.onDropForm === 'function' ? opts.onDropForm : null;
163
+ for (const d of askBlock?.dropped || []) {
164
+ const message = `BAD_ASK_FORM: ${key || '<unkeyed>'}/${d.id}: ${d.reason}`;
165
+ if (onDropForm) onDropForm({ formId: d.id, reason: d.reason, message });
166
+ warn(`[agent-registry] ${message}`);
167
+ }
168
+
140
169
  const asksQuestions = !!raw.asksQuestions;
141
170
  const meta = {
142
171
  metaVersion: 2,
@@ -167,7 +196,13 @@ export function normalizeAgentMeta(raw, opts = {}) {
167
196
  // an absent field means "the default", so a v2 entry stays diffable against
168
197
  // the sidecar that produced it.
169
198
  if (verdict) meta.verdict = verdict;
199
+ // Absent when the agent declares no SURVIVING form, so a sidecar without forms
200
+ // round-trips byte-identically through the store (the fixed key set is what
201
+ // makes {...existing, ...raw} safe).
202
+ if (askBlock && Object.keys(askBlock.forms).length) meta.ask = { forms: askBlock.forms };
170
203
  if (SIDE_EFFECTS.has(raw.sideEffect)) meta.sideEffect = raw.sideEffect;
204
+ const humanEffort = readHumanEffort(raw.humanEffort, (m) => warn(`[agent-meta] "${key || '<unkeyed>'}": ${m}`));
205
+ if (humanEffort) meta.humanEffort = humanEffort;
171
206
  if (mockRole) meta.mockRole = mockRole;
172
207
  if (raw.wantsRequest) meta.wantsRequest = true;
173
208
  if (raw.workspaceFanOut) meta.workspaceFanOut = true;
@@ -177,7 +212,7 @@ export function normalizeAgentMeta(raw, opts = {}) {
177
212
  return { errors, meta };
178
213
  }
179
214
 
180
- function readVerdict(raw, err) {
215
+ export function readVerdict(raw, err) {
181
216
  if (raw === undefined) return null;
182
217
  const filename = raw && typeof raw === 'object' && typeof raw.filename === 'string' ? raw.filename.trim() : '';
183
218
  if (!filename) { err('verdict must be an object with a filename'); return null; }
@@ -208,7 +243,11 @@ function readPortHead(raw, side, seen, err) {
208
243
  return port;
209
244
  }
210
245
 
211
- function readInputs(raw, err, warn) {
246
+ /**
247
+ * @param {{noPromptFields?:boolean}} [opts] scripts: `as`, `directive` and `expands`
248
+ * are prompt-side and REFUSED; no default `as` is stamped either.
249
+ */
250
+ export function readInputs(raw, err, warn, opts = {}) {
212
251
  if (!Array.isArray(raw)) { err('inputs must be an array'); return []; }
213
252
  if (raw.length > MAX_PORTS_PER_SIDE) err(`inputs: at most ${MAX_PORTS_PER_SIDE} ports per side (got ${raw.length})`);
214
253
  const seen = new Set();
@@ -227,6 +266,13 @@ function readInputs(raw, err, warn) {
227
266
  }
228
267
  port.required = required;
229
268
  if (loop) port.loop = true;
269
+ if (opts.noPromptFields) {
270
+ for (const field of ['as', 'directive', 'expands']) {
271
+ if (p[field] !== undefined) err(`inputs.${port.id}: ${field} is a prompt-side field — a script input does not take it`);
272
+ }
273
+ out.push(port);
274
+ continue;
275
+ }
230
276
  if (p.expands) {
231
277
  if (port.type !== 'json') err(`inputs.${port.id}: expands is only legal on json inputs`);
232
278
  else port.expands = true;
@@ -244,9 +290,13 @@ function readInputs(raw, err, warn) {
244
290
  return out;
245
291
  }
246
292
 
247
- function readOutputs(raw, hasVerdict, err) {
293
+ /**
294
+ * @param {{allowEmptyOutputs?:boolean, who?:string}} [opts] scripts may declare zero
295
+ * outputs (a pure side effect); `who` names the declarer in the `when` error.
296
+ */
297
+ export function readOutputs(raw, hasVerdict, err, opts = {}) {
248
298
  if (!Array.isArray(raw)) { err('outputs must be an array'); return []; }
249
- if (raw.length === 0) err('at least one output port is required');
299
+ if (raw.length === 0 && !opts.allowEmptyOutputs) err('at least one output port is required');
250
300
  if (raw.length > MAX_PORTS_PER_SIDE) err(`outputs: at most ${MAX_PORTS_PER_SIDE} ports per side (got ${raw.length})`);
251
301
  const seen = new Set();
252
302
  const out = [];
@@ -257,7 +307,7 @@ function readOutputs(raw, hasVerdict, err) {
257
307
  if (!OUTPUT_WHEN.has(when)) err(`outputs.${port.id}: when must be one of ${[...OUTPUT_WHEN].join(', ')}`);
258
308
  else {
259
309
  if (when !== 'always' && !hasVerdict) {
260
- err(`outputs.${port.id}: when "${when}" requires the agent to declare verdict: { filename }`);
310
+ err(`outputs.${port.id}: when "${when}" requires the ${opts.who || 'agent'} to declare verdict: { filename }`);
261
311
  }
262
312
  port.when = when;
263
313
  }
@@ -8,12 +8,18 @@
8
8
  export const TEMPLATE_VERSION = 2;
9
9
 
10
10
  /** Every node kind a template may carry (palette order). */
11
- export const KINDS = Object.freeze(['agent', 'task', 'end', 'and', 'or', 'combine']);
11
+ export const KINDS = Object.freeze(['agent', 'script', 'task', 'end', 'and', 'or', 'combine']);
12
12
 
13
- /** The flow cards: every kind that is not a spawned agent. They are pure engine
13
+ /** The flow cards: every kind that is not a spawned card. They are pure engine
14
14
  * executions — instant, $0, no spawn. */
15
15
  export const FLOW_KINDS = Object.freeze(['task', 'end', 'and', 'or', 'combine']);
16
16
 
17
+ /** The kinds that carry a registry `key` — a placed sidecar, an agent's or a
18
+ * script's. Every consumer that means "a keyed, ported card" tests THIS;
19
+ * consumers that mean "spawns Claude" (tunables, the Ports prompt block) keep
20
+ * `kind === 'agent'`. A script is not a flow kind: it spawns and it is not free. */
21
+ export const KEYED_KINDS = Object.freeze(['agent', 'script']);
22
+
17
23
  /** The flow cards' DISPLAY names — one label per FLOW_KIND. The single copy:
18
24
  * the run manifest (`manifest.mjs` cell labels), the run monitor and the
19
25
  * New-pipeline loop caption all read this table, so a flow card can never be
@@ -38,6 +44,12 @@ export const DEFAULT_MAX_CYCLES = 3;
38
44
  * sequencing: no file, no renderer, no directive, no mode effect. */
39
45
  export const AWAIT_PORT = Object.freeze({ id: 'await', type: 'any', required: false, synthetic: true });
40
46
 
47
+ /** The engine-owned `params` input a placed SCRIPT card can opt into (`config.paramsPort`): a json
48
+ * wire whose object overlays the card's params before the program runs. Deliberately NOT
49
+ * `synthetic` — that flag means "the await gate" to geometry, the manifest and the view — so it
50
+ * draws, wires and schedules as an ordinary input; `engine` only marks it as not the script's own. */
51
+ export const PARAMS_PORT = Object.freeze({ id: 'params', type: 'json', required: false, engine: 'params' });
52
+
41
53
  /** Task card — the graph's single source: zero inputs, one always-firing md
42
54
  * output carrying the rendered task md. */
43
55
  export const TASK_PORTS = Object.freeze({
@@ -9,6 +9,7 @@ import { NODE_W, nodeSize, portAnchor } from './geometry.mjs';
9
9
  import { rankNodes } from './layout.mjs';
10
10
  import { classifyLoops } from './loops.mjs';
11
11
  import { portsOf } from './ports.mjs';
12
+ import { KEYED_KINDS } from './constants.mjs';
12
13
 
13
14
  export const FLOW_SCALE = 0.65;
14
15
  export const FLOW_PAD = 20; // host padding on X, NOT scaled
@@ -42,7 +43,7 @@ export function flowOrder(tpl, portsFn, { agentOrder = null } = {}) {
42
43
  nonLoopIn.set(w.to.node, (nonLoopIn.get(w.to.node) || 0) + 1);
43
44
  }
44
45
  const given = new Map((Array.isArray(agentOrder) ? agentOrder : []).map((id, i) => [id, i]));
45
- const cls = (n) => (n.kind === 'task' ? 0 : n.kind === 'end' ? 3 : (n.kind !== 'agent' && !nonLoopIn.get(n.id) ? 2 : 1));
46
+ const cls = (n) => (n.kind === 'task' ? 0 : n.kind === 'end' ? 3 : (!KEYED_KINDS.includes(n.kind) && !nonLoopIn.get(n.id) ? 2 : 1));
46
47
  const key = (n) => [cls(n), rank[n.id] ?? 0, given.has(n.id) ? given.get(n.id) : Number.MAX_SAFE_INTEGER, n.id];
47
48
  return nodes.map((n) => ({ n, k: key(n) })).sort((a, b) => {
48
49
  for (let i = 0; i < 4; i += 1) if (a.k[i] !== b.k[i]) return a.k[i] < b.k[i] ? -1 : 1;
@@ -4,17 +4,19 @@
4
4
  // when agents, wiring, gates, loops and their budgets are identical. Tunables
5
5
  // (model/effort/fanOut/askQuestions/subagentModel), positions, ids and names
6
6
  // are deliberately invisible — they are per-run tuning, not topology.
7
- // Pure, browser-safe, no imports.
7
+ // Pure and browser-safe: its one import is the shared constants table.
8
+ import { KEYED_KINDS } from './constants.mjs';
8
9
 
9
10
  /** The config keys that ARE topology, per kind (mirrors validate.mjs KNOWN_CONFIG minus the tunables). */
10
11
  const TOPOLOGY_CONFIG = Object.freeze({
11
- task: ['planStoreSeed'], agent: ['awaitAll'], and: ['arity'], or: ['arity'], combine: ['arity'], end: [],
12
+ task: ['planStoreSeed'], agent: ['awaitAll'], script: ['awaitAll', 'ports', 'params'],
13
+ and: ['arity'], or: ['arity'], combine: ['arity'], end: [],
12
14
  });
13
15
 
14
16
  export function nodeLabel(node) {
15
17
  const kind = String(node?.kind ?? '');
16
18
  const cfg = node && typeof node.config === 'object' && node.config ? node.config : {};
17
- const parts = [kind, kind === 'agent' ? String(node.key ?? '') : ''];
19
+ const parts = [kind, KEYED_KINDS.includes(kind) ? String(node.key ?? '') : ''];
18
20
  for (const k of TOPOLOGY_CONFIG[kind] || []) {
19
21
  const v = cfg[k];
20
22
  // A gate's arity defaults to 2; an absent flag equals an explicit false.
@@ -14,7 +14,7 @@
14
14
  // agent cell, and the sub_agents.ui_phase attribution column still needs it. The
15
15
  // workflows.mjs copy is gone (the v1 topology helpers went with the v1 engine),
16
16
  // so THIS is the only copy — shared code may not import workflows.mjs.
17
- import { TEMPLATE_VERSION, AWAIT_PORT, DEFAULT_MAX_CYCLES, FLOW_LABEL } from './constants.mjs';
17
+ import { TEMPLATE_VERSION, AWAIT_PORT, DEFAULT_MAX_CYCLES, FLOW_LABEL, KEYED_KINDS } from './constants.mjs';
18
18
  import { portsFnFor, portsOf, resolveOrOutType } from './ports.mjs';
19
19
  import { classifyLoops } from './loops.mjs';
20
20
  import { rankNodes } from './layout.mjs';
@@ -89,13 +89,14 @@ const isWire = (w) => Boolean(w) && typeof w === 'object' && !Array.isArray(w)
89
89
  /**
90
90
  * @param {object} tpl resolved v2 template
91
91
  * @param {Record<string,object>} agentsByKey merged registry metas
92
- * @param {{overlays?:{nodes?:object, wires?:object}}} [opts] effective per-node config + per-wire budgets
92
+ * @param {{overlays?:{nodes?:object, wires?:object}, scripts?:Record<string,object>}} [opts] effective per-node config + per-wire budgets; the script registry slice
93
93
  */
94
94
  export function buildGraphManifest(tpl, agentsByKey, opts = {}) {
95
95
  const overlays = opts.overlays || {};
96
+ const scripts = opts.scripts || {};
96
97
  const nodeOverlays = overlays.nodes || {};
97
98
  const wireOverlays = overlays.wires || {};
98
- const portsFn = portsFnFor(agentsByKey);
99
+ const portsFn = portsFnFor(agentsByKey, scripts);
99
100
  // Objects with real endpoints only. `filter(Boolean)` kept a truthy non-object
100
101
  // node and an endpoint-less wire, and `w.from.node` threw one map later — the
101
102
  // manifest is built from an ALREADY validated template, but it is also built
@@ -109,7 +110,9 @@ export function buildGraphManifest(tpl, agentsByKey, opts = {}) {
109
110
 
110
111
  const manifestNodes = nodes.map((node) => {
111
112
  const resolved = portsOf(portsFn, node);
112
- const meta = node.kind === 'agent' ? (agentsByKey?.[node.key] || null) : null;
113
+ const keyed = KEYED_KINDS.includes(node.kind);
114
+ const meta = node.kind === 'agent' ? (agentsByKey?.[node.key] || null)
115
+ : node.kind === 'script' ? (scripts?.[node.key] || null) : null;
113
116
  const over = nodeOverlays[node.id] || {};
114
117
  const cfg = node.config || {};
115
118
  const outType = (port) => (node.kind === 'or' && (!port.type || port.type === 'any')
@@ -117,14 +120,12 @@ export function buildGraphManifest(tpl, agentsByKey, opts = {}) {
117
120
  const cell = {
118
121
  id: node.id,
119
122
  kind: node.kind,
120
- key: node.kind === 'agent' ? node.key : null,
123
+ key: keyed ? node.key : null,
121
124
  x: Number(node.x) || 0,
122
125
  y: Number(node.y) || 0,
123
- label: node.kind === 'agent' ? (meta?.displayName || node.key) : (FLOW_LABEL[node.kind] || node.kind),
124
- // The v1 stepper bucket. UI_PHASE knows the 11 builtins; a custom agent
125
- // buckets under its own key (the sidecar's `uiPhase` died with the v1
126
- // vocabulary). The whole field goes with the phase shim in Task 16.
127
- uiPhase: node.kind === 'agent' ? (UI_PHASE[node.key] || node.key) : node.kind,
126
+ label: keyed ? (meta?.displayName || node.key) : (FLOW_LABEL[node.kind] || node.kind),
127
+ // The v1 stepper bucket: the builtin map for agents, the bare key for every other keyed card.
128
+ uiPhase: node.kind === 'agent' ? (UI_PHASE[node.key] || node.key) : keyed ? node.key : node.kind,
128
129
  // The AUTHORED config, verbatim and complete (unknown keys included —
129
130
  // V17's "preserved and ignored" promise). The manifest is the ONLY
130
131
  // persisted copy of the topology: P4 rebuilds the template from it on a
@@ -133,7 +134,8 @@ export function buildGraphManifest(tpl, agentsByKey, opts = {}) {
133
134
  config: { ...(node.config || {}) },
134
135
  ports: {
135
136
  inputs: resolved.inputs.filter((p) => !p.synthetic).map((p) => ({
136
- id: p.id, type: p.type, required: p.required !== false, loop: !!p.loop, expands: !!p.expands })),
137
+ id: p.id, type: p.type, required: p.required !== false, loop: !!p.loop, expands: !!p.expands,
138
+ ...(p.engine ? { engine: p.engine } : {}) })),
137
139
  outputs: resolved.outputs.map((p) => ({ id: p.id, type: outType(p), when: p.when || 'always' })),
138
140
  await: resolved.inputs.some((p) => p.synthetic),
139
141
  },
@@ -151,6 +153,12 @@ export function buildGraphManifest(tpl, agentsByKey, opts = {}) {
151
153
  // alone — the workflow row is never re-read after the run starts.
152
154
  cell.subagentModel = over.subagentModel ?? cfg.subagentModel ?? '';
153
155
  }
156
+ if (node.kind === 'script') {
157
+ cell.color = meta?.color || '';
158
+ cell.icon = sanitizeIcon(meta?.icon);
159
+ cell.runtime = meta?.runtime || '';
160
+ cell.awaitAll = !!(over.awaitAll ?? cfg.awaitAll ?? false);
161
+ }
154
162
  if (node.kind === 'and' || node.kind === 'or' || node.kind === 'combine') {
155
163
  cell.arity = Number.isInteger(cfg.arity) ? cfg.arity : 2;
156
164
  }
@@ -183,7 +191,7 @@ export function buildGraphManifest(tpl, agentsByKey, opts = {}) {
183
191
  uiPhase: n.uiPhase,
184
192
  label: n.label,
185
193
  color: n.color || '',
186
- sub: (n.key && agentsByKey?.[n.key]?.description) || '',
194
+ sub: (n.key && (agentsByKey?.[n.key] || scripts?.[n.key])?.description) || '',
187
195
  cycles: n.ports.inputs.some((p) => p.loop && isWired.has(`${n.id}.${p.id}`)),
188
196
  model: n.model || '',
189
197
  effort: n.effort || '',
@@ -226,6 +234,7 @@ export function manifestPortsFn(manifest) {
226
234
  displayName: cell.label,
227
235
  color: cell.color,
228
236
  icon: cell.icon,
237
+ runtime: cell.runtime,
229
238
  // A verdict-bearing node is one with a conditional output — enough for V13
230
239
  // and firedOutputs; the filename itself never leaves the engine.
231
240
  verdict: cell.ports.outputs.some((p) => p.when && p.when !== 'always') ? { filename: '' } : undefined,
@@ -245,7 +254,7 @@ export function manifestTemplate(manifest) {
245
254
  // old `{arity, awaitAll}` reconstruction silently dropped everything else
246
255
  // (`planStoreSeed` on the Task card of `wf_provided-plan`, for one).
247
256
  const node = { id: n.id, kind: n.kind, x: n.x, y: n.y, config: { ...(n.config || {}) } };
248
- if (n.kind === 'agent') node.key = n.key;
257
+ if (KEYED_KINDS.includes(n.kind)) node.key = n.key;
249
258
  return node;
250
259
  }),
251
260
  wires: (manifest?.graph?.wires || []).map((w) => {
@@ -5,8 +5,9 @@
5
5
  // malformed template — an unknown agent key (V4), a dangling endpoint (V5) or
6
6
  // an unknown kind (V3) is an error for the validator to COLLECT, so every
7
7
  // lookup guards instead of crashing.
8
- import { AWAIT_PORT, FLOW_KINDS, gatePorts, TASK_PORTS, END_PORTS } from './constants.mjs';
8
+ import { AWAIT_PORT, PARAMS_PORT, FLOW_KINDS, gatePorts, TASK_PORTS, END_PORTS } from './constants.mjs';
9
9
  import { hasBlocking } from './verdict.mjs';
10
+ import { readConfigPorts, hasParamsPort } from './script-meta.mjs';
10
11
 
11
12
  /** Engine flow-card ports. `undefined` for an unknown kind — V3's error. */
12
13
  export function flowPorts(node) {
@@ -22,31 +23,56 @@ export function flowPorts(node) {
22
23
  return undefined;
23
24
  }
24
25
 
26
+ const toIndex = (v) => (v instanceof Map ? v : new Map(Object.entries(v && typeof v === 'object' ? v : {})));
27
+
25
28
  /**
26
- * Build the ports function over the merged agent registry (an object or a Map
27
- * keyed by agent key). Agent nodes get their sidecar's typed ports PLUS the
28
- * engine-synthesized `await` gate appended LAST; flow cards get flowPorts.
29
- * Three outcomes, and V4 tells them apart:
30
- * unknown key -> undefined (known:false)
31
- * key without v2 ports -> {known:true, ported:false}
32
- * ported v2 sidecar -> {known:true, ported:true, inputs:[...meta, await]}
29
+ * Build the ports function over the merged AGENT registry and the merged SCRIPT
30
+ * registry (objects or Maps keyed by key — the two namespaces never overlap, D16).
31
+ * Keyed nodes get their sidecar's typed ports PLUS the engine-synthesized `await`
32
+ * gate appended LAST; flow cards get flowPorts. Outcomes V4 tells apart:
33
+ * unknown key -> undefined (known:false)
34
+ * agent key without v2 ports -> {known:true, ported:false}
35
+ * config-ported script, no config -> {known:true, ported:false, configPortsMissing:true}
36
+ * config-ported script, bad config -> {known:true, ported:false, configPortsInvalid:true, configPortsErrors}
37
+ * ported -> {known:true, ported:true, inputs:[...ports, params?, await], outputs}
33
38
  */
34
- export function portsFnFor(agentsByKey) {
35
- const index = agentsByKey instanceof Map
36
- ? agentsByKey
37
- : new Map(Object.entries(agentsByKey && typeof agentsByKey === 'object' ? agentsByKey : {}));
39
+ export function portsFnFor(agentsByKey, scriptsByKey = {}) {
40
+ const agents = toIndex(agentsByKey);
41
+ const scripts = toIndex(scriptsByKey);
38
42
  return (node) => {
39
43
  if (!node || typeof node !== 'object') return undefined;
40
- if (node.kind !== 'agent') return flowPorts(node);
41
- const meta = index.get(node.key);
42
- if (!meta) return undefined;
43
- if (!Array.isArray(meta.inputs) || !Array.isArray(meta.outputs)) {
44
- return { ...meta, known: true, ported: false, inputs: [], outputs: [] };
45
- }
46
- return { ...meta, known: true, ported: true, inputs: [...meta.inputs, AWAIT_PORT], outputs: [...meta.outputs] };
44
+ if (node.kind === 'agent') return sidecarPorts(agents.get(node.key));
45
+ if (node.kind === 'script') return scriptPorts(scripts.get(node.key), node);
46
+ return flowPorts(node);
47
47
  };
48
48
  }
49
49
 
50
+ function sidecarPorts(meta) {
51
+ if (!meta) return undefined;
52
+ if (!Array.isArray(meta.inputs) || !Array.isArray(meta.outputs)) {
53
+ return { ...meta, known: true, ported: false, inputs: [], outputs: [] };
54
+ }
55
+ return { ...meta, known: true, ported: true, inputs: [...meta.inputs, AWAIT_PORT], outputs: [...meta.outputs] };
56
+ }
57
+
58
+ /** A script card's engine inputs, after its own: the opt-in `params` port, then `await` LAST. */
59
+ const engineInputs = (meta, node) => (hasParamsPort(meta, node?.config) ? [PARAMS_PORT, AWAIT_PORT] : [AWAIT_PORT]);
60
+
61
+ /** Script ports: the sidecar's, or — for `ports: "config"` (D14) — the placed
62
+ * node's `config.ports` through the shared readers. */
63
+ function scriptPorts(meta, node) {
64
+ if (!meta) return undefined;
65
+ if (meta.ports !== 'config') {
66
+ const p = sidecarPorts(meta);
67
+ return p.ported ? { ...p, inputs: [...meta.inputs, ...engineInputs(meta, node)] } : p;
68
+ }
69
+ const cfg = node?.config?.ports;
70
+ if (cfg === undefined) return { ...meta, known: true, ported: false, configPortsMissing: true, inputs: [], outputs: [] };
71
+ const { ports, errors } = readConfigPorts(cfg, { hasVerdict: !!meta.verdict });
72
+ if (!ports) return { ...meta, known: true, ported: false, configPortsInvalid: true, configPortsErrors: errors, inputs: [], outputs: [] };
73
+ return { ...meta, known: true, ported: true, inputs: [...ports.inputs, ...engineInputs(meta, node)], outputs: [...ports.outputs] };
74
+ }
75
+
50
76
  /**
51
77
  * Resolve one node's ports defensively. NEVER throws — a portsFn that throws,
52
78
  * a missing key and an unknown kind all collapse to `known:false`, and a
@@ -0,0 +1,257 @@
1
+ // src/shared/graph/script-cases.mjs
2
+ // Saved test cases for a script (workbench spec §3.2). ONE normalizer behind the
3
+ // store's hard 400, the plugin validator and the bench UI, so a case that saves
4
+ // in the app is a case a plugin can ship and `worca script test` can run.
5
+ // Cases live in `<key>.tests.json` beside the script — or, for a built-in or
6
+ // plugin key, in the user layer with no meta beside it (W18: a tests file
7
+ // without a meta is an OVERLAY, never a broken script).
8
+ // Pure: no fs, no node: builtins, no DOM — the Scripts page imports it straight
9
+ // from /src/shared (test/shared-graph-purity.test.mjs).
10
+ import { readConfigPorts, paramValueError, MIN_TIMEOUT_MS } from './script-meta.mjs';
11
+
12
+ export const CASES_VERSION = 1;
13
+ export const MAX_CASES = 32;
14
+ export const MAX_CASE_NAME = 80;
15
+ export const MAX_CASE_INPUT_BYTES = 262144; // W9: a case input is inline text
16
+ /** Case ids are key-shaped (dashes and underscores allowed), not port-shaped. */
17
+ export const CASE_ID_RE = /^[A-Za-z][A-Za-z0-9_-]{0,63}$/;
18
+ export const EXPECT_VERDICTS = Object.freeze(['clean', 'blocking', 'error']);
19
+
20
+ const isObject = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
21
+ /** BYTES, not characters: the cap is a file-size cap (TextEncoder works in both hosts). */
22
+ const byteLength = (s) => new TextEncoder().encode(String(s)).length;
23
+
24
+ /**
25
+ * The port set a case runs against: the sidecar's own ports, or — for a
26
+ * `ports: "config"` script (D14) — the set the case carries, falling back to the
27
+ * sidecar's `defaultPorts`. The verdict is always the sidecar's.
28
+ * @param {object} meta a normalized script meta
29
+ * @param {{ports?: object}} [kase]
30
+ * @returns {{inputs: Array, outputs: Array, verdict: object|null}|{errors: string[]}}
31
+ */
32
+ export function casePortSet(meta, kase) {
33
+ const verdict = meta && meta.verdict ? meta.verdict : null;
34
+ if (!meta || meta.ports !== 'config') {
35
+ return {
36
+ inputs: Array.isArray(meta && meta.inputs) ? meta.inputs : [],
37
+ outputs: Array.isArray(meta && meta.outputs) ? meta.outputs : [],
38
+ verdict,
39
+ };
40
+ }
41
+ const raw = kase && kase.ports !== undefined && kase.ports !== null ? kase.ports : meta.defaultPorts;
42
+ const { ports, errors } = readConfigPorts(raw, { hasVerdict: !!verdict });
43
+ if (!ports) return { errors };
44
+ return { inputs: ports.inputs, outputs: ports.outputs, verdict };
45
+ }
46
+
47
+ /**
48
+ * Normalize a `<key>.tests.json` document against its script.
49
+ * @param {object|undefined} raw the parsed file, or undefined (no cases)
50
+ * @param {object} meta a normalized script meta
51
+ * @param {{shipped?: boolean, lenient?: boolean}} [opts] shipped: a built-in or plugin
52
+ * file — a project cwd cannot travel to another machine, so it is refused there.
53
+ * lenient: the READ path. A case outlives an edit to its script's sidecar: a param,
54
+ * an input port or an expected output the script NO LONGER declares — or no longer
55
+ * declares the same WAY (an enum option dropped, a port retyped) — is skipped
56
+ * instead of failing the case — a strict read would drop the whole case, and the
57
+ * next write (the page sends the full list it was given) would delete it for good.
58
+ * Every other rule still applies; the WRITE path is always strict.
59
+ * @returns {{cases: Array, errors: string[]}} a case that produced an error is
60
+ * NOT returned: the store refuses the whole write, the UI lists the reasons.
61
+ */
62
+ export function normalizeCases(raw, meta, { shipped = false, lenient = false } = {}) {
63
+ const errors = [];
64
+ const cases = [];
65
+ if (raw === undefined || raw === null) return { cases, errors };
66
+ if (!isObject(raw)) {
67
+ errors.push('cases file must be an object { version: 1, cases: [...] }');
68
+ return { cases, errors };
69
+ }
70
+ if (raw.version !== CASES_VERSION) errors.push(`cases file requires version ${CASES_VERSION}`);
71
+ if (!Array.isArray(raw.cases)) {
72
+ errors.push('cases must be an array');
73
+ return { cases, errors };
74
+ }
75
+ if (raw.cases.length > MAX_CASES) errors.push(`at most ${MAX_CASES} cases (got ${raw.cases.length})`);
76
+ const key = typeof (meta && meta.key) === 'string' ? meta.key : '';
77
+ const declared = new Map((Array.isArray(meta && meta.params) ? meta.params : []).filter(Boolean).map((p) => [p.id, p]));
78
+ const seen = new Set();
79
+
80
+ for (let i = 0; i < raw.cases.length && cases.length < MAX_CASES; i += 1) {
81
+ const kase = raw.cases[i];
82
+ if (!isObject(kase)) { errors.push(`cases[${i}]: each case must be an object`); continue; }
83
+ const id = typeof kase.id === 'string' ? kase.id.trim() : '';
84
+ if (!CASE_ID_RE.test(id)) { errors.push(`cases[${i}]: bad case id "${id}"`); continue; }
85
+ if (seen.has(id)) { errors.push(`cases[${i}]: duplicate case id "${id}"`); continue; }
86
+ seen.add(id);
87
+ const before = errors.length;
88
+ const bad = (msg) => errors.push(`case "${id}": ${msg}`);
89
+
90
+ let name = id;
91
+ if (kase.name !== undefined && kase.name !== null) {
92
+ if (typeof kase.name !== 'string') bad('name must be a string');
93
+ else if (kase.name.trim().length > MAX_CASE_NAME) bad(`name must be ${MAX_CASE_NAME} characters or fewer`);
94
+ else name = kase.name.trim() || id;
95
+ }
96
+
97
+ // Ports: a config-ported script carries its own set per case (W5); a
98
+ // sidecar-ported one must not carry one at all.
99
+ let ownPorts = null;
100
+ if (meta && meta.ports === 'config') {
101
+ if (kase.ports !== undefined && kase.ports !== null && !isObject(kase.ports)) {
102
+ bad('ports must be an object { inputs: [...], outputs: [...] }');
103
+ } else if (isObject(kase.ports)) ownPorts = kase.ports;
104
+ } else if (kase.ports !== undefined && kase.ports !== null && !lenient) {
105
+ // lenient: the script dropped `ports: "config"` after the case was saved.
106
+ // The stale set is ignored and the sidecar's own ports stand — dropping the
107
+ // case here would blank the list, and the page's next write deletes it.
108
+ bad('ports is only legal for a script that declares ports: "config"');
109
+ }
110
+ let set = casePortSet(meta, { ports: ownPorts });
111
+ // Same rule for a set that no longer validates (a cleared verdict makes a
112
+ // conditional output illegal): fall back to the script's default port set.
113
+ if (set.errors && lenient && ownPorts) set = casePortSet(meta, {});
114
+ if (set.errors) for (const e of set.errors) bad(e);
115
+ const inPorts = new Map((set.inputs || [])
116
+ .filter((p) => p && !p.synthetic && p.id !== 'await').map((p) => [p.id, p]));
117
+ const outIds = new Set((set.outputs || []).map((p) => p && p.id).filter(Boolean));
118
+
119
+ const params = {};
120
+ if (kase.params !== undefined && kase.params !== null) {
121
+ if (!isObject(kase.params)) bad('params must be an object');
122
+ else {
123
+ for (const [pid, value] of Object.entries(kase.params)) {
124
+ const d = declared.get(pid);
125
+ if (!d) {
126
+ if (lenient) continue;
127
+ bad(`unknown param "${pid}" — script "${key}" declares ${declared.size ? [...declared.keys()].join(', ') : 'no params'}`);
128
+ continue;
129
+ }
130
+ const why = paramValueError(d, value);
131
+ // lenient: the param is still declared but its SHAPE changed (an enum
132
+ // option dropped, a type swapped). Skipped like an undeclared one —
133
+ // dropping the case blanks the list just the same.
134
+ if (why) { if (!lenient) bad(`param "${pid}" ${why}`); }
135
+ else params[pid] = value;
136
+ }
137
+ }
138
+ }
139
+
140
+ const inputs = {};
141
+ if (kase.inputs !== undefined && kase.inputs !== null) {
142
+ if (!isObject(kase.inputs)) bad('inputs must be an object keyed by input port id');
143
+ else {
144
+ for (const [pid, spec] of Object.entries(kase.inputs)) {
145
+ const port = inPorts.get(pid);
146
+ if (!port) { if (!lenient) bad(`"${pid}" is not an input port of script "${key}"`); continue; }
147
+ if (!isObject(spec)) { bad(`input "${pid}" must be { text } or { fired: true }`); continue; }
148
+ // lenient: the port was RETYPED under the stored spec — skipped like a
149
+ // removed one, because a dropped case is a deleted case one save later.
150
+ if (port.type === 'void') {
151
+ if (spec.fired !== true) { if (!lenient) bad(`input "${pid}" is a void port — use { fired: true }`); }
152
+ else inputs[pid] = { fired: true };
153
+ continue;
154
+ }
155
+ if (typeof spec.text !== 'string') { if (!lenient) bad(`input "${pid}" must be { text }`); continue; }
156
+ if (byteLength(spec.text) > MAX_CASE_INPUT_BYTES) { bad(`input "${pid}" is over ${MAX_CASE_INPUT_BYTES} bytes`); continue; }
157
+ if (port.type === 'json') {
158
+ try { JSON.parse(spec.text); } catch { if (!lenient) bad(`input "${pid}" is not valid JSON`); continue; }
159
+ }
160
+ inputs[pid] = { text: spec.text };
161
+ }
162
+ }
163
+ }
164
+
165
+ // W1: the scratch folder or a REGISTERED project, never an absolute path —
166
+ // a case travels between machines inside a plugin.
167
+ let cwd = { kind: 'scratch' };
168
+ if (kase.cwd !== undefined && kase.cwd !== null) {
169
+ if (!isObject(kase.cwd) || (kase.cwd.kind !== 'scratch' && kase.cwd.kind !== 'project')) {
170
+ bad('cwd must be { kind: "scratch" } or { kind: "project", projectKey }');
171
+ } else if (kase.cwd.kind === 'project') {
172
+ const pk = typeof kase.cwd.projectKey === 'string' ? kase.cwd.projectKey.trim() : '';
173
+ if (!pk) bad('cwd: a project folder needs a projectKey');
174
+ else if (shipped) bad('a shipped case must run in the scratch folder');
175
+ else cwd = { kind: 'project', projectKey: pk };
176
+ }
177
+ }
178
+
179
+ let timeoutMs = null;
180
+ if (kase.timeoutMs !== undefined && kase.timeoutMs !== null) {
181
+ if (!Number.isInteger(kase.timeoutMs) || kase.timeoutMs < MIN_TIMEOUT_MS) {
182
+ bad(`timeoutMs must be an integer >= ${MIN_TIMEOUT_MS}`);
183
+ } else timeoutMs = kase.timeoutMs;
184
+ }
185
+
186
+ let expect = null;
187
+ if (kase.expect !== undefined && kase.expect !== null) {
188
+ if (!isObject(kase.expect)) bad('expect must be an object { verdict?, fired?, summaryIncludes? }');
189
+ else {
190
+ const e = {};
191
+ if (kase.expect.verdict !== undefined) {
192
+ if (!EXPECT_VERDICTS.includes(kase.expect.verdict)) bad(`expect.verdict must be one of ${EXPECT_VERDICTS.join(', ')}`);
193
+ else e.verdict = kase.expect.verdict;
194
+ }
195
+ if (kase.expect.fired !== undefined) {
196
+ const list = Array.isArray(kase.expect.fired) && kase.expect.fired.every((x) => typeof x === 'string')
197
+ ? kase.expect.fired : null;
198
+ if (!list) bad('expect.fired must be a list of output port ids');
199
+ else {
200
+ // lenient: an output the script no longer declares drops out of the expectation.
201
+ const kept = lenient ? list.filter((x) => outIds.has(x)) : list;
202
+ const unknown = kept.filter((x) => !outIds.has(x));
203
+ if (unknown.length) bad(`expect.fired names ${unknown.join(', ')}, which is not an output port`);
204
+ // An expectation filtered down to NOTHING is dropped, not kept as []: an empty
205
+ // list is a positive claim ("nothing may fire") the author never made.
206
+ else if (kept.length || kept.length === list.length) e.fired = [...kept];
207
+ }
208
+ }
209
+ if (kase.expect.summaryIncludes !== undefined) {
210
+ if (typeof kase.expect.summaryIncludes !== 'string' || !kase.expect.summaryIncludes) {
211
+ bad('expect.summaryIncludes must be a non-empty string');
212
+ } else e.summaryIncludes = kase.expect.summaryIncludes;
213
+ }
214
+ if (Object.keys(e).length) expect = e;
215
+ }
216
+ }
217
+
218
+ if (errors.length !== before) continue;
219
+ cases.push({
220
+ id,
221
+ name,
222
+ params,
223
+ ports: meta && meta.ports === 'config' ? { inputs: set.inputs, outputs: set.outputs } : null,
224
+ inputs,
225
+ cwd,
226
+ timeoutMs,
227
+ expect,
228
+ });
229
+ }
230
+ return { cases, errors };
231
+ }
232
+
233
+ /**
234
+ * Compare a bench result with a case's expectation (spec §3.2). Absent
235
+ * expectation -> null: the case reports its result and is never red.
236
+ * @param {object|null|undefined} expect
237
+ * @param {{status: string, fired: string[], summary: string}} result
238
+ * @returns {{pass: boolean, diffs: string[]}|null}
239
+ */
240
+ export function evaluateExpect(expect, result) {
241
+ if (!isObject(expect)) return null;
242
+ const diffs = [];
243
+ const status = result && typeof result.status === 'string' ? result.status : 'nothing';
244
+ if (expect.verdict !== undefined && expect.verdict !== status) diffs.push(`expected ${expect.verdict}, got ${status}`);
245
+ if (Array.isArray(expect.fired)) {
246
+ const want = [...new Set(expect.fired)].sort();
247
+ const got = [...new Set(Array.isArray(result && result.fired) ? result.fired : [])].sort();
248
+ if (want.length !== got.length || want.some((x, n) => x !== got[n])) {
249
+ diffs.push(`expected fired ${want.join(', ') || '(none)'}, got ${got.join(', ') || '(none)'}`);
250
+ }
251
+ }
252
+ if (typeof expect.summaryIncludes === 'string' && expect.summaryIncludes) {
253
+ const summary = String((result && result.summary) || '');
254
+ if (!summary.includes(expect.summaryIncludes)) diffs.push(`expected the summary to contain "${expect.summaryIncludes}"`);
255
+ }
256
+ return { pass: diffs.length === 0, diffs };
257
+ }