@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,597 @@
1
+ // src/core/graph/script-runner.mjs
2
+ // The script executor (spec §4–§6): spawn the program as a child process, hand it
3
+ // the bound input paths and the allocated output paths in ONE JSON envelope, read
4
+ // ONE JSON frame back, materialize outputs, write the verdict. Two runtimes:
5
+ // node — process.execPath script-child.mjs <file>, envelope on stdin (§5.1)
6
+ // shell — /bin/sh -c | cmd.exe /d /s /c, envelope as WORCA_* env, output captured (§5.2)
7
+ // Cost is $0; the result is repeatable; every error thrown here carries
8
+ // errorClass:null (D9) so the node site's '*' row applies — pause, resumable —
9
+ // and a 10-minute test suite is never "retried as network".
10
+ import { spawn, spawnSync } from 'node:child_process';
11
+ import { existsSync, statSync } from 'node:fs';
12
+ import { readFile, writeFile, mkdir, rm } from 'node:fs/promises';
13
+ import { dirname, join } from 'node:path';
14
+ import { fileURLToPath } from 'node:url';
15
+ import { mockEnabled, buildSpawnEnv } from '../claude-runner.mjs';
16
+ import { scrubbedEnv } from '../plugin-shim.mjs'; // PATH/HOME + the Windows start-up baseline (P11); no cycle: plugin-shim imports no graph module
17
+ import { normalizeReview } from '../protocol.mjs';
18
+ import { readVerdict, missingVerdictWarning } from './exec-io.mjs';
19
+ import { AWAIT_PORT, PARAMS_PORT } from '../../shared/graph/constants.mjs';
20
+ import { DEFAULT_EXIT_CODES, DEFAULT_TIMEOUT_MS, MIN_TIMEOUT_MS, MAX_TIMEOUT_MS, SCRIPT_RUNTIMES, pythonMissingSentence, overlayWiredParams } from '../../shared/graph/script-meta.mjs';
21
+ import { probePython } from './python-probe.mjs';
22
+
23
+ const CHILD_PATH = fileURLToPath(new URL('./script-child.mjs', import.meta.url));
24
+ /** The `python` harness (workbench spec §7), spawned as `<python> -u worca_script.py <program.py>`. */
25
+ const PY_HARNESS_PATH = fileURLToPath(new URL('./worca_script.py', import.meta.url));
26
+ /** Forced on every python spawn: a Windows console defaults to cp1252 and would corrupt the frame. */
27
+ const PY_ENV = Object.freeze({ PYTHONUNBUFFERED: '1', PYTHONIOENCODING: 'utf-8', PYTHONUTF8: '1' });
28
+ const AWAIT_ID = AWAIT_PORT.id;
29
+ /** The three server-side variables a script must not inherit (D11). */
30
+ const STRIPPED_ENV = new Set(['WORCA_HOME', 'WORCA_RUN_ROOT', 'WORCA_HOST_PID']);
31
+ export const CAPTURE_HEAD = 256 * 1024;
32
+ export const CAPTURE_TAIL = 768 * 1024;
33
+ /** v4 T1 — three bounds on what ONE child can push into the host. MEASURED before they existed: 600 MiB of
34
+ * newline-free output kept the host's event loop at 100 % CPU for the whole 120 s timeout (the partial-line
35
+ * buffer was re-scanned from its start on every chunk — quadratic), and every streamed line is persisted by
36
+ * the run's log writer with no ceiling at all.
37
+ * MAX_LINE: a newline-free run is cut into lines of this many characters, so the buffer never holds more.
38
+ * STREAM_MAX: the live-log budget of one execution; past it ONE marker line is emitted and streaming stops
39
+ * (the capture — head + tail, the report — is unaffected).
40
+ * FRAME_MAX: the node runtime's stdout is the result frame and nothing else. */
41
+ export const MAX_LINE = 64 * 1024;
42
+ export const STREAM_MAX = 4 * 1024 * 1024;
43
+ export const FRAME_MAX = 8 * 1024 * 1024;
44
+ const SUMMARY_MAX = 2048;
45
+ const VALUE_INLINE_MAX = 64 * 1024;
46
+ const TAIL_LINES = 20;
47
+
48
+ /** Every error the runner throws: never classified recoverable (D9). */
49
+ function scriptError(message, extra = {}) {
50
+ return Object.assign(new Error(message), { errorClass: null, ...extra });
51
+ }
52
+ function abortError() {
53
+ return Object.assign(new Error('aborted'), { name: 'AbortError' });
54
+ }
55
+ const lastLines = (text, n) => String(text || '').trimEnd().split('\n').slice(-n).join('\n');
56
+ const secs = (ms) => (Math.round((ms || 0) / 100) / 10).toFixed(1);
57
+ const upperSnake = (id) => String(id).replace(/([a-z0-9])([A-Z])/g, '$1_$2').toUpperCase();
58
+
59
+ // ── pure ───────────────────────────────────────────────────────────────────────
60
+
61
+ /** `<pipelineDir>/scripts/<nodeId>-c<ordinal>[-<slice>].envelope.json` — no colons (Windows). */
62
+ export function envelopeAuditPath(ctx) {
63
+ const slice = ctx.slice?.id ? `-${ctx.slice.id}` : '';
64
+ return join(ctx.pipelineDir, 'scripts', `${ctx.node.id}-c${ctx.ordinal ?? 1}${slice}.envelope.json`);
65
+ }
66
+
67
+ /** The envelope (§4.1): BOUND inputs only (never the synthesized await), every
68
+ * declared output with its allocated path, the params, the run context. */
69
+ export function buildEnvelope(ctx) {
70
+ const { node, ordinal = 1 } = ctx;
71
+ const ports = ctx.ports || {};
72
+ const bindings = ctx.bindings || {};
73
+ const outputs = ctx.outputs || {};
74
+ const script = ctx.script || {};
75
+ const fresh = new Set(Array.isArray(ctx.trigger?.freshPorts) ? ctx.trigger.freshPorts : Object.keys(bindings));
76
+ const inputs = {};
77
+ for (const p of ports.inputs || []) {
78
+ if (!p || p.id === AWAIT_ID || p.synthetic) continue;
79
+ if (script.paramsPort && p.id === PARAMS_PORT.id) continue; // the engine's, not the program's: its json is already IN `params`
80
+ const token = bindings[p.id];
81
+ if (!token) continue;
82
+ inputs[p.id] = { type: p.type, path: p.type === 'void' ? null : (token.path ?? null), fresh: fresh.has(p.id) };
83
+ }
84
+ const outs = {};
85
+ for (const p of ports.outputs || []) {
86
+ if (!p) continue;
87
+ outs[p.id] = { type: p.type, path: p.type === 'void' ? null : (outputs[p.id]?.path ?? null) };
88
+ }
89
+ const detached = Boolean(ctx.runRoot) && Boolean(ctx.workspace);
90
+ const repos = detached && Array.isArray(ctx.repos) && ctx.repos.length
91
+ ? ctx.repos.map((r) => ({ key: r.projectKey, dir: r.dir }))
92
+ : null;
93
+ return {
94
+ apiVersion: 1,
95
+ node: { id: node.id, key: node.key, displayName: script.meta?.displayName || node.key },
96
+ execution: { id: ctx.executionId, ordinal },
97
+ inputs,
98
+ outputs: outs,
99
+ verdictPath: ctx.verdict?.path ?? null,
100
+ params: script.params || {},
101
+ wiredParams: Array.isArray(ctx.wiredParams) ? ctx.wiredParams : [], // ids a wire set (additive — apiVersion stays 1)
102
+ ctx: {
103
+ cwd: ctx.projectDir, // the run's work dir (D12)
104
+ pipelineDir: ctx.pipelineDir,
105
+ projectDir: ctx.runCtx?.projectDir ?? ctx.projectDir,
106
+ runRoot: ctx.runRoot ?? null,
107
+ repos,
108
+ checkpointRef: ctx.checkpointRef ?? null,
109
+ baseName: ctx.runCtx?.baseName ?? null,
110
+ runId: ctx.pipelineId ?? null,
111
+ platform: process.platform,
112
+ mock: Boolean(mockEnabled(ctx.claudeOpts)),
113
+ // W12: true only on a test-bench execution, so a side-effect script ("post
114
+ // a comment") can stay quiet under test. Additive — apiVersion stays 1.
115
+ bench: Boolean(ctx.bench),
116
+ },
117
+ };
118
+ }
119
+
120
+ /** The child's environment (D11 + §4.3): the server's env minus WORCA_HOME /
121
+ * WORCA_RUN_ROOT / WORCA_HOST_PID, plus the envelope as WORCA_* variables. Both
122
+ * runtimes get it; the shell reads it, the node child gets the envelope on stdin too. */
123
+ export function envForShell(envelope, baseEnv = process.env) {
124
+ const env = {};
125
+ for (const [k, v] of Object.entries(baseEnv || {})) if (!STRIPPED_ENV.has(k) && v !== undefined) env[k] = v;
126
+ for (const [id, p] of Object.entries(envelope.inputs || {})) env[`WORCA_IN_${upperSnake(id)}`] = p.path || '';
127
+ for (const [id, p] of Object.entries(envelope.outputs || {})) if (p.path) env[`WORCA_OUT_${upperSnake(id)}`] = p.path;
128
+ if (envelope.verdictPath) env.WORCA_VERDICT = envelope.verdictPath;
129
+ for (const [id, v] of Object.entries(envelope.params || {})) {
130
+ env[`WORCA_PARAM_${upperSnake(id)}`] = typeof v === 'boolean' ? String(v) : (v == null ? '' : String(v));
131
+ }
132
+ const c = envelope.ctx || {};
133
+ env.WORCA_CWD = c.cwd || '';
134
+ env.WORCA_PIPELINE_DIR = c.pipelineDir || '';
135
+ env.WORCA_PROJECT_DIR = c.projectDir || '';
136
+ env.WORCA_RUN_ROOT = c.runRoot || '';
137
+ env.WORCA_CHECKPOINT_REF = c.checkpointRef || '';
138
+ env.WORCA_CYCLE = String(envelope.execution?.ordinal ?? 1);
139
+ env.WORCA_RUN_ID = c.runId || '';
140
+ env.WORCA_PLATFORM = c.platform || process.platform;
141
+ env.WORCA_MOCK = c.mock ? '1' : '0';
142
+ // W12: set on a bench run, ABSENT on a pipeline run (a shell's `${WORCA_BENCH+set}`
143
+ // must not see it) — and never inherited from a worca that is itself under test.
144
+ if (c.bench) env.WORCA_BENCH = '1'; else delete env.WORCA_BENCH;
145
+ return env;
146
+ }
147
+
148
+ /** The env a script child STARTS from (P11). With the run's env-scrub guardrail on it is
149
+ * what that run's agents get (buildSpawnEnv: base vars + the per-project allowlist) over
150
+ * the platform baseline a child needs to start at all (scrubbedEnv: PATH/HOME, and on
151
+ * Windows SYSTEMROOT/COMSPEC/PATHEXT/TEMP…) — a script never sees MORE of the host
152
+ * environment than the agents beside it. Scrub off: the server's env (D11). */
153
+ export function scriptBaseEnv(claudeOpts, platform = process.platform) {
154
+ const scrubbed = buildSpawnEnv(claudeOpts?.envScrub, claudeOpts?.envAllowlist);
155
+ return scrubbed ? { ...scrubbedEnv(platform), ...scrubbed } : process.env;
156
+ }
157
+
158
+ export function parseFrame(text) {
159
+ const raw = String(text ?? '').trim();
160
+ if (!raw) return { ok: false, reason: 'no result frame' };
161
+ let frame;
162
+ try { frame = JSON.parse(raw); } catch { return { ok: false, reason: 'stdout is not JSON' }; }
163
+ if (!frame || typeof frame !== 'object' || typeof frame.ok !== 'boolean') return { ok: false, reason: 'frame has no ok field' };
164
+ return { ok: true, frame };
165
+ }
166
+
167
+ /** The md every unwritten md output of a shell script receives (§5.2). */
168
+ export function shellReport({ displayName, ordinal, command, exitCode, signal = null, timedOut = false, durationMs, platform, capture }) {
169
+ const status = timedOut ? 'timed out' : signal ? `killed by ${signal}` : `Exit code: ${exitCode}`;
170
+ const body = String(capture || '').replace(/\r\n/g, '\n');
171
+ const fenced = body && !body.endsWith('\n') ? `${body}\n` : body;
172
+ return `# ${displayName} — cycle ${ordinal}\n\nCommand: \`${command}\`\n${status} · ${secs(durationMs)} s · ${platform}\n\n\`\`\`text\n${fenced}\`\`\`\n`;
173
+ }
174
+
175
+ /** Head + tail capture with an omitted-bytes marker (§5.2: 1 MiB cap). Character counts. */
176
+ export function createCapture() {
177
+ let head = '';
178
+ let tail = '';
179
+ let omitted = 0;
180
+ return {
181
+ push(chunk) {
182
+ let rest = String(chunk);
183
+ if (head.length < CAPTURE_HEAD) {
184
+ const take = rest.slice(0, CAPTURE_HEAD - head.length);
185
+ head += take;
186
+ rest = rest.slice(take.length);
187
+ if (!rest) return;
188
+ }
189
+ tail += rest;
190
+ if (tail.length > CAPTURE_TAIL) {
191
+ omitted += tail.length - CAPTURE_TAIL;
192
+ tail = tail.slice(tail.length - CAPTURE_TAIL);
193
+ }
194
+ },
195
+ text() {
196
+ return omitted ? `${head}\n… ${omitted} bytes omitted …\n${tail}` : head + tail;
197
+ },
198
+ };
199
+ }
200
+
201
+ // ── fs / process ──────────────────────────────────────────────────────────────
202
+
203
+ /** Rule §4.2.2: a file on disk stands; else the frame's `value` is written; else an error. */
204
+ export async function materializeOutputs(ports, outputs, frame, key) {
205
+ const tokens = {};
206
+ for (const port of ports?.outputs || []) {
207
+ if (!port) continue;
208
+ if (port.type === 'void') { tokens[port.id] = {}; continue; }
209
+ const path = outputs[port.id]?.path;
210
+ if (!path) { tokens[port.id] = {}; continue; }
211
+ if (!existsSync(path)) {
212
+ const given = frame?.outputs?.[port.id];
213
+ if (!given || given.value === undefined) throw scriptError(`script "${key}": output "${port.id}" was not written`);
214
+ let text;
215
+ if (port.type === 'json') text = JSON.stringify(given.value, null, 2) + '\n';
216
+ else if (typeof given.value === 'string') text = given.value;
217
+ else throw scriptError(`script "${key}": output "${port.id}" value must be a string for an md port`);
218
+ await mkdir(dirname(path), { recursive: true });
219
+ await writeFile(path, text, 'utf8');
220
+ }
221
+ const token = { path };
222
+ if (port.type === 'json' && statSync(path).size <= VALUE_INLINE_MAX) {
223
+ try { token.value = JSON.parse(await readFile(path, 'utf8')); } catch { /* not JSON on disk: path only */ }
224
+ }
225
+ tokens[port.id] = token;
226
+ }
227
+ return tokens;
228
+ }
229
+
230
+ /** Kill a child and everything under it (§6.3). POSIX children are spawned
231
+ * detached (their own process group) so `-pid` reaches the tree; Windows
232
+ * `taskkill /T` walks the tree itself. Best effort, never throws.
233
+ *
234
+ * v2 R5: there is NO early return once the direct child has exited. `sh -c "server &"`
235
+ * exits at once and leaves a grandchild holding our pipes — `'close'` never fires, and
236
+ * that grandchild is exactly what a timeout or a Stop has to reach. On POSIX the group
237
+ * id stays allocated for as long as the group has a member, so signalling `-pid` can
238
+ * only hit OUR descendants; a group that is already empty answers ESRCH, which is caught.
239
+ * spawnScript calls this only while the spawn is unsettled (i.e. before `'close'`). */
240
+ export function killTree(child, platform = process.platform) {
241
+ if (!child || typeof child.pid !== 'number') return;
242
+ if (platform === 'win32') {
243
+ // taskkill /T needs the root alive to walk the tree; once it is gone there is nothing left to address.
244
+ if (child.exitCode === null && child.signalCode === null) {
245
+ try { spawnSync('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true }); } catch { /* best effort */ }
246
+ try { child.kill('SIGKILL'); } catch { /* gone */ }
247
+ }
248
+ return;
249
+ }
250
+ try { process.kill(-child.pid, 'SIGKILL'); } catch { try { child.kill('SIGKILL'); } catch { /* gone */ } }
251
+ }
252
+
253
+ /** How long a killed spawn may keep its pipes before we stop waiting for `'close'`. */
254
+ const KILL_GRACE_MS = 2000;
255
+
256
+ /** v4 T4 — children still running, reaped when the HOST exits. POSIX script children lead their own process
257
+ * group (that is what makes the tree kill work), so a terminal's Ctrl+C never reaches them, and neither the
258
+ * server's shutdown nor the CLI's hard exit aborts the run first. MEASURED: a quiet child survived its host.
259
+ * An 'exit' listener must be synchronous — killTree is (process.kill; spawnSync taskkill on Windows). A host
260
+ * that is SIGKILLed cannot run it; nothing can. */
261
+ const live = new Map(); // ChildProcess -> platform
262
+ process.on('exit', () => { for (const [child, platform] of live) killTree(child, platform); });
263
+
264
+ /**
265
+ * Spawn one child with a timeout, an abort signal, line streaming and the capped
266
+ * capture. `stdoutMode: 'frame'` (node) keeps stdout whole for the frame and
267
+ * streams stderr only; `'capture'` (shell) interleaves both into the capture.
268
+ * Resolves `{ exitCode, signal, timedOut, aborted, stdout, frameOverflow, capture, durationMs }`;
269
+ * rejects only on a spawn failure.
270
+ */
271
+ export function spawnScript({ file, args, cwd, env, stdin = null, timeoutMs, signal, onLine, stdoutMode, platform = process.platform }) {
272
+ return new Promise((resolve, reject) => {
273
+ const started = Date.now();
274
+ const child = spawn(file, args, {
275
+ cwd,
276
+ env,
277
+ detached: platform !== 'win32',
278
+ windowsHide: true,
279
+ windowsVerbatimArguments: platform === 'win32' && stdoutMode === 'capture',
280
+ stdio: [stdin == null ? 'ignore' : 'pipe', 'pipe', 'pipe'],
281
+ });
282
+ live.set(child, platform);
283
+ const capture = createCapture();
284
+ const partial = { out: '', err: '' };
285
+ let frameOut = '';
286
+ let frameOverflow = false;
287
+ let timedOut = false;
288
+ let aborted = false;
289
+ let settled = false;
290
+ let streamed = 0;
291
+ let streamCut = false;
292
+ const line = (text) => {
293
+ if (streamCut || !text.trim()) return;
294
+ streamed += text.length;
295
+ if (streamed > STREAM_MAX) {
296
+ streamCut = true;
297
+ onLine(`… live output cut after ${STREAM_MAX / (1024 * 1024)} MiB — the head and the tail are still captured`);
298
+ return;
299
+ }
300
+ onLine(text);
301
+ };
302
+ const feed = (which, chunk) => {
303
+ if (which === 'out' && stdoutMode === 'frame') {
304
+ if (frameOut.length + chunk.length > FRAME_MAX) frameOverflow = true; else if (!frameOverflow) frameOut += chunk;
305
+ return;
306
+ }
307
+ capture.push(chunk);
308
+ // ONE pass per chunk: `partial` is always shorter than MAX_LINE, so this concat is O(chunk) and the scan
309
+ // never revisits a character. (v3 searched the whole accumulated buffer on every chunk.)
310
+ const text = partial[which] + chunk;
311
+ let start = 0;
312
+ let i;
313
+ while ((i = text.indexOf('\n', start)) >= 0) { line(text.slice(start, i).replace(/\r$/, '')); start = i + 1; }
314
+ while (text.length - start > MAX_LINE) { line(text.slice(start, start + MAX_LINE)); start += MAX_LINE; }
315
+ partial[which] = text.slice(start);
316
+ };
317
+ const flush = () => {
318
+ for (const w of ['out', 'err']) { const rest = partial[w]; partial[w] = ''; line(rest.replace(/\r$/, '')); }
319
+ };
320
+ // Kill, then stop WAITING (v2 R5): a descendant that escaped the group (setsid) or a Windows grandchild whose
321
+ // root already died can keep stdout/stderr open forever. Destroying OUR ends of the pipes makes `'close'` fire,
322
+ // so a timeout and a Stop always settle the execution even when something survives the kill.
323
+ let grace = null;
324
+ const killAndRelease = () => {
325
+ killTree(child, platform);
326
+ if (grace) return;
327
+ grace = setTimeout(() => { try { child.stdout.destroy(); child.stderr.destroy(); } catch { /* already closed */ } }, KILL_GRACE_MS);
328
+ grace.unref?.();
329
+ };
330
+ const timer = setTimeout(() => { timedOut = true; killAndRelease(); }, timeoutMs);
331
+ const onAbort = () => { aborted = true; killAndRelease(); };
332
+ if (signal) { if (signal.aborted) onAbort(); else signal.addEventListener('abort', onAbort, { once: true }); }
333
+ const done = (fn, v) => {
334
+ if (settled) return;
335
+ settled = true;
336
+ live.delete(child);
337
+ clearTimeout(timer);
338
+ if (grace) clearTimeout(grace);
339
+ if (signal) signal.removeEventListener('abort', onAbort);
340
+ fn(v);
341
+ };
342
+ child.stdout.setEncoding('utf8').on('data', (c) => feed('out', c));
343
+ child.stderr.setEncoding('utf8').on('data', (c) => feed('err', c));
344
+ child.on('error', (err) => done(reject, err));
345
+ child.on('close', (code, sig) => {
346
+ flush();
347
+ done(resolve, { exitCode: code, signal: sig, timedOut, aborted, stdout: frameOut, frameOverflow, capture: capture.text(), durationMs: Date.now() - started });
348
+ });
349
+ if (stdin != null) {
350
+ // v2 R5: a child that dies before it reads (a bad inherited NODE_OPTIONS, an OOM kill) turns this write into
351
+ // EPIPE — an 'error' event on the stdin stream. With no listener that is an uncaught exception in the HOST
352
+ // process. The failure itself is already reported through 'close' (no frame, exit N), so the event is dropped.
353
+ child.stdin.on('error', () => {});
354
+ child.stdin.end(stdin);
355
+ }
356
+ });
357
+ }
358
+
359
+ // ── the executor ──────────────────────────────────────────────────────────────
360
+
361
+ /** Mock (§6.6): write the declared outputs and verdict, spawn nothing. */
362
+ async function runMock({ key, meta, ports, outputs, verdict, mock }) {
363
+ const declared = new Map((ports.outputs || []).filter(Boolean).map((p) => [p.id, p]));
364
+ for (const [id, spec] of Object.entries(mock.outputs || {})) {
365
+ const port = declared.get(id);
366
+ if (!port || port.type === 'void') throw scriptError(`script "${key}": mock names output "${id}", which is not a declared non-void output`);
367
+ const path = outputs[id]?.path;
368
+ if (!path) continue;
369
+ await mkdir(dirname(path), { recursive: true });
370
+ await writeFile(path, String(spec?.text ?? ''), 'utf8');
371
+ }
372
+ for (const port of declared.values()) {
373
+ if (port.type === 'void') continue;
374
+ const path = outputs[port.id]?.path;
375
+ if (path && !existsSync(path)) throw scriptError(`script "${key}": mock leaves output "${port.id}" unwritten`);
376
+ }
377
+ let review = null;
378
+ if (verdict?.path) {
379
+ review = normalizeReview(mock.verdict || { issues: [] });
380
+ await mkdir(dirname(verdict.path), { recursive: true });
381
+ await writeFile(verdict.path, JSON.stringify(review, null, 2) + '\n', 'utf8');
382
+ }
383
+ const tokens = {};
384
+ for (const port of declared.values()) { const path = outputs[port.id]?.path; tokens[port.id] = path ? { path } : {}; }
385
+ return {
386
+ summary: typeof mock.summary === 'string' && mock.summary ? mock.summary : `${meta.displayName || key} completed (mock).`,
387
+ outputs: tokens, verdict: review,
388
+ };
389
+ }
390
+
391
+ /** The effective shell command: the command-typed param, else `sh <file>`, else the sidecar's. */
392
+ function shellCommand(script, meta, platform) {
393
+ const cmdParam = (meta.params || []).find((p) => p.type === 'command');
394
+ const fromParam = cmdParam ? script.params?.[cmdParam.id] : undefined;
395
+ if (typeof fromParam === 'string' && fromParam.trim()) return fromParam;
396
+ // The registry stamps `file` as an absolute path inside its layer dir; quote it for the shell that will read it.
397
+ // POSIX: single quotes, with an embedded ' closed-escaped-reopened — nothing else is special inside them.
398
+ if (script.file) return platform === 'win32' ? `"${script.file}"` : `sh '${String(script.file).replace(/'/g, `'\\''`)}'`;
399
+ return script.command || null;
400
+ }
401
+
402
+ /** The card's params with the engine `params` wire overlaid (D6–D8). No port: the card's own params.
403
+ * The token's inline `value` (a script producer's small JSON) wins; an agent's file is read from
404
+ * `path` — BOM stripped, capped like every inline value. Nothing bound (a loop wire is excused from
405
+ * the first-run barrier) is an EMPTY overlay, so a required param V22 deferred to the wire is still
406
+ * enforced. Every problem throws. */
407
+ export async function resolveWiredParams(ctx) {
408
+ const script = ctx.script || {};
409
+ const base = script.params || {};
410
+ if (script.paramsPort !== true) return { params: base, wired: [] };
411
+ const key = ctx.node?.key || script.meta?.key || ctx.node?.id;
412
+ const refuse = (why) => scriptError(`script "${key}": wired params — ${why}`);
413
+ const token = ctx.bindings?.[PARAMS_PORT.id];
414
+ let value = token ? token.value : {};
415
+ if (token && value == null) {
416
+ if (!token.path) throw refuse('the wire carried no JSON');
417
+ let text;
418
+ try {
419
+ if (statSync(token.path).size > VALUE_INLINE_MAX) throw refuse(`${token.path} is larger than ${VALUE_INLINE_MAX / 1024} KiB`);
420
+ text = await readFile(token.path, 'utf8');
421
+ } catch (err) {
422
+ throw err?.errorClass === null ? err : refuse(`cannot read ${token.path}: ${err?.message || err}`);
423
+ }
424
+ try { value = JSON.parse(text.replace(/^\uFEFF/, '')); } catch (err) { throw refuse(`${token.path} is not valid JSON (${err?.message || err})`); }
425
+ }
426
+ const r = overlayWiredParams(script.meta || {}, base, value);
427
+ if (r.errors.length) throw refuse(r.errors.join('; '));
428
+ return { params: r.params, wired: r.wired };
429
+ }
430
+
431
+ /**
432
+ * Run one script execution (spec §6.1). `ctx` is the orchestrator's execution
433
+ * context with `ctx.script = { meta, runtime, file, command, params, paramsPort, timeoutMs, mock }`.
434
+ * @returns {Promise<{summary:string, outputs:object, verdict:object|null, warnings:string[], sessionId:null, runtime:string, exitCode:number|null, durationMs:number, envelopePath:string|null}>}
435
+ */
436
+ export async function runScriptExecution(ctx) {
437
+ const { node, ordinal = 1 } = ctx;
438
+ let script = ctx.script || {};
439
+ const meta = script.meta || {};
440
+ const key = node?.key || meta.key || node?.id;
441
+ const ports = ctx.ports || {};
442
+ const outputs = ctx.outputs || {};
443
+ const verdict = ctx.verdict || null;
444
+ const emit = typeof ctx.onEvent === 'function' ? ctx.onEvent : () => {};
445
+ const platform = process.platform;
446
+ const warnings = [];
447
+ const started = Date.now();
448
+ const resultEvent = (extra) => emit({ type: 'result', costUsd: 0, raw: { type: 'result', script: true, ...extra } });
449
+
450
+ // D22 / v2 R6: every (re-)run of an execution starts from scratch. A resumed execution keeps its executionId, its
451
+ // ordinal and therefore its PATHS, and rule §4.2.2 lets a file on disk stand — so the failed attempt's shell report
452
+ // (written before the exit mapping throws) or a stale self-written verdict would otherwise be published as THIS
453
+ // attempt's result. Only this execution's ALLOCATED paths are touched: never an input, never another cycle's file.
454
+ const allocated = new Set(Object.values(outputs).map((o) => o?.path).filter(Boolean));
455
+ if (verdict?.path) allocated.add(verdict.path);
456
+ for (const path of allocated) await rm(path, { force: true });
457
+
458
+ const mock = script.mock && typeof script.mock === 'object' ? script.mock : null;
459
+ if (mockEnabled(ctx.claudeOpts) && mock) {
460
+ const res = await runMock({ key, meta, ports, outputs, verdict, mock });
461
+ resultEvent({ mock: true, exitCode: 0, durationMs: 0 });
462
+ return { ...res, warnings, sessionId: null, runtime: meta.runtime, exitCode: 0, durationMs: Date.now() - started, envelopePath: null };
463
+ }
464
+
465
+ if (!SCRIPT_RUNTIMES.includes(meta.runtime)) throw scriptError(`script "${key}": unknown runtime ${JSON.stringify(meta.runtime)}`);
466
+ // After the mock return (a mocked card spawns nothing), before the envelope: the program, the shell env and the
467
+ // audit copy all see ONE set of params. A card with no mock of its own runs for REAL on a mock run, fed by a mock
468
+ // agent's arbitrary JSON — there a refused payload is a warning and the card's own params stand.
469
+ let wiredParams;
470
+ try {
471
+ wiredParams = await resolveWiredParams(ctx);
472
+ } catch (err) {
473
+ if (!mockEnabled(ctx.claudeOpts) || err?.errorClass !== null) throw err;
474
+ warnings.push(`${err.message} — ignored on a mock run; the card's own params apply`);
475
+ wiredParams = { params: script.params || {}, wired: [] };
476
+ }
477
+ script = { ...script, params: wiredParams.params };
478
+ ctx = { ...ctx, script, wiredParams: wiredParams.wired };
479
+ const envelope = buildEnvelope(ctx);
480
+ const envelopePath = envelopeAuditPath(ctx);
481
+ await mkdir(dirname(envelopePath), { recursive: true });
482
+ await writeFile(envelopePath, JSON.stringify(envelope, null, 2) + '\n', 'utf8');
483
+ for (const p of Object.values(outputs)) if (p?.path) await mkdir(dirname(p.path), { recursive: true });
484
+ if (verdict?.path) await mkdir(dirname(verdict.path), { recursive: true });
485
+ const env = { ...envForShell(envelope, scriptBaseEnv(ctx.claudeOpts, platform)), WORCA_ENVELOPE: envelopePath };
486
+ // Clamped as well as validated (v4 T3): the bench and the CLI build a ctx without going through V22, and a delay
487
+ // past 2^31-1 ms makes the timer fire after one millisecond.
488
+ const timeoutMs = Number.isInteger(script.timeoutMs) && script.timeoutMs >= MIN_TIMEOUT_MS ? Math.min(script.timeoutMs, MAX_TIMEOUT_MS) : DEFAULT_TIMEOUT_MS;
489
+ const onLine = (line) => emit({ type: 'text', text: line });
490
+ const tail = (run) => (run.capture ? `\n${lastLines(run.capture, TAIL_LINES)}` : '');
491
+ const afterRun = (run) => {
492
+ if (run.aborted || ctx.signal?.aborted) throw ctx.signal?.reason ?? abortError();
493
+ if (run.timedOut) throw scriptError(`script "${key}" timed out after ${Math.round(timeoutMs / 1000)} s`);
494
+ };
495
+ const spawnOrThrow = async (o) => {
496
+ try { return await spawnScript(o); } catch (err) { throw scriptError(`script "${key}": spawn failed — ${err?.message || err}`); }
497
+ };
498
+
499
+ // The two HARNESSED runtimes differ only in what is spawned: same envelope on
500
+ // stdin, same 'frame' stdout mode, so the same kill / timeout / abort rules and
501
+ // the same reader apply to both.
502
+ const programFile = () => {
503
+ const file = script.file;
504
+ if (!file || !existsSync(file)) throw scriptError(`script "${key}": program file not found: ${file || '(none)'}`);
505
+ return file;
506
+ };
507
+ const readFrame = (r, logHint) => {
508
+ if (r.frameOverflow) throw scriptError(`script "${key}": stdout exceeded ${FRAME_MAX / (1024 * 1024)} MiB — stdout is reserved for the result frame; log through ${logHint}${tail(r)}`);
509
+ const parsed = parseFrame(r.stdout);
510
+ if (!parsed.ok) throw scriptError(`script "${key}": no result frame (exit ${r.exitCode ?? r.signal}) — ${parsed.reason}${tail(r)}`);
511
+ if (parsed.frame.ok !== true) throw scriptError(`script "${key}": ${parsed.frame.error?.message || 'failed'}`);
512
+ return parsed.frame;
513
+ };
514
+
515
+ let run;
516
+ let frame;
517
+ if (meta.runtime === 'node') {
518
+ run = await spawnOrThrow({ file: process.execPath, args: [CHILD_PATH, programFile()], cwd: envelope.ctx.cwd, env,
519
+ stdin: JSON.stringify(envelope), timeoutMs, signal: ctx.signal, onLine, stdoutMode: 'frame', platform });
520
+ afterRun(run);
521
+ frame = readFrame(run, 'console.* or log()');
522
+ } else if (meta.runtime === 'python') {
523
+ // The probe is 60 s-cached, so this costs nothing per execution. A failed
524
+ // probe is an EXECUTION error, which the bench reports as status 'error' with
525
+ // this exact sentence (spec §4.1); a run never reaches here because its
526
+ // preflight refuses first. Windows: probe.command is ['py','-3'] | ['python']
527
+ // | ['python3'] — real executables, never a .cmd shim, so no `shell: true`,
528
+ // and windowsVerbatimArguments stays off (spawnScript sets it for
529
+ // stdoutMode 'capture' only), which is what quotes a path with a space.
530
+ const file = programFile();
531
+ const probe = await probePython();
532
+ if (!probe.ok) throw scriptError(pythonMissingSentence(key));
533
+ const [exe, ...pre] = probe.command;
534
+ run = await spawnOrThrow({ file: exe, args: [...pre, '-u', PY_HARNESS_PATH, file], cwd: envelope.ctx.cwd,
535
+ env: { ...env, ...PY_ENV }, stdin: JSON.stringify(envelope), timeoutMs, signal: ctx.signal, onLine,
536
+ stdoutMode: 'frame', platform });
537
+ afterRun(run);
538
+ frame = readFrame(run, 'print()'); // NOT api.log(): its lines travel ON the frame (frame.logs) and count against the same 8 MiB
539
+ } else {
540
+ const command = shellCommand(script, meta, platform);
541
+ if (!command) throw scriptError(`script "${key}": no command to run`);
542
+ // v2 R3: the OUTER quotes are load-bearing. `/s` makes cmd.exe strip the first and the last quote of the command
543
+ // line and run the rest verbatim; without the wrapper it strips the quotes of the COMMAND instead and
544
+ // `"C:\Program Files\nodejs\node.exe" -e "…"` becomes garbage. This is byte-for-byte what Node's own
545
+ // `shell: true` builds (child_process normalizeSpawnArguments), with windowsVerbatimArguments so nothing re-quotes it.
546
+ const [shell, args] = platform === 'win32'
547
+ ? [process.env.ComSpec || 'cmd.exe', ['/d', '/s', '/c', `"${command}"`]]
548
+ : ['/bin/sh', ['-c', command]];
549
+ run = await spawnOrThrow({ file: shell, args, cwd: envelope.ctx.cwd, env, stdin: null, timeoutMs, signal: ctx.signal, onLine, stdoutMode: 'capture', platform });
550
+ // Every md output the command did not write receives the report — written
551
+ // before the exit mapping so a failed run still leaves its log behind.
552
+ const displayName = meta.displayName || key;
553
+ const report = shellReport({ displayName, ordinal, command, exitCode: run.exitCode, signal: run.signal, timedOut: run.timedOut, durationMs: run.durationMs, platform, capture: run.capture });
554
+ const seen = new Set();
555
+ for (const port of ports.outputs || []) {
556
+ const path = outputs[port?.id]?.path;
557
+ if (!path || port.type !== 'md' || seen.has(path) || existsSync(path)) continue;
558
+ seen.add(path);
559
+ await writeFile(path, report, 'utf8');
560
+ }
561
+ afterRun(run);
562
+ const codes = meta.exitCodes || DEFAULT_EXIT_CODES;
563
+ if (run.signal) throw scriptError(`script "${key}" was killed by ${run.signal}${tail(run)}`);
564
+ const clean = codes.clean.includes(run.exitCode);
565
+ if (!clean && !codes.blocking.includes(run.exitCode)) throw scriptError(`script "${key}" exited ${run.exitCode}${tail(run)}`);
566
+ frame = { ok: true, outputs: {}, summary: `${displayName}: exit ${run.exitCode} in ${secs(run.durationMs)} s` };
567
+ if (verdict?.path && !existsSync(verdict.path)) {
568
+ frame.verdict = clean
569
+ ? { issues: [], summary: `${displayName} exited ${run.exitCode}` }
570
+ : { issues: [{ severity: 'major', title: `${displayName} failed (exit ${run.exitCode})`, detail: lastLines(run.capture, 40), location: '' }],
571
+ summary: `${displayName} exited ${run.exitCode}` };
572
+ }
573
+ }
574
+
575
+ const tokens = await materializeOutputs(ports, outputs, frame, key);
576
+ let review = null;
577
+ if (verdict?.path) {
578
+ if (frame.verdict && typeof frame.verdict === 'object') {
579
+ review = normalizeReview(frame.verdict);
580
+ await writeFile(verdict.path, JSON.stringify(review, null, 2) + '\n', 'utf8');
581
+ } else {
582
+ review = await readVerdict(verdict.path); // garbage throws (agent parity)
583
+ if (review.missing) warnings.push(missingVerdictWarning(ctx, verdict.path));
584
+ }
585
+ } else if (frame.verdict !== undefined) {
586
+ warnings.push(`script "${key}" returned a verdict but declares none — ignored`);
587
+ }
588
+ const summary = typeof frame.summary === 'string' && frame.summary.trim()
589
+ ? frame.summary.trim().slice(0, SUMMARY_MAX)
590
+ : `${meta.displayName || key} completed.`;
591
+ for (const l of Array.isArray(frame.logs) ? frame.logs : []) emit({ type: 'text', text: `[${l?.level || 'info'}] ${String(l?.msg ?? '')}` });
592
+ resultEvent({ exitCode: run.exitCode, durationMs: run.durationMs });
593
+ return {
594
+ summary, outputs: tokens, verdict: review, warnings, sessionId: null,
595
+ runtime: meta.runtime, exitCode: run.exitCode, durationMs: run.durationMs, envelopePath,
596
+ };
597
+ }