@krapht/ork 1.0.0-alpha.2 → 1.0.0-alpha.20

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 (71) hide show
  1. package/README.md +12 -7
  2. package/dist/__build_info__-D6mXC4cD.mjs +228 -0
  3. package/dist/apply-Mb0fNpCX.mjs +17 -0
  4. package/dist/argument-error-CihH46Le.mjs +27 -0
  5. package/dist/attach-C1OnxyMh.mjs +92 -0
  6. package/dist/attach-CEGbRg1D.mjs +1230 -0
  7. package/dist/bin.mjs +1312 -2390
  8. package/dist/brief-DH66xmj8.mjs +115 -0
  9. package/dist/{decide-CGyx9rv7.mjs → builtin-files-Cf-NZDqM.mjs} +3 -51
  10. package/dist/chips-BqcT58qr.mjs +43 -0
  11. package/dist/command-tree-CEkzWNy6.mjs +63 -0
  12. package/dist/{completion-CSU5vkEt.mjs → completion-DS0gCZRm.mjs} +200 -30
  13. package/dist/context-CGPh12HV.mjs +106 -0
  14. package/dist/detached-Dakw5RZj.mjs +38 -0
  15. package/dist/extra-arguments-BxQMXARO.mjs +13 -0
  16. package/dist/{find-D6Gj1sxg.mjs → find-BTikxc54.mjs} +2 -2
  17. package/dist/flags-C8yCq0j5.mjs +145 -0
  18. package/dist/follow-ups-CtlhHbwe.mjs +563 -0
  19. package/dist/format-BB4E-T6x.mjs +249 -0
  20. package/dist/{help-groups-BwYoAWsh.mjs → help-groups-BpRgBSTh.mjs} +42 -76
  21. package/dist/in-a-run-CbnKjO7t.mjs +27 -0
  22. package/dist/install-CWcpX5g4.mjs +211 -0
  23. package/dist/{limits-B1-DYc3d.mjs → limits-VQqsBoQk.mjs} +2 -2
  24. package/dist/{mcp-DE_EmyVm.mjs → mcp-wGfkMCVF.mjs} +6 -6
  25. package/dist/once-BHxSdZz_.mjs +112 -0
  26. package/dist/orphan-guard-BFKg8VSd.mjs +58 -0
  27. package/dist/output-B1kwo0YU.mjs +439 -0
  28. package/dist/outputs-text-CNTJ7Qq_.mjs +19 -0
  29. package/dist/{pause-CL6N_Xrj.mjs → pause-C_Wxh-KM.mjs} +7 -4
  30. package/dist/print-DNYzq4Rr.mjs +19 -0
  31. package/dist/project-records-XY3ku95x.mjs +233 -0
  32. package/dist/refusals-DQQX3AIb.mjs +116 -0
  33. package/dist/{relay-0SD0erwf.mjs → relay-BA6tsnpc.mjs} +2 -2
  34. package/dist/run-Bvvg-fiF.mjs +234 -0
  35. package/dist/run-DBUAq-Uu.mjs +1452 -0
  36. package/dist/send-nrKtak5F.mjs +105 -0
  37. package/dist/{src-Dujmvv4s.mjs → src-8cOPOgrT.mjs} +12523 -5507
  38. package/dist/{src-BbyEoMn4.mjs → src-CmEvHdju.mjs} +797 -233
  39. package/dist/src-D4JSymhK.mjs +3104 -0
  40. package/dist/{src-DOcK8Jfp.mjs → src-I_RyKu6_.mjs} +1022 -590
  41. package/dist/store-CDpk8Wtz.mjs +16 -0
  42. package/dist/ui/THIRD_PARTY_LICENSES.txt +612 -0
  43. package/dist/ui/assets/app--FijEXiJ.css +1 -0
  44. package/dist/ui/assets/app-BowhjEAe.js +112 -0
  45. package/dist/ui/assets/{index-9dFha9Qn.js → index-CrMa2oHB.js} +3 -3
  46. package/dist/ui/assets/theme-CdXvqTQ6.js +1 -0
  47. package/dist/ui/index.html +2 -2
  48. package/dist/{ui-CTmO2Xzu.mjs → ui-DY3Dnpho.mjs} +54 -25
  49. package/dist/usage-1VwSg_Qt.mjs +257 -0
  50. package/dist/validate-BxayjjkV.mjs +632 -0
  51. package/dist/verdict-CMvPCpWC.mjs +84 -0
  52. package/dist/{watch-CtbgDAcz.mjs → watch-CT0EtLPZ.mjs} +45 -17
  53. package/dist/{workflows-D1_WVukk.mjs → workflows-DHHi-APg.mjs} +30 -5
  54. package/dist/worktrees-CZvbHzbN.mjs +302 -0
  55. package/package.json +10 -8
  56. package/dist/__build_info__-fy6L1Mrd.mjs +0 -615
  57. package/dist/clean-DaHjs__2.mjs +0 -88
  58. package/dist/context-DT8MfEIR.mjs +0 -274
  59. package/dist/format-Dnfv8zMT.mjs +0 -427
  60. package/dist/in-a-run-pLAZeNys.mjs +0 -22
  61. package/dist/list-BrH8KRSJ.mjs +0 -110
  62. package/dist/output-BLQWD6MJ.mjs +0 -950
  63. package/dist/remove-BeGdqroy.mjs +0 -194
  64. package/dist/report-DK4LGqwF.mjs +0 -129
  65. package/dist/send-C8tunWhv.mjs +0 -163
  66. package/dist/src-CCUKRX51.mjs +0 -1204
  67. package/dist/ui/assets/app-BNT0SuIz.css +0 -1
  68. package/dist/ui/assets/app-T7wVAhgx.js +0 -67
  69. package/dist/ui/assets/theme-3xH5jxKs.js +0 -1
  70. package/dist/usage-DYUmP0i4.mjs +0 -419
  71. package/dist/worktrees-DdMYoNOC.mjs +0 -212
@@ -1,10 +1,176 @@
1
- import { $n as timeoutMs, $t as parseTemplate, At as runScope, F as declaredProblem, Jn as beforeOf, Mt as envReadsOf, Ot as heldTimeout, Pt as declaredOutputOf, Qt as evaluate, Vn as markValue, Xn as isToolNode, Zn as programsRunBy, en as renderTemplate, er as INTEGRATIONS, ft as sessionDriver, jt as calledWorkflows, mt as reportSchemaFor, nr as integrationFor, s as planReason, tr as TOOL_PREFIX, ut as planNodeSession } from "./src-Dujmvv4s.mjs";
1
+ import { At as heldTimeout, Bt as lowerChild, Ct as reportSchemaFor, Di as INTEGRATIONS, Ei as timeoutMs, Fi as AGENT_DRIVERS, Ft as failedWith, Gr as placeFor, Ht as startAt, It as stoppedEnd, Lt as declaredProblem, Mt as calledWorkflows, Oi as TOOL_PREFIX, Pt as endedWith, Q as sessionRefusals, Rt as slotClock, Si as isToolNode, Sr as renderTemplate, Wn as countedTokens, ar as endFor, br as evaluate, dn as declaredOutputOf, fi as parseWorkflow, fn as MCP_FILE, jt as runScope, ki as integrationFor, li as beforeOf, ln as sessionDriver, mi as markValue, mn as serversIn, nr as outcomeEnd, on as envReadsOf, pn as mcpNotText, rr as END_DETAIL_LIMIT, tr as endOf, ui as programsRunBy, vt as planReason, xi as isClaudeNode, xr as parseTemplate, xt as planNodeSession, z as storePath } from "./src-8cOPOgrT.mjs";
2
+ import { t as runOnce } from "./run-Bvvg-fiF.mjs";
3
+ import { n as holdGuard, r as orphanGuard, t as KILL_AFTER_MS } from "./orphan-guard-BFKg8VSd.mjs";
2
4
  import path from "node:path";
3
- import { accessSync, constants, existsSync, lstatSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
5
+ import { accessSync, constants, lstatSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
4
6
  import { spawn } from "node:child_process";
7
+ import { Readable } from "node:stream";
5
8
  import { Client } from "@modelcontextprotocol/sdk/client/index.js";
6
9
  import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
7
10
  import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
11
+ // ../core/src/project/environ.ts
12
+ /** A variable of the user's. */
13
+ const readEnv = (environ, name) => Object.hasOwn(environ, name) ? environ[name] : void 0;
14
+ // ../core/src/store/scrub.ts
15
+ /**
16
+ * @file Masking the values of the user's secret variables in text Ork stores:
17
+ * what a tool node's program prints, and a run's own messages. A plain
18
+ * search, so a value of any size or shape takes time in step with the text:
19
+ * it looks in the text as it is, and with JSON's escapes decoded once and
20
+ * twice, so that it does not matter which characters an encoder chose to
21
+ * escape. It masks the value, and the pieces programs print on their own:
22
+ * each line, and each value of a JSON document. Other encodings, such as
23
+ * base64, are out of its reach (L81).
24
+ */
25
+ /** Shorter values than this would mask ordinary text: `1`, `true`, a word. */
26
+ const MIN_SECRET_LENGTH = 8;
27
+ const SHORT_ESCAPES = {
28
+ "\"": "\"",
29
+ "\\": "\\",
30
+ "/": "/",
31
+ b: "\b",
32
+ f: "\f",
33
+ n: "\n",
34
+ r: "\r",
35
+ t: " "
36
+ };
37
+ /** The unit an escape at `at` stands for, and how long it is written. */
38
+ const escapeAt = (text, at) => {
39
+ const next = text.charAt(at + 1);
40
+ const short = SHORT_ESCAPES[next];
41
+ if (short !== void 0) return {
42
+ unit: short,
43
+ width: 2
44
+ };
45
+ if (next !== "u") return null;
46
+ const hex = text.slice(at + 2, at + 6);
47
+ return /^[\da-f]{4}$/iu.test(hex) ? {
48
+ unit: String.fromCodePoint(Number(`0x${hex}`)),
49
+ width: 6
50
+ } : null;
51
+ };
52
+ /** The last mark at or before `index`, by a binary search. */
53
+ const lastMark = (marks, index) => {
54
+ let low = 0;
55
+ let high = marks.length - 1;
56
+ while (low < high) {
57
+ const middle = Math.ceil((low + high) / 2);
58
+ if ((marks[middle] ?? 0) <= index) low = middle;
59
+ else high = middle - 1;
60
+ }
61
+ return low;
62
+ };
63
+ /** The view with its escapes decoded; `null` when it has none. */
64
+ const decoded = (view) => {
65
+ const { text } = view;
66
+ const parts = [];
67
+ const decodedAt = [0];
68
+ const sourceAt = [0];
69
+ let from = 0;
70
+ let length = 0;
71
+ let at = text.indexOf("\\");
72
+ while (at !== -1) {
73
+ const escape = escapeAt(text, at);
74
+ if (escape === null) at = text.indexOf("\\", at + 1);
75
+ else {
76
+ parts.push(text.slice(from, at), escape.unit);
77
+ length += at - from;
78
+ decodedAt.push(length);
79
+ sourceAt.push(at);
80
+ length += 1;
81
+ from = at + escape.width;
82
+ decodedAt.push(length);
83
+ sourceAt.push(from);
84
+ at = text.indexOf("\\", from);
85
+ }
86
+ }
87
+ if (from === 0) return null;
88
+ parts.push(text.slice(from));
89
+ return {
90
+ text: parts.join(""),
91
+ toSource: (index) => {
92
+ const mark = lastMark(decodedAt, index);
93
+ return view.toSource((sourceAt[mark] ?? 0) + (index - (decodedAt[mark] ?? 0)));
94
+ }
95
+ };
96
+ };
97
+ /** The text as it is, then decoded as far as JSON inside JSON goes. */
98
+ const viewsOf = (text) => {
99
+ const raw = {
100
+ text,
101
+ toSource: (index) => index
102
+ };
103
+ const once = decoded(raw);
104
+ const twice = once === null ? null : decoded(once);
105
+ return [
106
+ raw,
107
+ ...once === null ? [] : [once],
108
+ ...twice === null ? [] : [twice]
109
+ ];
110
+ };
111
+ /** Where each secret is in the text, overlapping ones joined. */
112
+ const rangesOf = (text, secrets) => {
113
+ const found = [];
114
+ for (const view of viewsOf(text)) for (const secret of secrets) {
115
+ let at = view.text.indexOf(secret);
116
+ while (at !== -1) {
117
+ found.push([view.toSource(at), view.toSource(at + secret.length)]);
118
+ at = view.text.indexOf(secret, at + secret.length);
119
+ }
120
+ }
121
+ const joined = [];
122
+ for (const [start, end] of found.toSorted((a, b) => a[0] - b[0])) {
123
+ const last = joined.at(-1);
124
+ if (last !== void 0 && start < last[1]) last[1] = Math.max(last[1], end);
125
+ else joined.push([start, end]);
126
+ }
127
+ return joined;
128
+ };
129
+ /** The strings and numbers inside a parsed JSON document. */
130
+ const leavesOf = (value) => {
131
+ if (typeof value === "string") return [value];
132
+ if (typeof value === "number") return [String(value)];
133
+ if (typeof value === "object" && value !== null) return Object.values(value).flatMap((item) => leavesOf(item));
134
+ return [];
135
+ };
136
+ /** A value's leaves, when it is a JSON object or array. */
137
+ const jsonLeavesOf = (value) => {
138
+ if (!/^\s*[[{]/u.test(value)) return [];
139
+ try {
140
+ return leavesOf(JSON.parse(value));
141
+ } catch {
142
+ return [];
143
+ }
144
+ };
145
+ /** A value, and the pieces of it a program may well print on their own. */
146
+ const formsOf = (value) => [
147
+ value,
148
+ value.trim(),
149
+ ...value.split(/\r?\n|\r/u).map((line) => line.trim()),
150
+ ...jsonLeavesOf(value)
151
+ ];
152
+ /** Replaces the values, in any form JSON writes them, with `[masked]`. */
153
+ const scrubberOf = (values) => {
154
+ const secrets = [...new Set(values.flatMap((value) => formsOf(value)))].filter((secret) => secret.trim().length >= MIN_SECRET_LENGTH);
155
+ return (text) => {
156
+ if (secrets.length === 0) return text;
157
+ const parts = [];
158
+ let from = 0;
159
+ for (const [start, end] of rangesOf(text, secrets)) {
160
+ parts.push(text.slice(from, start), "[masked]");
161
+ from = end;
162
+ }
163
+ parts.push(text.slice(from));
164
+ return parts.join("");
165
+ };
166
+ };
167
+ /** The values of the variables named that are set. */
168
+ const envValuesOf = (names, environ) => names.flatMap((name) => {
169
+ const value = readEnv(environ, name);
170
+ return value === void 0 ? [] : [value];
171
+ });
172
+ /** What a run masks in a message of its own: the named variables' values. */
173
+ const runScrubber = (names, environ) => scrubberOf(envValuesOf(names, environ));
8
174
  // ../nodes/src/call.ts
9
175
  /**
10
176
  * @file The `workflow.call` node type (RFC-004): renders what the node asks for
@@ -18,24 +184,17 @@ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/
18
184
  */
19
185
  const limitedCall = (result) => {
20
186
  if (result.ok) return result;
21
- return (result.error ?? "").includes("Not started: ") || result.output["runStatus"] === "quota_exhausted" ? {
187
+ const why = result.end?.detail ?? result.error;
188
+ return result.end?.kind === "plan_limit" || (why ?? "").includes("Not started: ") || result.output["runStatus"] === "quota_exhausted" ? {
22
189
  ...result,
23
- limited: result.error ?? "the called run reached a limit"
190
+ limited: why ?? "the called run reached a limit"
24
191
  } : result;
25
192
  };
26
193
  const callExecutor = (context) => {
27
194
  const { node, calls } = context;
28
- if (node.type !== "workflow.call") return Promise.resolve({
29
- ok: false,
30
- output: {},
31
- error: `${context.id} is not a call`
32
- });
33
- if (calls === void 0) return Promise.resolve({
34
- ok: false,
35
- output: { status: "fail" },
36
- error: "This run cannot call workflows."
37
- });
38
- const scope = runScope(context.inputs, context.outputs, context.runId);
195
+ if (node.type !== "workflow.call") return Promise.resolve(failedWith(endFor("unknown", {}, `${context.id} is not a call`), {}));
196
+ if (calls === void 0) return Promise.resolve(failedWith(endFor("invalid_node", { reason: "this run cannot call workflows" }), { status: "fail" }));
197
+ const scope = runScope(context.inputs, context.outputs, context.runId, context.item);
39
198
  const render = (text) => {
40
199
  const template = parseTemplate(text);
41
200
  return template.ok ? renderTemplate(template.parts, scope) : text;
@@ -61,33 +220,82 @@ const callExecutor = (context) => {
61
220
  }
62
221
  }, context.signal).then(limitedCall);
63
222
  };
223
+ // ../nodes/src/claude-plan.ts
224
+ /**
225
+ * @file What a Claude node's session is planned with, read from the node's
226
+ * context, and what it is told beyond its plan: the note of a sent-back input
227
+ * on re-entry, a resumed leader's message, and the turn for messages that
228
+ * came in as it finished.
229
+ */
230
+ /** The plan options every session of a node starts from. */
231
+ const planOptionsOf = (context, inPlace) => {
232
+ const planOptions = {
233
+ cwd: context.cwd,
234
+ inputs: context.inputs,
235
+ outputs: context.outputs,
236
+ inPlace,
237
+ workflowDir: context.workflowDir,
238
+ projectRoot: context.projectRoot,
239
+ ...context.orkDir === void 0 ? {} : { orkDir: context.orkDir },
240
+ pluginDir: path.join(context.runDir, "plugins", context.id)
241
+ };
242
+ if (context.checkout !== void 0) planOptions.checkout = context.checkout;
243
+ if (context.brief !== void 0) planOptions.brief = context.brief;
244
+ if (context.template !== void 0) planOptions.template = context.template;
245
+ if (context.item !== void 0) planOptions.item = context.item;
246
+ if (context.ownership !== void 0) planOptions.ownership = context.ownership;
247
+ if (context.mailbox !== void 0) planOptions.mailbox = context.mailbox;
248
+ if (context.claims !== void 0) planOptions.claims = context.claims;
249
+ return planOptions;
250
+ };
251
+ const MAX_INPUT = 8192;
252
+ const inputJson = (input) => JSON.stringify(input ?? {}, void 0, 2);
253
+ /** What a sent-back input is whole, for `read_input`, when its note cuts it. */
254
+ const wholeInput = (input) => {
255
+ const json = inputJson(input);
256
+ return json.length > MAX_INPUT ? json : void 0;
257
+ };
258
+ /** A re-entry's note of what was sent back; `read_input` has a cut rest. */
259
+ const reentryNote = (round, input) => {
260
+ const json = inputJson(input);
261
+ const cut = json.length > MAX_INPUT;
262
+ const body = cut ? `${json.slice(0, MAX_INPUT)}\n…` : json;
263
+ const rest = cut ? `\n\nThat is the first ${String(MAX_INPUT)} of its ${String(json.length)} characters. Call read_input for all of it before you act on it.` : "";
264
+ return `\n\n## Round ${String(round)}\n\nThe workflow sent this task back to you with:\n\n\`\`\`json\n${body}\n\`\`\`${rest}`;
265
+ };
266
+ /**
267
+ * What a resumed leader is told: it already has its brief and its earlier work,
268
+ * so only the new round, and the new task when its owner sent one.
269
+ */
270
+ const resumeMessage = (round, input, brief) => [brief === void 0 ? sentBack(round, input) : `## New task from \`${brief.from}\`\n\n${brief.text}`, "End with a new structured report."].join("\n\n");
271
+ /** A first-round resume is a session the run was cut off in (`ork resume`). */
272
+ const sentBack = (round, input) => round === 1 ? "Ork was stopped while you worked on this task, and the run is resumed. Your earlier turns are above: check what you finished, and carry on from there." : `The workflow sent your task back to you.${reentryNote(round, input)}`;
273
+ /** What a session is told when messages came in as it finished. */
274
+ const lateMessage = (count) => `[ork] ${String(count)} message${count === 1 ? "" : "s"} came in while you finished. Call read_inbox, act on ${count === 1 ? "it" : "them"} if needed, and end with a new structured report.`;
64
275
  // ../nodes/src/claude-result.ts
65
276
  /**
66
277
  * @file A finished session as a node's result: its report is the output, held
67
- * to what the node declares (RFC-010).
278
+ * to what the node declares (RFC-010), and how it ended is its end kind
279
+ * (specs/runtime/session-ends.md).
68
280
  */
281
+ /** How a session with no report ended; a driver's `done` without one is not. */
282
+ const withoutReport = (outcome) => {
283
+ const end = outcomeEnd(outcome);
284
+ return end.kind === "done" || end.kind === "reported_failure" ? endFor("no_report", {}, end.detail) : end;
285
+ };
69
286
  /** A finished session as a node result: its report is the node's output. */
70
287
  const resultOf = (outcome, declared) => {
71
- if (outcome === null) return {
72
- ok: false,
73
- output: {
74
- status: "fail",
75
- summary: "the session ended without a result"
76
- },
77
- error: "the session ended without a result"
78
- };
288
+ if (outcome === null) return failedWith(endFor("driver_crashed", {}, "the session ended without a result"));
79
289
  const { report, usage } = outcome;
80
290
  if (report === null) {
81
- const reason = outcome.errors[0] ?? "no valid report";
291
+ const end = withoutReport(outcome);
82
292
  return {
83
- ok: false,
84
- output: {
293
+ ...failedWith(end, {
85
294
  status: "fail",
86
- summary: reason,
295
+ summary: end.message,
87
296
  usage
88
- },
297
+ }),
89
298
  usage,
90
- error: `ended without a report (${outcome.subtype}): ${reason}`,
91
299
  sessionId: outcome.sessionId
92
300
  };
93
301
  }
@@ -101,7 +309,7 @@ const resultOf = (outcome, declared) => {
101
309
  };
102
310
  if (report.branch !== void 0) output["branch"] = report.branch;
103
311
  const problem = declaredProblem(declared, output);
104
- if (problem !== null) return {
312
+ if (problem !== null) return endedWith({
105
313
  ok: false,
106
314
  output: {
107
315
  status: "fail",
@@ -111,18 +319,163 @@ const resultOf = (outcome, declared) => {
111
319
  ...report.branch === void 0 ? {} : { branch: report.branch }
112
320
  },
113
321
  usage,
114
- error: problem,
115
322
  ...ran
116
- };
117
- const result = {
118
- ok: report.status !== "fail",
323
+ }, endFor("invalid_report", { reason: problem }));
324
+ const ok = report.status !== "fail";
325
+ return endedWith({
326
+ ok,
119
327
  output,
120
328
  usage,
121
329
  ...ran
330
+ }, endFor(ok ? "done" : "reported_failure"));
331
+ };
332
+ // ../nodes/src/mcp-servers.ts
333
+ /**
334
+ * @file Reading an MCP server's definition from the project's `.mcp.json`, the
335
+ * file Claude Code reads too, so a server is defined in one place (RFC-006).
336
+ * `${VAR}` and `${VAR:-default}` read the user's environment, which is where
337
+ * secrets stay: Ork stores none, and a run masks each value read this way. A
338
+ * server that needs an OAuth sign-in holds Claude Code's tokens, not Ork's,
339
+ * so it is refused.
340
+ */
341
+ const VARIABLE = /\$\{([A-Za-z_]\w*)(?::-([^}]*))?\}/gu;
342
+ const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
343
+ const stringsOf = (value) => isRecord(value) ? Object.fromEntries(Object.entries(value).flatMap(([key, item]) => typeof item === "string" ? [[key, item]] : [])) : {};
344
+ const listOf = (value) => Array.isArray(value) ? value.flatMap((item) => typeof item === "string" ? [item] : []) : [];
345
+ /** The server as written, or why Ork can't call it. */
346
+ const readMcpServer = (root, name) => {
347
+ const servers = serversIn(root);
348
+ if (!servers.ok) return servers;
349
+ const known = Object.keys(servers.value);
350
+ const server = Object.hasOwn(servers.value, name) ? servers.value[name] : void 0;
351
+ if (!isRecord(server)) return {
352
+ ok: false,
353
+ error: `no MCP server \`${name}\` in ${MCP_FILE}${known.length === 0 ? "" : `, which has ${known.join(", ")}`}`
354
+ };
355
+ if ("oauth" in server) return {
356
+ ok: false,
357
+ error: `MCP server \`${name}\` signs in with OAuth, whose tokens are Claude Code's: give it a token through headers and \`env:\`, or call it from a session`
358
+ };
359
+ const notText = mcpNotText(server);
360
+ if (notText !== void 0) return {
361
+ ok: false,
362
+ error: `MCP server \`${name}\` has a value in ${notText} that isn't a string: write it in quotes`
363
+ };
364
+ const { type, url, command } = server;
365
+ if (type === "http" && typeof url === "string") return {
366
+ ok: true,
367
+ value: {
368
+ kind: "http",
369
+ url,
370
+ headers: stringsOf(server["headers"])
371
+ }
372
+ };
373
+ if ((type === void 0 || type === "stdio") && typeof command === "string") return {
374
+ ok: true,
375
+ value: {
376
+ kind: "stdio",
377
+ command,
378
+ args: listOf(server["args"]),
379
+ env: stringsOf(server["env"])
380
+ }
381
+ };
382
+ return {
383
+ ok: false,
384
+ error: `MCP server \`${name}\` is not one Ork can call: it takes stdio and http servers`
385
+ };
386
+ };
387
+ /**
388
+ * The named servers' entries as written, for a session: `${VAR}` stays text for
389
+ * the `claude` that loads them to expand. The compiler has checked the names.
390
+ */
391
+ const namedMcpEntries = (root, names) => {
392
+ if (names.length === 0) return {
393
+ ok: true,
394
+ value: {}
395
+ };
396
+ const servers = serversIn(root);
397
+ if (!servers.ok) return servers;
398
+ const mcpServers = {};
399
+ for (const name of names) {
400
+ const server = Object.hasOwn(servers.value, name) ? servers.value[name] : void 0;
401
+ if (!isRecord(server)) return {
402
+ ok: false,
403
+ error: `no MCP server \`${name}\` in ${MCP_FILE}`
404
+ };
405
+ mcpServers[name] = server;
406
+ }
407
+ return {
408
+ ok: true,
409
+ value: { mcpServers }
410
+ };
411
+ };
412
+ /** Each variable a server reads, and whether it has a default. */
413
+ const readsOf = (server) => {
414
+ return (server.kind === "stdio" ? [
415
+ server.command,
416
+ ...server.args,
417
+ ...Object.values(server.env)
418
+ ] : [server.url, ...Object.values(server.headers)]).flatMap((text) => [...text.matchAll(VARIABLE)].map((match) => ({
419
+ name: String(match[1]),
420
+ fallback: match[2] !== void 0
421
+ })));
422
+ };
423
+ /**
424
+ * The server with its variables filled in, and the values to mask: all it read
425
+ * but those `plain` names, which aren't secret.
426
+ */
427
+ const expandServer = (server, environ, plain = []) => {
428
+ const unset = readsOf(server).find(({ name, fallback }) => !fallback && readEnv(environ, name) === void 0);
429
+ if (unset !== void 0) return {
430
+ ok: false,
431
+ error: `${MCP_FILE} reads ${unset.name}, which is not set`
432
+ };
433
+ const values = [];
434
+ const fill = (text) => text.replaceAll(VARIABLE, (_all, name, fallback) => {
435
+ const value = readEnv(environ, name);
436
+ if (value !== void 0 && !plain.includes(name)) values.push(value);
437
+ return value ?? fallback ?? "";
438
+ });
439
+ const fillAll = (record) => Object.fromEntries(Object.entries(record).map(([key, text]) => [key, fill(text)]));
440
+ return {
441
+ ok: true,
442
+ value: {
443
+ server: server.kind === "stdio" ? {
444
+ kind: "stdio",
445
+ command: fill(server.command),
446
+ args: server.args.map((arg) => fill(arg)),
447
+ env: fillAll(server.env)
448
+ } : {
449
+ kind: "http",
450
+ url: fill(server.url),
451
+ headers: fillAll(server.headers)
452
+ },
453
+ values
454
+ }
122
455
  };
123
- if (!result.ok) result.error = report.summary;
124
- return result;
125
456
  };
457
+ /** What a workflow's MCP servers read of the user's, unless called not secret. */
458
+ const mcpVariablesOf = (workflow, root) => Object.values(workflow.nodes).flatMap((node) => {
459
+ if (node.type !== "mcp.call") return [];
460
+ const read = readMcpServer(root, node.server);
461
+ return read.ok ? readsOf(read.value).map(({ name }) => name).filter((name) => node.plainEnv?.includes(name) !== true) : [];
462
+ });
463
+ /** A stdio server's program, when its name can be known before it is filled in. */
464
+ const programOf = (server) => server.kind === "stdio" && !server.command.includes("$") ? server.command : void 0;
465
+ /**
466
+ * What stops a workflow's `mcp.call` nodes, found before a run spends anything:
467
+ * a server that isn't there or can't be called, a program that isn't installed,
468
+ * or a variable that is not set.
469
+ */
470
+ const mcpProblems = (workflow, root, environ = process.env, finished = /* @__PURE__ */ new Set(), installed = () => true) => Object.entries(workflow.nodes).flatMap(([id, node]) => {
471
+ if (node.type !== "mcp.call" || finished.has(id)) return [];
472
+ const read = readMcpServer(root, node.server);
473
+ if (!read.ok) return [`${id}: ${read.error}`];
474
+ const given = new Set(Object.keys(node.env ?? {}));
475
+ const unset = readsOf(read.value).flatMap(({ name, fallback }) => fallback || given.has(name) || readEnv(environ, name) !== void 0 ? [] : [`${id}: ${MCP_FILE} reads ${name}, which is not set`]);
476
+ const program = programOf(read.value);
477
+ return program === void 0 || installed(program) ? unset : [`\`${id}\`: MCP server \`${node.server}\` runs \`${program}\`, which is not installed`].concat(unset);
478
+ });
126
479
  /** The only built-in tools a fork has: it reads, and reports. */
127
480
  const FORK_TOOLS = [
128
481
  "Read",
@@ -158,7 +511,7 @@ const forkPrompt = (question, passOn) => [
158
511
  /** The fork's report: one of the options or the pass-on, and a note. */
159
512
  const forkReportSchema = (ids) => {
160
513
  const node = {
161
- type: "claude.worker",
514
+ type: "agent.worker",
162
515
  prompt: "",
163
516
  output: { schema: {
164
517
  choice: [...ids],
@@ -274,28 +627,42 @@ const askFork = async (context, owner, question, deps) => {
274
627
  if (signal.aborted) return null;
275
628
  return answerOf(outcome, [...question.options.map((option) => option.id), passOn]);
276
629
  };
277
- /** What the person sees of a question its asker's owner passed on. */
278
- const passedOnContext = (context, owner, note) => [...context === void 0 ? [] : [context, ""], note === void 0 ? `${owner} passed this on.` : `${owner} passed this on: ${note}`].join("\n");
630
+ /** What the person sees of a question its asker's owner passed on. */
631
+ const passedOnContext = (context, owner, note) => [...context === void 0 ? [] : [context, ""], note === void 0 ? `${owner} passed this on.` : `${owner} passed this on: ${note}`].join("\n");
632
+ /**
633
+ * The node's channel to the person: what it asked is withdrawn when it stops,
634
+ * so a stopped node leaves no decision open.
635
+ */
636
+ const withdrawnOnStop = (context) => {
637
+ const { human } = context;
638
+ const signalOf = (asked) => asked === void 0 ? context.signal : AbortSignal.any([asked, context.signal]);
639
+ return {
640
+ permit: (request) => human.permit({
641
+ ...request,
642
+ signal: signalOf(request.signal)
643
+ }),
644
+ ask: (question) => human.ask({
645
+ ...question,
646
+ signal: signalOf(question.signal)
647
+ }),
648
+ ...human.settled === void 0 ? {} : { settled: human.settled }
649
+ };
650
+ };
279
651
  /**
280
652
  * The child's channel when its owner takes its questions first: a question goes
281
653
  * to a fork of the owner's session, then to the person if the fork passes it on
282
654
  * or can't answer. Permission prompts go to the person as before.
283
655
  */
284
656
  const ownerFirst = (context, owner, deps) => {
285
- const { human } = context;
657
+ const human = withdrawnOnStop(context);
286
658
  return {
287
659
  permit: (request) => human.permit(request),
288
660
  ask: async (question) => {
289
661
  const answered = await askFork(context, owner, question, deps);
290
662
  if (context.signal.aborted) throw new Error(`${context.id} was stopped`);
291
- const signal = question.signal === void 0 ? context.signal : AbortSignal.any([question.signal, context.signal]);
292
- if (answered === null) return human.ask({
293
- ...question,
294
- signal
295
- });
663
+ if (answered === null) return human.ask(question);
296
664
  if (answered.choice === passOnId(question.options)) return human.ask({
297
665
  ...question,
298
- signal,
299
666
  owner: owner.id,
300
667
  context: passedOnContext(question.context, owner.id, answered.note)
301
668
  });
@@ -347,50 +714,79 @@ const withUsageOf = (first, later) => {
347
714
  */
348
715
  const afterLateTurn = (first, later) => {
349
716
  const merged = withUsageOf(first, later);
350
- return later.report === null ? {
351
- ...merged,
717
+ if (later.report !== null) return merged;
718
+ const { end: _later, ...rest } = merged;
719
+ return {
720
+ ...rest,
352
721
  report: first.report,
353
722
  subtype: first.subtype,
723
+ ...first.end === void 0 ? {} : { end: first.end },
354
724
  text: first.text,
355
725
  errors: first.errors
356
- } : merged;
726
+ };
727
+ };
728
+ /** The least of the limits that are set; `undefined` with none. */
729
+ const least = (...limits) => {
730
+ const known = limits.filter((limit) => limit !== void 0);
731
+ return known.length === 0 ? void 0 : Math.min(...known);
357
732
  };
733
+ /** A cap less what this activation used, beside what a quota says is left. */
734
+ const leftOf = (caps, used, quotaLeft) => least(...caps.map((cap) => cap === void 0 ? void 0 : cap - used), quotaLeft);
358
735
  /**
359
- * What another turn of a session may use. `spent` and `turns` are this
360
- * activation's, which neither the node's cap nor the run's budget has counted.
361
- * The quota has: the ledger took it as the session finished, so what it says is
362
- * left is left. `null` when nothing is, and the turn does not start.
736
+ * What another turn of a session may use. `spent`, `tokens` (counted, RFC-015)
737
+ * and `turns` are this activation's, which neither the node's cap nor the run's
738
+ * budget has counted. The quota has: the ledger took it as the session
739
+ * finished, so what it says is left is left. The run's tokens left have too:
740
+ * only the node's own `budgetTokens` takes `tokens` off. `null` when nothing
741
+ * is, and the turn does not start. `turnsAtLeast` lifts the turns left to that
742
+ * many (a failed check's resume: `CHECK_RETRY_TURNS`).
363
743
  */
364
- const lateLimits = (spec, { spent, turns }, runLeft, quotaLeft) => {
365
- const budgets = [...[spec.budgetUsd, runLeft].flatMap((limit) => limit === void 0 ? [] : [limit - spent]), ...quotaLeft === void 0 ? [] : [quotaLeft]];
366
- const budgetUsd = budgets.length === 0 ? void 0 : Math.min(...budgets);
367
- if (budgetUsd !== void 0 && budgetUsd <= 0) return null;
368
- const maxTurns = spec.maxTurns === void 0 ? void 0 : spec.maxTurns - turns;
369
- if (maxTurns !== void 0 && maxTurns <= 0) return null;
744
+ const lateLimits = (spec, used, runLeft, quotaLeft, tokensLeft = {}, turnsAtLeast = 0) => {
745
+ const budgetUsd = leftOf([spec.budgetUsd, runLeft], used.spent, quotaLeft);
746
+ const budgetTokens = leftOf([spec.budgetTokens], used.tokens ?? 0, least(tokensLeft.run, tokensLeft.quota));
747
+ const maxTurns = spec.maxTurns === void 0 ? void 0 : Math.max(spec.maxTurns - used.turns, turnsAtLeast);
748
+ if ([
749
+ budgetUsd,
750
+ budgetTokens,
751
+ maxTurns
752
+ ].some((left) => (left ?? 1) <= 0)) return null;
370
753
  return {
371
754
  ...budgetUsd === void 0 ? {} : { budgetUsd },
755
+ ...budgetTokens === void 0 ? {} : { budgetTokens },
372
756
  ...maxTurns === void 0 ? {} : { maxTurns }
373
757
  };
374
758
  };
375
- /** What a session may spend as it starts: the least of the budget and any quota. */
376
- const budgetNow = (context) => {
377
- if (context.budgetLeftNow === void 0) return context.budgetLeftUsd;
378
- const known = [context.budgetLeftNow(), context.quota?.().leftUsd].flatMap((left) => left === void 0 ? [] : [left]);
379
- return known.length === 0 ? void 0 : Math.min(...known);
759
+ const reached = (cap, used) => cap !== void 0 && used >= cap;
760
+ /**
761
+ * Why the run's budget stopped a session that reached its cap, if it did. A
762
+ * unit blames the run when the session reached it, or when it is the only cap
763
+ * the session had; otherwise the node's own cap in the other unit may have
764
+ * stopped it. A resumed turn's dollars are its own: its cost is the session's
765
+ * total (ADR-046) while its cap is what was left, so `priorCostUsd` comes off,
766
+ * read as `ownSpend` reads it (a lower total counted again from zero).
767
+ */
768
+ const runSpent = (run, asked, outcome, priorCostUsd) => {
769
+ const total = outcome.usage.costUsd;
770
+ const turnCostUsd = total < priorCostUsd ? total : total - priorCostUsd;
771
+ if (run.tokens !== void 0 && (reached(asked.budgetTokens, countedTokens(outcome.usage)) || asked.budgetUsd === void 0)) return `the run's budget of ${String(run.tokens)} tokens is spent`;
772
+ return run.usd !== void 0 && (reached(asked.budgetUsd, turnCostUsd) || asked.budgetTokens === void 0) ? `the run's budget of $${String(run.usd)} is spent` : void 0;
380
773
  };
381
774
  /**
382
775
  * Whether a limit across runs, rather than the node's work, ended a node: a
383
776
  * session whose cap was a quota's and that ran into it, or one its plan refused
384
- * and that gave no report. A node that still succeeded is left alone.
777
+ * and that gave no report. A session whose cap was the run's budget and that
778
+ * ran into it stops the run as `budget_exhausted` (`run`, from `runCapsOf`). A
779
+ * node that still succeeded is left alone.
385
780
  */
386
- const limitWatch = (context) => {
781
+ const limitWatch = (context, run = {}) => {
387
782
  let why;
783
+ let runWhy;
388
784
  return {
389
785
  stoppedBy: (reason) => {
390
786
  why = reason;
391
787
  },
392
- session: (asked) => {
393
- const left = context.quota?.().leftUsd;
788
+ session: (asked, priorCostUsd = 0) => {
789
+ const { leftUsd: left, leftTokens } = context.quota?.() ?? {};
394
790
  let refused;
395
791
  return {
396
792
  saw: (event) => {
@@ -398,26 +794,151 @@ const limitWatch = (context) => {
398
794
  },
399
795
  ended: (outcome) => {
400
796
  if (refused !== void 0 && (outcome?.report ?? null) === null) why = refused;
401
- if (left !== void 0 && asked.budgetUsd !== void 0 && asked.budgetUsd >= left && outcome?.subtype === "error_max_budget_usd") why = context.quota?.().blocked ?? "a session reached what was left of a quota";
797
+ const capped = left !== void 0 && asked.budgetUsd !== void 0 && asked.budgetUsd >= left || leftTokens !== void 0 && asked.budgetTokens !== void 0 && asked.budgetTokens >= leftTokens;
798
+ const spent = outcome !== null && outcomeEnd(outcome).kind === "budget_limit";
799
+ if (capped && spent) why = context.quota?.().blocked ?? "a session reached what was left of a quota";
800
+ else if (spent) runWhy = runSpent(run, asked, outcome, priorCostUsd);
402
801
  }
403
802
  };
404
803
  },
405
- result: (result) => result.ok || why === void 0 ? result : {
406
- ...result,
407
- limited: why
804
+ result: (result) => {
805
+ if (result.ok) return result;
806
+ if (why !== void 0) return {
807
+ ...endedWith(result, endFor("plan_limit", {}, why)),
808
+ limited: why
809
+ };
810
+ if (runWhy !== void 0) return {
811
+ ...result,
812
+ limited: runWhy,
813
+ stopsAs: "budget_exhausted"
814
+ };
815
+ const { end } = result;
816
+ return end?.kind === "plan_limit" ? {
817
+ ...result,
818
+ limited: end.message
819
+ } : result;
408
820
  }
409
821
  };
410
822
  };
411
- /** No session started: nothing was left for one. */
412
- const notStarted = (reason, limited) => ({
413
- ok: false,
414
- error: `not started: ${reason}`,
415
- output: {
823
+ /**
824
+ * No session started: nothing was left for one, or nothing to start it. Its
825
+ * summary keeps Ork's reason, `end.detail` too; the message is the kind's.
826
+ */
827
+ const notStarted = (kind, reason, limited) => ({
828
+ ...failedWith(endFor(kind, {
829
+ reason,
830
+ program: "an agent driver"
831
+ }, reason), {
416
832
  status: "fail",
417
833
  summary: `not started: ${reason}`
418
- },
834
+ }),
419
835
  ...limited === void 0 ? {} : { limited }
420
836
  });
837
+ // ../nodes/src/session-driver.ts
838
+ /**
839
+ * @file The driver a Claude node's session starts on (core's `sessionDriver`),
840
+ * and, when its model names that driver (`command-code.deepseek/…`, RFC-015),
841
+ * the fit check on the value an item rendered: the run's own check before it
842
+ * started saw only the written model.
843
+ */
844
+ /** What stops the node from running on `driver`, in one line, or nothing. */
845
+ const refusalOn = (context, driver, drivers) => {
846
+ const id = context.template ?? context.id;
847
+ const node = context.workflow.nodes[id];
848
+ const descriptor = drivers.find((d) => d.id === driver);
849
+ if (node === void 0 || descriptor === void 0) return void 0;
850
+ const refusals = sessionRefusals(id, node, context.workflow, descriptor);
851
+ return refusals.length === 0 ? void 0 : refusals.map((r) => r.message).join("; ");
852
+ };
853
+ /** The session's driver, or the node's result when none fits. */
854
+ const sessionDriverOf = (context, plan, options) => {
855
+ const driver = sessionDriver({
856
+ pinned: plan.pinned,
857
+ override: options.driver,
858
+ named: plan.driver,
859
+ ready: options.readyDriver
860
+ });
861
+ if (driver === void 0) return notStarted("tool_missing", "no agent driver is ready; see `ork doctor`");
862
+ const unfit = plan.pinned === void 0 ? void 0 : refusalOn(context, driver, options.drivers ?? AGENT_DRIVERS);
863
+ return unfit === void 0 ? driver : notStarted("invalid_node", unfit);
864
+ };
865
+ // ../nodes/src/start-budgets.ts
866
+ /** What a session may spend as it starts: the least of the budget and any quota. */
867
+ const budgetNow = (context) => context.budgetLeftNow === void 0 ? context.budgetLeftUsd : least(context.budgetLeftNow(), context.quota?.().leftUsd);
868
+ /** The counted tokens a session may use as it starts: the run's and a quota's. */
869
+ const tokensNow = (context) => least(context.tokensLeftNow?.(), context.quota?.().leftTokens);
870
+ /**
871
+ * What is `left` now, with `own` (the node's cap) set aside through `reserve`
872
+ * in the same step; with nothing left, it waits for a worker's reserve to
873
+ * free.
874
+ */
875
+ const startWith = async (context, left, own, reserve) => {
876
+ const now = left();
877
+ if (now !== void 0 && now <= 0) {
878
+ context.reserveBudget?.(0);
879
+ context.reserveTokens?.(0);
880
+ return await context.budgetFreed?.(context.signal) === true ? startWith(context, left, own, reserve) : now;
881
+ }
882
+ reserve?.(own === void 0 || now === void 0 ? now : Math.min(own, now));
883
+ return now;
884
+ };
885
+ /**
886
+ * What a node's first session may spend, set aside from the run's budget in the
887
+ * same step that reads it (ADR-077), so a node starting beside it is never
888
+ * offered the same part: the least of what is left and the node's own
889
+ * `budgetUsd`. With nothing left, it waits for a worker running beside it to
890
+ * finish, as long as one may.
891
+ */
892
+ const budgetToStart = (context) => startWith(context, () => budgetNow(context), ownCaps(context).usd, context.reserveBudget);
893
+ /** The node's own caps: its `budgetUsd` and `budgetTokens`, else the defaults'. */
894
+ const ownCaps = (context) => isClaudeNode(context.node) ? {
895
+ usd: context.node.budgetUsd ?? context.workflow.defaults?.budgetUsd,
896
+ tokens: context.node.budgetTokens ?? context.workflow.defaults?.budgetTokens
897
+ } : {};
898
+ /**
899
+ * Where the run's budget, not the node's own cap, caps the node's first
900
+ * session: the run has a budget in that unit, and what `startBudgets` left the
901
+ * session is below the node's own cap, or it has none. A later turn of the
902
+ * session keeps them: both its caps go down by what it used, and the run's by
903
+ * what others used too.
904
+ */
905
+ const runCapsOf = (context, planOptions) => {
906
+ const own = ownCaps(context);
907
+ const limits = context.workflow.limits;
908
+ const caps = {};
909
+ const usd = planOptions.budgetLeftUsd;
910
+ if (context.budgetLeftNow?.() !== void 0 && limits?.budgetUsd !== void 0 && usd !== void 0 && usd < (own.usd ?? Infinity)) caps.usd = limits.budgetUsd;
911
+ const tokens = planOptions.budgetLeftTokens;
912
+ if (context.tokensLeftNow?.() !== void 0 && limits?.budgetTokens !== void 0 && tokens !== void 0 && tokens < (own.tokens ?? Infinity)) caps.tokens = limits.budgetTokens;
913
+ return caps;
914
+ };
915
+ /** `budgetToStart` in counted tokens: the run's `budgetTokens` and a quota's. */
916
+ const tokensToStart = (context) => startWith(context, () => tokensNow(context), ownCaps(context).tokens, context.reserveTokens);
917
+ /**
918
+ * What a node's first session may spend, in dollars and in counted tokens, set
919
+ * into `planOptions`; the node's result instead when nothing is left of
920
+ * either.
921
+ */
922
+ const startBudgets = async (context, planOptions) => {
923
+ const usd = await budgetToStart(context);
924
+ if (usd !== void 0 && usd <= 0) return notStarted("budget_limit", "nothing is left of the run's budget");
925
+ if (usd !== void 0) planOptions.budgetLeftUsd = usd;
926
+ const waited = { done: false };
927
+ const tokens = await tokensToStart({
928
+ ...context,
929
+ budgetFreed: async (signal) => {
930
+ waited.done = true;
931
+ return await context.budgetFreed?.(signal) ?? false;
932
+ }
933
+ });
934
+ if (tokens !== void 0 && tokens <= 0) return notStarted("budget_limit", "nothing is left of the run's tokens");
935
+ if (waited.done) {
936
+ context.reserveTokens?.(0);
937
+ return startBudgets(context, planOptions);
938
+ }
939
+ if (tokens !== void 0) planOptions.budgetLeftTokens = tokens;
940
+ return null;
941
+ };
421
942
  // ../nodes/src/tool-output.ts
422
943
  /**
423
944
  * @file A tool node's structured result (`output: { format: json }`): the file
@@ -660,315 +1181,172 @@ const tokenize = (command) => {
660
1181
  args
661
1182
  };
662
1183
  };
663
- // ../nodes/src/environ.ts
664
- /** A variable of the user's. */
665
- const readEnv = (environ, name) => Object.hasOwn(environ, name) ? environ[name] : void 0;
666
- // ../nodes/src/mcp-servers.ts
667
1184
  /**
668
- * @file Reading an MCP server's definition from the project's `.mcp.json`, the
669
- * file Claude Code reads too, so a server is defined in one place (RFC-006).
670
- * `${VAR}` and `${VAR:-default}` read the user's environment, which is where
671
- * secrets stay: Ork stores none, and a run masks each value read this way. A
672
- * server that needs an OAuth sign-in holds Claude Code's tokens, not Ork's,
673
- * so it is refused.
1185
+ * Runs `execute`, or, with `once: true`, skips it after a pass on these files:
1186
+ * `null` then, and `verify.skipped` is recorded. `shown` is the scrubbed
1187
+ * command.
674
1188
  */
675
- const MCP_FILE = ".mcp.json";
676
- const VARIABLE = /\$\{([A-Za-z_]\w*)(?::-([^}]*))?\}/gu;
677
- const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
678
- /** The file's `mcpServers`; `{}` when there is no file. */
679
- const serversIn = (root) => {
680
- const file = path.join(root, MCP_FILE);
681
- if (!existsSync(file)) return {
682
- ok: true,
683
- value: {}
684
- };
685
- try {
686
- const parsed = JSON.parse(readFileSync(file, "utf8"));
687
- const servers = isRecord(parsed) ? parsed["mcpServers"] : void 0;
688
- return {
689
- ok: true,
690
- value: isRecord(servers) ? servers : {}
691
- };
692
- } catch (error) {
693
- const reason = error instanceof SyntaxError ? "it is not valid JSON" : error instanceof Error ? error.message : String(error);
694
- return {
695
- ok: false,
696
- error: `${MCP_FILE} can't be read: ${reason}`
697
- };
698
- }
699
- };
700
- const stringsOf = (value) => isRecord(value) ? Object.fromEntries(Object.entries(value).flatMap(([key, item]) => typeof item === "string" ? [[key, item]] : [])) : {};
701
- const listOf = (value) => Array.isArray(value) ? value.flatMap((item) => typeof item === "string" ? [item] : []) : [];
702
- /** A server's field that holds something other than text: it would be dropped. */
703
- const notTextIn = (server) => [
704
- "args",
705
- "env",
706
- "headers"
707
- ].find((field) => {
708
- const value = server[field];
709
- return (Array.isArray(value) ? value : isRecord(value) ? Object.values(value) : []).some((item) => typeof item !== "string");
710
- });
711
- /** The server as written, or why Ork can't call it. */
712
- const readMcpServer = (root, name) => {
713
- const servers = serversIn(root);
714
- if (!servers.ok) return servers;
715
- const known = Object.keys(servers.value);
716
- const server = Object.hasOwn(servers.value, name) ? servers.value[name] : void 0;
717
- if (!isRecord(server)) return {
718
- ok: false,
719
- error: `no MCP server \`${name}\` in ${MCP_FILE}${known.length === 0 ? "" : `, which has ${known.join(", ")}`}`
720
- };
721
- if ("oauth" in server) return {
722
- ok: false,
723
- error: `MCP server \`${name}\` signs in with OAuth, whose tokens are Claude Code's: give it a token through headers and \`env:\`, or call it from a session`
724
- };
725
- const notText = notTextIn(server);
726
- if (notText !== void 0) return {
727
- ok: false,
728
- error: `MCP server \`${name}\` has a value in ${notText} that isn't a string: write it in quotes`
729
- };
730
- const { type, url, command } = server;
731
- if (type === "http" && typeof url === "string") return {
732
- ok: true,
733
- value: {
734
- kind: "http",
735
- url,
736
- headers: stringsOf(server["headers"])
737
- }
738
- };
739
- if ((type === void 0 || type === "stdio") && typeof command === "string") return {
740
- ok: true,
741
- value: {
742
- kind: "stdio",
743
- command,
744
- args: listOf(server["args"]),
745
- env: stringsOf(server["env"])
1189
+ const runUnlessPassed = async ({ node, context, cwd, shown, environ, execute }) => {
1190
+ if (node.once !== true) return execute();
1191
+ const done = {};
1192
+ const once = await runOnce({
1193
+ cwd,
1194
+ name: node.script === void 0 ? shown : `${shown}\n${node.script}`,
1195
+ who: `${context.runId}/${context.id}`,
1196
+ env: environ,
1197
+ run: async () => {
1198
+ done.run = await execute();
1199
+ return done.run.exitCode;
746
1200
  }
747
- };
748
- return {
749
- ok: false,
750
- error: `MCP server \`${name}\` is not one Ork can call: it takes stdio and http servers`
751
- };
752
- };
753
- /** Each variable a server reads, and whether it has a default. */
754
- const readsOf = (server) => {
755
- return (server.kind === "stdio" ? [
756
- server.command,
757
- ...server.args,
758
- ...Object.values(server.env)
759
- ] : [server.url, ...Object.values(server.headers)]).flatMap((text) => [...text.matchAll(VARIABLE)].map((match) => ({
760
- name: String(match[1]),
761
- fallback: match[2] !== void 0
762
- })));
763
- };
764
- /**
765
- * The server with its variables filled in, and the values to mask: all it read
766
- * but those `plain` names, which aren't secret.
767
- */
768
- const expandServer = (server, environ, plain = []) => {
769
- const unset = readsOf(server).find(({ name, fallback }) => !fallback && readEnv(environ, name) === void 0);
770
- if (unset !== void 0) return {
771
- ok: false,
772
- error: `${MCP_FILE} reads ${unset.name}, which is not set`
773
- };
774
- const values = [];
775
- const fill = (text) => text.replaceAll(VARIABLE, (_all, name, fallback) => {
776
- const value = readEnv(environ, name);
777
- if (value !== void 0 && !plain.includes(name)) values.push(value);
778
- return value ?? fallback ?? "";
779
1201
  });
780
- const fillAll = (record) => Object.fromEntries(Object.entries(record).map(([key, text]) => [key, fill(text)]));
781
- return {
782
- ok: true,
783
- value: {
784
- server: server.kind === "stdio" ? {
785
- kind: "stdio",
786
- command: fill(server.command),
787
- args: server.args.map((arg) => fill(arg)),
788
- env: fillAll(server.env)
789
- } : {
790
- kind: "http",
791
- url: fill(server.url),
792
- headers: fillAll(server.headers)
793
- },
794
- values
795
- }
796
- };
797
- };
798
- /** What a workflow's MCP servers read of the user's, unless called not secret. */
799
- const mcpVariablesOf = (workflow, root) => Object.values(workflow.nodes).flatMap((node) => {
800
- if (node.type !== "mcp.call") return [];
801
- const read = readMcpServer(root, node.server);
802
- return read.ok ? readsOf(read.value).map(({ name }) => name).filter((name) => node.plainEnv?.includes(name) !== true) : [];
803
- });
804
- /** A stdio server's program, when its name can be known before it is filled in. */
805
- const programOf = (server) => server.kind === "stdio" && !server.command.includes("$") ? server.command : void 0;
806
- /**
807
- * What stops a workflow's `mcp.call` nodes, found before a run spends anything:
808
- * a server that isn't there or can't be called, a program that isn't installed,
809
- * or a variable that is not set.
810
- */
811
- const mcpProblems = (workflow, root, environ = process.env, finished = /* @__PURE__ */ new Set(), installed = () => true) => Object.entries(workflow.nodes).flatMap(([id, node]) => {
812
- if (node.type !== "mcp.call" || finished.has(id)) return [];
813
- const read = readMcpServer(root, node.server);
814
- if (!read.ok) return [`${id}: ${read.error}`];
815
- const given = new Set(Object.keys(node.env ?? {}));
816
- const unset = readsOf(read.value).flatMap(({ name, fallback }) => fallback || given.has(name) || readEnv(environ, name) !== void 0 ? [] : [`${id}: ${MCP_FILE} reads ${name}, which is not set`]);
817
- const program = programOf(read.value);
818
- return program === void 0 || installed(program) ? unset : [`\`${id}\`: MCP server \`${node.server}\` runs \`${program}\`, which is not installed`].concat(unset);
819
- });
820
- // ../nodes/src/tool-scrub.ts
821
- /**
822
- * @file Masking a secret in what a program prints. A plain search, so a value
823
- * of any size or shape takes time in step with the text: it looks in the text
824
- * as it is, and with JSON's escapes decoded once and twice, so that it does
825
- * not matter which characters an encoder chose to escape. It masks the value,
826
- * and the pieces programs print on their own: each line, and each value of a
827
- * JSON document. Other encodings, such as base64, are out of its reach
828
- * (L81).
829
- */
830
- /** Shorter values than this would mask ordinary text: `1`, `true`, a word. */
831
- const MIN_SECRET_LENGTH = 8;
832
- const SHORT_ESCAPES = {
833
- "\"": "\"",
834
- "\\": "\\",
835
- "/": "/",
836
- b: "\b",
837
- f: "\f",
838
- n: "\n",
839
- r: "\r",
840
- t: " "
841
- };
842
- /** The unit an escape at `at` stands for, and how long it is written. */
843
- const escapeAt = (text, at) => {
844
- const next = text.charAt(at + 1);
845
- const short = SHORT_ESCAPES[next];
846
- if (short !== void 0) return {
847
- unit: short,
848
- width: 2
849
- };
850
- if (next !== "u") return null;
851
- const hex = text.slice(at + 2, at + 6);
852
- return /^[\da-f]{4}$/iu.test(hex) ? {
853
- unit: String.fromCodePoint(Number(`0x${hex}`)),
854
- width: 6
855
- } : null;
856
- };
857
- /** The last mark at or before `index`, by a binary search. */
858
- const lastMark = (marks, index) => {
859
- let low = 0;
860
- let high = marks.length - 1;
861
- while (low < high) {
862
- const middle = Math.ceil((low + high) / 2);
863
- if ((marks[middle] ?? 0) <= index) low = middle;
864
- else high = middle - 1;
865
- }
866
- return low;
867
- };
868
- /** The view with its escapes decoded; `null` when it has none. */
869
- const decoded = (view) => {
870
- const { text } = view;
871
- const parts = [];
872
- const decodedAt = [0];
873
- const sourceAt = [0];
874
- let from = 0;
875
- let length = 0;
876
- let at = text.indexOf("\\");
877
- while (at !== -1) {
878
- const escape = escapeAt(text, at);
879
- if (escape === null) at = text.indexOf("\\", at + 1);
880
- else {
881
- parts.push(text.slice(from, at), escape.unit);
882
- length += at - from;
883
- decodedAt.push(length);
884
- sourceAt.push(at);
885
- length += 1;
886
- from = at + escape.width;
887
- decodedAt.push(length);
888
- sourceAt.push(from);
889
- at = text.indexOf("\\", from);
890
- }
891
- }
892
- if (from === 0) return null;
893
- parts.push(text.slice(from));
894
- return {
895
- text: parts.join(""),
896
- toSource: (index) => {
897
- const mark = lastMark(decodedAt, index);
898
- return view.toSource((sourceAt[mark] ?? 0) + (index - (decodedAt[mark] ?? 0)));
899
- }
900
- };
1202
+ if (!once.skipped) return done.run ?? execute();
1203
+ context.skipped?.(shown, once.pass);
1204
+ return null;
901
1205
  };
902
- /** The text as it is, then decoded as far as JSON inside JSON goes. */
903
- const viewsOf = (text) => {
904
- const raw = {
905
- text,
906
- toSource: (index) => index
1206
+ // ../nodes/src/tool-dash.ts
1207
+ /**
1208
+ * @file The leading-`-` guard on a tool node's command: an argument that starts
1209
+ * with a template reading `nodes.*.output` and renders to a leading `-` would
1210
+ * read as an option of the program, so it is refused unless a `--` comes
1211
+ * before it. A literal `-x` and `--flag={{…}}` are not affected. Also how a
1212
+ * tool node renders one argument (or its `cwd`) with that guard.
1213
+ */
1214
+ /** The error for a rendered argument that reads as an option, or `undefined`. */
1215
+ const leadingDashError = (parts, value) => {
1216
+ const first = parts[0];
1217
+ if (!value.startsWith("-") || typeof first !== "object" || first.expr.kind !== "path" || first.expr.segments[0] !== "nodes" || first.expr.segments[2] !== "output") return;
1218
+ return `argument from nodes.${String(first.expr.segments[1])}.output starts with \`-\`; put \`--\` before it in the command`;
1219
+ };
1220
+ /** One templated argument, rendered; `guardDash` refuses a leading `-`. */
1221
+ const renderArg = (text, context, guardDash = false) => {
1222
+ const template = parseTemplate(text);
1223
+ if (!template.ok) return {
1224
+ ok: false,
1225
+ error: template.message
1226
+ };
1227
+ const value = renderTemplate(template.parts, runScope(context.inputs, context.outputs, context.runId, context.item));
1228
+ const error = guardDash ? leadingDashError(template.parts, value) : void 0;
1229
+ return error === void 0 ? {
1230
+ ok: true,
1231
+ value
1232
+ } : {
1233
+ ok: false,
1234
+ error
907
1235
  };
908
- const once = decoded(raw);
909
- const twice = once === null ? null : decoded(once);
910
- return [
911
- raw,
912
- ...once === null ? [] : [once],
913
- ...twice === null ? [] : [twice]
914
- ];
915
1236
  };
916
- /** Where each secret is in the text, overlapping ones joined. */
917
- const rangesOf = (text, secrets) => {
918
- const found = [];
919
- for (const view of viewsOf(text)) for (const secret of secrets) {
920
- let at = view.text.indexOf(secret);
921
- while (at !== -1) {
922
- found.push([view.toSource(at), view.toSource(at + secret.length)]);
923
- at = view.text.indexOf(secret, at + secret.length);
924
- }
925
- }
926
- const joined = [];
927
- for (const [start, end] of found.toSorted((a, b) => a[0] - b[0])) {
928
- const last = joined.at(-1);
929
- if (last !== void 0 && start < last[1]) last[1] = Math.max(last[1], end);
930
- else joined.push([start, end]);
1237
+ // ../nodes/src/tool-ends.ts
1238
+ /**
1239
+ * @file How a tool node ended (specs/runtime/session-ends.md): a node that
1240
+ * could not run its command, JSON that broke its declared output, or how a
1241
+ * command that ran exited: done, stopped, never started, signed out, or
1242
+ * failed. Ork's message names the program; its stderr is the detail.
1243
+ */
1244
+ /** A node that could not run its command: its own definition is wrong. */
1245
+ const failed$1 = (error, kind = "invalid_node") => endedWith({
1246
+ ok: false,
1247
+ output: {
1248
+ exitCode: -1,
1249
+ stdout: "",
1250
+ stderr: error
931
1251
  }
932
- return joined;
1252
+ }, endFor(kind, { reason: error }, error));
1253
+ /** JSON that broke what the node declares, or that it could not read. */
1254
+ const invalidOutput = (output, reason) => endedWith({
1255
+ ok: false,
1256
+ output
1257
+ }, endFor("invalid_output", { reason }));
1258
+ /**
1259
+ * How a command that ran and exited non-zero ended: stopped, never started, a
1260
+ * sign-in its program asks for (`signedOut`), or a plain failure.
1261
+ */
1262
+ const exitKind = (run, context, signedOut) => {
1263
+ if (context.signal.aborted) return "interrupted";
1264
+ if (run.exitCode === -1 && /\bENOENT\b/u.test(run.stderr)) return "tool_missing";
1265
+ return signedOut?.(run) === true ? "auth" : "command_failed";
1266
+ };
1267
+ /** How a command that ran ended, by its exit code; `program` is scrubbed. */
1268
+ const exitEnd = (output, run, context, program, signedOut) => {
1269
+ if (run.exitCode === 0) return endedWith({
1270
+ ok: true,
1271
+ output
1272
+ }, endFor("done"));
1273
+ const end = endFor(exitKind(run, context, signedOut), {
1274
+ program,
1275
+ exitCode: run.exitCode
1276
+ }, run.stderr.trim().slice(-END_DETAIL_LIMIT));
1277
+ return endedWith({
1278
+ ok: false,
1279
+ output
1280
+ }, end);
933
1281
  };
934
- /** The strings and numbers inside a parsed JSON document. */
935
- const leavesOf = (value) => {
936
- if (typeof value === "string") return [value];
937
- if (typeof value === "number") return [String(value)];
938
- if (typeof value === "object" && value !== null) return Object.values(value).flatMap((item) => leavesOf(item));
939
- return [];
1282
+ /** The result of a skipped check: it passed, and printed nothing this time. */
1283
+ const skippedCheck = (outputFile) => {
1284
+ if (outputFile !== void 0) removeOutputFile(outputFile);
1285
+ return endedWith({
1286
+ ok: true,
1287
+ output: {
1288
+ exitCode: 0,
1289
+ stdout: "",
1290
+ stderr: ""
1291
+ }
1292
+ }, endFor("done"));
940
1293
  };
941
- /** A value's leaves, when it is a JSON object or array. */
942
- const jsonLeavesOf = (value) => {
943
- if (!/^\s*[[{]/u.test(value)) return [];
1294
+ // ../nodes/src/run-marker.ts
1295
+ /**
1296
+ * @file The marker a run leaves on its sessions and programs (ADR-047), and the
1297
+ * stores it guards: the commands that act as the user refuse in the stores of
1298
+ * the runs that started a process, and everywhere under a marker that lists
1299
+ * none, which an older `ork` sets.
1300
+ */
1301
+ /** Set for a program in a run. */
1302
+ const RUN_ENV = "ORK_RUN";
1303
+ /** Set for a Claude session. It says no more than that: no run, no node. */
1304
+ const SESSION_ENV = "ORK_SESSION";
1305
+ /**
1306
+ * The stores whose runs started a session or program, as a JSON array of real
1307
+ * paths: the commands that act as the user refuse only in these.
1308
+ */
1309
+ const GUARDED_STORES_ENV = "ORK_GUARDED_STORES";
1310
+ /**
1311
+ * The variable that says a program in a run or a session started this process,
1312
+ * or `null` when none does.
1313
+ */
1314
+ const runMarkerIn = (env) => ["ORK_RUN", "ORK_SESSION"].find((name) => env[name] !== void 0) ?? null;
1315
+ /** The stores `env` lists, or `undefined` when it lists none or garbles it. */
1316
+ const listIn = (env) => {
1317
+ const text = env[GUARDED_STORES_ENV];
1318
+ if (text === void 0) return void 0;
944
1319
  try {
945
- return leavesOf(JSON.parse(value));
1320
+ const value = JSON.parse(text);
1321
+ return Array.isArray(value) && value.every((item) => typeof item === "string") ? value : void 0;
946
1322
  } catch {
947
- return [];
1323
+ return;
948
1324
  }
949
1325
  };
950
- /** A value, and the pieces of it a program may well print on their own. */
951
- const formsOf = (value) => [
952
- value,
953
- value.trim(),
954
- ...value.split(/\r?\n|\r/u).map((line) => line.trim()),
955
- ...jsonLeavesOf(value)
956
- ];
957
- /** Replaces the values, in any form JSON writes them, with `[masked]`. */
958
- const scrubberOf = (values) => {
959
- const secrets = [...new Set(values.flatMap((value) => formsOf(value)))].filter((secret) => secret.trim().length >= MIN_SECRET_LENGTH);
960
- return (text) => {
961
- if (secrets.length === 0) return text;
962
- const parts = [];
963
- let from = 0;
964
- for (const [start, end] of rangesOf(text, secrets)) {
965
- parts.push(text.slice(from, start), "[masked]");
966
- from = end;
967
- }
968
- parts.push(text.slice(from));
969
- return parts.join("");
970
- };
1326
+ /** A store file's real path, so a symlinked `~/.ork` is the same store. */
1327
+ const realStore = (store) => {
1328
+ try {
1329
+ return realpathSync(store);
1330
+ } catch {
1331
+ return path.resolve(store);
1332
+ }
971
1333
  };
1334
+ /**
1335
+ * The stores a run that started this process is in: `null` when no run did, and
1336
+ * `"all"` when one did but listed none (an older `ork`), which fails closed.
1337
+ */
1338
+ const guardedStoresIn = (env) => runMarkerIn(env) === null ? null : listIn(env) ?? "all";
1339
+ /** Whether a run in `store` (a store file) started this process. */
1340
+ const storeGuarded = (env, store) => {
1341
+ const guarded = guardedStoresIn(env);
1342
+ return guarded === "all" || guarded !== null && guarded.includes(realStore(store));
1343
+ };
1344
+ /**
1345
+ * What a run in `store` (default: the user's) tells its sessions and programs:
1346
+ * the stores it inherited, and its own. Nothing when it inherited "all", so
1347
+ * that holds.
1348
+ */
1349
+ const guardOf = (env, store = storePath()) => guardedStoresIn(env) === "all" ? {} : { [GUARDED_STORES_ENV]: JSON.stringify([.../* @__PURE__ */ new Set([...listIn(env) ?? [], realStore(store)])]) };
972
1350
  // ../nodes/src/tool-env.ts
973
1351
  /**
974
1352
  * @file What a tool node's program sees besides what it inherits: who it is
@@ -980,16 +1358,13 @@ const scrubberOf = (values) => {
980
1358
  const OUTPUT_ENV = "ORK_OUTPUT";
981
1359
  /** A file a `watch:` node's program keeps between looks (RFC-011). */
982
1360
  const STATE_ENV = "ORK_STATE";
983
- /** Set for a program in a run. */
984
- const RUN_ENV = "ORK_RUN";
985
- /** Set for a Claude session. It says no more than that: no run, no node. */
986
- const SESSION_ENV = "ORK_SESSION";
987
1361
  /** The variables a caller's run reads, for a run it calls to mask too. */
988
1362
  const MASKED_ENV = "ORK_MASKED";
989
1363
  /** Every variable Ork sets itself; a workflow's `env:` cannot set them. */
990
1364
  const IDENTITY_KEYS = /* @__PURE__ */ new Set([
991
1365
  RUN_ENV,
992
1366
  SESSION_ENV,
1367
+ GUARDED_STORES_ENV,
993
1368
  MASKED_ENV,
994
1369
  "ORK_NODE",
995
1370
  "ORK_ROUND",
@@ -998,7 +1373,9 @@ const IDENTITY_KEYS = /* @__PURE__ */ new Set([
998
1373
  "ORK_WORKFLOW",
999
1374
  "ORK_BIN",
1000
1375
  OUTPUT_ENV,
1001
- STATE_ENV
1376
+ STATE_ENV,
1377
+ "ORK_ITEM",
1378
+ "ORK_INDEX"
1002
1379
  ]);
1003
1380
  /**
1004
1381
  * `env` without Ork's identity variables. A run started by a program in another
@@ -1006,27 +1383,32 @@ const IDENTITY_KEYS = /* @__PURE__ */ new Set([
1006
1383
  */
1007
1384
  const withoutIdentity = (env) => Object.fromEntries(Object.entries(env).flatMap(([key, value]) => value === void 0 || IDENTITY_KEYS.has(key) ? [] : [[key, value]]));
1008
1385
  /**
1009
- * What a Claude session runs with: no identity, only the marker that stops its
1010
- * Bash from speaking as the user through `ork chat`.
1386
+ * What a Claude session of a run in `store` (default: the user's) runs with: no
1387
+ * identity, only the marker that stops its Bash from speaking as the user
1388
+ * through `ork chat`, and the stores it must not do that in.
1011
1389
  */
1012
- const sessionEnv = (env) => ({
1390
+ const sessionEnv = (env, store) => ({
1013
1391
  ...withoutIdentity(env),
1014
- [SESSION_ENV]: "1"
1392
+ [SESSION_ENV]: "1",
1393
+ ...guardOf(env, store)
1015
1394
  });
1016
1395
  /**
1017
- * The variable that says a program in a run or a session started this process,
1018
- * or `null` when none does.
1396
+ * Who a node's program is, and the stores its run guards (none listed without
1397
+ * `store`, which guards every one). It is attribution, not a boundary.
1019
1398
  */
1020
- const runMarkerIn = (env) => ["ORK_RUN", SESSION_ENV].find((name) => env[name] !== void 0) ?? null;
1021
- /** Who a node's program is. It is attribution, not a security boundary. */
1022
- const identityOf = (context, orkBin) => ({
1399
+ const identityOf = (context, orkBin, environ = {}, store) => ({
1400
+ ...store === void 0 ? {} : guardOf(environ, store),
1023
1401
  [RUN_ENV]: context.runId,
1024
1402
  ORK_NODE: context.id,
1025
1403
  ORK_ROUND: String(context.round),
1026
1404
  ORK_RUN_DIR: context.runDir,
1027
1405
  ORK_PROJECT: context.projectRoot,
1028
1406
  ORK_WORKFLOW: context.workflow.name,
1029
- ...orkBin === void 0 ? {} : { ORK_BIN: orkBin }
1407
+ ...orkBin === void 0 ? {} : { ORK_BIN: orkBin },
1408
+ ...context.item === void 0 ? {} : {
1409
+ ORK_ITEM: typeof context.item.value === "string" ? context.item.value : JSON.stringify(context.item.value),
1410
+ ORK_INDEX: String(context.item.index)
1411
+ }
1030
1412
  });
1031
1413
  /** The variables a workflow's nodes read that no `plainEnv` excuses. */
1032
1414
  const secretNamesOf = (workflow) => Object.values(workflow.nodes).flatMap((node) => programsRunBy(node, workflow.defaults)).flatMap(({ program }) => {
@@ -1043,6 +1425,23 @@ const inheritedNames = (environ) => (environ[MASKED_ENV] ?? "").split(",").filte
1043
1425
  */
1044
1426
  const maskedNamesOfRun = (workflow, own, registered, environ = process.env) => [...new Set([workflow, ...calledWorkflows(workflow, own, registered, { everyCall: true })].flatMap((one) => secretNamesOf(one)).concat(mcpVariablesOf(workflow, own.root), inheritedNames(environ)))];
1045
1427
  /**
1428
+ * The env-value scrubber of a recorded run, from its workflow file as it is
1429
+ * now, for what is written to it from outside the run (a person's note);
1430
+ * `undefined` when the file is gone or no longer parses.
1431
+ */
1432
+ const scrubberOfRun = (run, registered, environ = process.env) => {
1433
+ let text;
1434
+ try {
1435
+ text = readFileSync(run.file, "utf8");
1436
+ } catch {
1437
+ return;
1438
+ }
1439
+ const parsed = parseWorkflow(text);
1440
+ if (!parsed.ok) return void 0;
1441
+ const own = placeFor(path.dirname(run.file), registered);
1442
+ return runScrubber(maskedNamesOfRun(parsed.workflow, own, registered, environ), environ);
1443
+ };
1444
+ /**
1046
1445
  * A program learns the names too, never their values: `ork send` masks with
1047
1446
  * them before it writes to the store, and a run the program starts passes them
1048
1447
  * on.
@@ -1052,23 +1451,17 @@ const namesToMask = (workflow, environ, also = []) => [.../* @__PURE__ */ new Se
1052
1451
  ...also,
1053
1452
  ...inheritedNames(environ)
1054
1453
  ])];
1055
- const valuesOf = (names, environ) => names.flatMap((name) => {
1056
- const value = readEnv(environ, name);
1057
- return value === void 0 ? [] : [value];
1058
- });
1059
1454
  /** What a process in a program masks: what its run told it to. */
1060
- const inheritedScrubber = (environ) => scrubberOf(valuesOf(inheritedNames(environ), environ));
1061
- /** What a run masks in a message of its own: the same names' values. */
1062
- const runScrubber = (names, environ) => scrubberOf(valuesOf(names, environ));
1455
+ const inheritedScrubber = (environ) => scrubberOf(envValuesOf(inheritedNames(environ), environ));
1063
1456
  /** What a run tells a run it calls: names, never values. */
1064
1457
  const maskedEnv = (names) => names.length === 0 ? {} : { [MASKED_ENV]: names.join(",") };
1065
1458
  /** The values to mask: what the workflow reads, and the names given. */
1066
- const secretsOf = (workflow, environ, also = []) => valuesOf(namesToMask(workflow, environ, also), environ);
1459
+ const secretsOf = (workflow, environ, also = []) => envValuesOf(namesToMask(workflow, environ, also), environ);
1067
1460
  /** A node's `env:`, rendered. */
1068
1461
  const renderEnv = (node, context, environ) => {
1069
1462
  const values = {};
1070
1463
  const scope = {
1071
- ...runScope(context.inputs, context.outputs, context.runId),
1464
+ ...runScope(context.inputs, context.outputs, context.runId, context.item),
1072
1465
  output: { env: environ }
1073
1466
  };
1074
1467
  for (const [key, text] of Object.entries(node.env ?? {})) {
@@ -1141,26 +1534,22 @@ const clearStateFiles = (runDir, warn) => {
1141
1534
  * without a shell, checked against `allow` (or the integration's default),
1142
1535
  * its arguments and observed output fields from the integration's adapter,
1143
1536
  * long output kept in run files. It sees who it is and the node's `env:`
1144
- * (`tool-env.ts`), and may return JSON (`tool-output.ts`).
1537
+ * (`tool-env.ts`), may return JSON (`tool-output.ts`), and may run once per
1538
+ * set of files (`runUnlessPassed` in `tool-command.ts`).
1145
1539
  */
1146
1540
  /** How much of stdout and stderr a node's output keeps. */
1147
1541
  const OUTPUT_LIMIT = 65536;
1148
1542
  const ERROR_LIMIT = 1024;
1543
+ /** The interpreters whose script's commands a heavy check reads. */
1544
+ const SHELLS = /* @__PURE__ */ new Set([
1545
+ "sh",
1546
+ "bash",
1547
+ "zsh"
1548
+ ]);
1149
1549
  /** Whether `args` starts with one of the allowed prefixes (`"pr merge"`). */
1150
1550
  const isAllowed = (args, allow) => allow === void 0 || allow.some((prefix) => {
1151
1551
  return prefix.trim().split(/\s+/u).every((word, i) => args[i] === word);
1152
1552
  });
1153
- const render = (text, context) => {
1154
- const template = parseTemplate(text);
1155
- if (!template.ok) return {
1156
- ok: false,
1157
- error: template.message
1158
- };
1159
- return {
1160
- ok: true,
1161
- value: renderTemplate(template.parts, runScope(context.inputs, context.outputs, context.runId))
1162
- };
1163
- };
1164
1553
  /** The program and arguments a tool node runs, before the allow check. */
1165
1554
  const commandOf = (node, context) => {
1166
1555
  const line = commandLineOf(node);
@@ -1171,12 +1560,14 @@ const commandOf = (node, context) => {
1171
1560
  const tokens = tokenize(line);
1172
1561
  if (!tokens.ok) return tokens;
1173
1562
  const args = [];
1563
+ let afterSeparator = false;
1174
1564
  for (const token of tokens.args) {
1175
1565
  if (node.script !== void 0) {
1176
1566
  args.push(token);
1177
1567
  continue;
1178
1568
  }
1179
- const rendered = render(token, context);
1569
+ const rendered = renderArg(token, context, !afterSeparator);
1570
+ afterSeparator ||= token === "--";
1180
1571
  if (!rendered.ok) return rendered;
1181
1572
  args.push(rendered.value);
1182
1573
  }
@@ -1212,19 +1603,10 @@ const capped = (text, file) => {
1212
1603
  * what a session is told. All of it is kept in the output's fields and files.
1213
1604
  */
1214
1605
  const clipError = (text) => text.length <= ERROR_LIMIT ? text : `${text.slice(0, ERROR_LIMIT)}…`;
1215
- const failed$1 = (error) => ({
1216
- ok: false,
1217
- output: {
1218
- exitCode: -1,
1219
- stdout: "",
1220
- stderr: error
1221
- },
1222
- error
1223
- });
1224
1606
  /** Runs a user's CLI through its integration's adapter, with its own login. */
1225
1607
  const toolExecutor = (given, options = {}) => async (context) => {
1226
1608
  const { node } = context;
1227
- if (!isToolNode(node)) return failed$1(`${context.id} is not a tool node`);
1609
+ if (!isToolNode(node)) return failed$1(`${context.id} is not a tool`, "unknown");
1228
1610
  const environ = options.environ ?? process.env;
1229
1611
  const scrub = scrubberOf(secretsOf(context.workflow, environ, options.maskedNames));
1230
1612
  const command = commandOf(node, context);
@@ -1235,7 +1617,7 @@ const toolExecutor = (given, options = {}) => async (context) => {
1235
1617
  if (!isAllowed(checked, allow)) return failed$1(scrub(`${markValue(checked.join(" "))} is not in the node's allow list (${(allow ?? []).map((entry) => markValue(entry)).join(", ")})`));
1236
1618
  let cwd = context.cwd;
1237
1619
  if (node.cwd !== void 0) {
1238
- const rendered = render(node.cwd, context);
1620
+ const rendered = renderArg(node.cwd, context);
1239
1621
  if (!rendered.ok) return failed$1(rendered.error);
1240
1622
  cwd = path.resolve(context.cwd, rendered.value);
1241
1623
  }
@@ -1245,32 +1627,49 @@ const toolExecutor = (given, options = {}) => async (context) => {
1245
1627
  const outputFile = node.output?.format === "json" ? prepareOutputFile(context.runDir, context.id, context.round) : void 0;
1246
1628
  const env = {
1247
1629
  ...nodeEnv.values,
1248
- ...identityOf(context, options.orkBin),
1630
+ ...identityOf(context, options.orkBin, environ, options.store),
1249
1631
  ...maskedEnv(namesToMask(context.workflow, environ, options.maskedNames)),
1250
1632
  ...outputFile === void 0 ? {} : { [OUTPUT_ENV]: outputFile },
1251
1633
  ...node.watch === void 0 ? {} : { [STATE_ENV]: prepareStateFile(context.runDir, context.id) }
1252
1634
  };
1253
1635
  const names = Object.keys(nodeEnv.values);
1254
- context.emit({
1255
- type: "tool.called",
1256
- id: context.id,
1257
- name: scrub(command.program),
1258
- input: {
1259
- command: scrub([
1260
- command.program,
1261
- ...command.args,
1262
- ...node.script === void 0 ? [] : ["<script>"]
1263
- ].join(" ")),
1264
- ...names.length === 0 ? {} : { env: names }
1265
- }
1266
- });
1267
- const scriptFile = node.script === void 0 ? void 0 : writeScriptFile(context.runDir, context.id, context.round, node.script);
1268
- const raw = await runner(command.program, scriptFile === void 0 ? command.args : [...command.args, scriptFile], {
1636
+ const shown = scrub([
1637
+ command.program,
1638
+ ...command.args,
1639
+ ...node.script === void 0 ? [] : ["<script>"]
1640
+ ].join(" "));
1641
+ const execute = async () => {
1642
+ const release = await context.machine?.slot([command.program, ...command.args].join(" "), context.signal, node.script !== void 0 && SHELLS.has(path.basename(command.program)) ? { script: node.script } : {});
1643
+ context.emit({
1644
+ type: "tool.called",
1645
+ id: context.id,
1646
+ name: scrub(command.program),
1647
+ input: {
1648
+ command: shown,
1649
+ ...names.length === 0 ? {} : { env: names }
1650
+ }
1651
+ });
1652
+ const scriptFile = node.script === void 0 ? void 0 : writeScriptFile(context.runDir, context.id, context.round, node.script);
1653
+ const priority = context.machine?.priority;
1654
+ return runner(command.program, scriptFile === void 0 ? command.args : [...command.args, scriptFile], {
1655
+ cwd,
1656
+ signal: context.signal,
1657
+ env,
1658
+ ...priority === void 0 ? {} : { priority }
1659
+ }).finally(() => {
1660
+ release?.();
1661
+ if (scriptFile !== void 0) removeOutputFile(scriptFile);
1662
+ });
1663
+ };
1664
+ const raw = await runUnlessPassed({
1665
+ node,
1666
+ context,
1269
1667
  cwd,
1270
- signal: context.signal,
1271
- env
1668
+ shown,
1669
+ environ,
1670
+ execute
1272
1671
  });
1273
- if (scriptFile !== void 0) removeOutputFile(scriptFile);
1672
+ if (raw === null) return skippedCheck(outputFile);
1274
1673
  const run = {
1275
1674
  exitCode: raw.exitCode,
1276
1675
  stdout: scrub(raw.stdout),
@@ -1306,30 +1705,14 @@ const toolExecutor = (given, options = {}) => async (context) => {
1306
1705
  context.emit({
1307
1706
  type: "tool.result",
1308
1707
  id: context.id,
1309
- isError: run.exitCode !== 0 || json?.ok === false || declared !== null
1708
+ isError: run.exitCode !== 0 || json?.ok === false || declared !== null,
1709
+ ...raw.cpu === void 0 ? {} : { cpu: raw.cpu }
1310
1710
  });
1311
- if (json?.ok === false) return {
1312
- ok: false,
1313
- output,
1314
- error: `${context.id}: ${json.error}`
1315
- };
1316
- if (declared !== null) return {
1317
- ok: false,
1318
- output,
1319
- error: declared
1320
- };
1711
+ if (json?.ok === false) return invalidOutput(output, json.error);
1712
+ if (declared !== null) return invalidOutput(output, declared);
1321
1713
  if (json !== null) output["json"] = json.json;
1322
- if (run.exitCode === 0) return {
1323
- ok: true,
1324
- output
1325
- };
1326
- const last = run.stderr.trim().split("\n").at(-1) ?? "";
1327
- return {
1328
- ok: false,
1329
- transient: true,
1330
- output,
1331
- error: `${scrub(command.program)} exited with ${String(run.exitCode)}${last === "" ? "" : `: ${clipError(last)}`}`
1332
- };
1714
+ const signedOut = integration?.signedOut;
1715
+ return exitEnd(output, run, context, scrub(command.program), signedOut);
1333
1716
  };
1334
1717
  // ../nodes/src/verify.ts
1335
1718
  /**
@@ -1341,15 +1724,17 @@ const toolExecutor = (given, options = {}) => async (context) => {
1341
1724
  /** A setup that failed: no session started, so nothing was spent on it. */
1342
1725
  const beforeFailed = (ran) => {
1343
1726
  const error = `its before step failed: ${ran.error ?? "no reason given"}`;
1344
- return {
1727
+ return endedWith({
1345
1728
  ok: false,
1346
- error,
1347
1729
  output: {
1348
1730
  status: "fail",
1349
1731
  summary: error,
1350
1732
  before: ran.output
1351
1733
  }
1352
- };
1734
+ }, endOf({
1735
+ ...ran,
1736
+ ok: false
1737
+ }));
1353
1738
  };
1354
1739
  /** With nothing to run a step, a report cannot be accepted. */
1355
1740
  const noSteps = () => Promise.resolve({
@@ -1370,20 +1755,23 @@ const runStep = async (tool, given, { timeout, ...fields }, step) => {
1370
1755
  type: "tool.shell",
1371
1756
  ...fields
1372
1757
  };
1373
- const context = {
1374
- ...given,
1375
- step
1376
- };
1377
1758
  const ms = timeoutMs(timeout);
1378
1759
  if (ms === void 0) return tool({
1379
- ...context,
1760
+ ...given,
1761
+ step,
1380
1762
  node
1381
1763
  });
1764
+ const { clock, machine } = slotClock(given.hold, given.machine);
1765
+ const context = {
1766
+ ...given,
1767
+ step,
1768
+ ...machine === void 0 ? {} : { machine }
1769
+ };
1382
1770
  const limit = new AbortController();
1383
1771
  const finished = new AbortController();
1384
1772
  let expired = false;
1385
1773
  const watch = async () => {
1386
- if (await heldTimeout(context.hold, ms, AbortSignal.any([context.signal, finished.signal]))) {
1774
+ if (await heldTimeout(clock, ms, AbortSignal.any([context.signal, finished.signal]))) {
1387
1775
  expired = true;
1388
1776
  limit.abort();
1389
1777
  }
@@ -1494,42 +1882,28 @@ const checkedResult = (result, { check, resumes }) => {
1494
1882
  };
1495
1883
  const reason = `its check failed: ${check.error ?? "no reason given"}`;
1496
1884
  const error = result.ok ? reason : `${reason} (then: ${result.error ?? "no report"})`;
1497
- return {
1885
+ return endedWith({
1498
1886
  ...result,
1499
1887
  ok: false,
1500
- error,
1501
1888
  output: {
1502
1889
  ...result.output,
1503
1890
  status: "fail",
1504
1891
  summary: error,
1505
1892
  verify
1506
1893
  }
1507
- };
1894
+ }, endOf(result.ok ? {
1895
+ ...check,
1896
+ ok: false
1897
+ } : result));
1508
1898
  };
1509
1899
  // ../nodes/src/claude.ts
1510
1900
  /**
1511
- * @file The Claude node type: runs a `claude.leader` or `claude.worker` as one
1901
+ * @file The Claude node type: runs an `agent.leader` or `agent.worker` as one
1512
1902
  * session, planned with the run's outputs (and the brief its owner gave it),
1513
1903
  * and turns its report into the node's output. A leader gets its children
1514
1904
  * through the session's ports, and resumes its session on re-entry
1515
1905
  * (ADR-019).
1516
1906
  */
1517
- const MAX_INPUT = 8192;
1518
- /** On a loop re-entry, the brief carries what the workflow sent back. */
1519
- const reentryNote = (round, input) => {
1520
- const json = JSON.stringify(input ?? {}, void 0, 2);
1521
- const body = json.length > MAX_INPUT ? `${json.slice(0, MAX_INPUT)}\n…` : json;
1522
- return `\n\n## Round ${String(round)}\n\nThe workflow sent this task back to you with:\n\n\`\`\`json\n${body}\n\`\`\``;
1523
- };
1524
- /**
1525
- * What a resumed leader is told: it already has its brief and its earlier work,
1526
- * so only the new round, and the new task when its owner sent one.
1527
- */
1528
- const resumeMessage = (round, input, brief) => [brief === void 0 ? sentBack(round, input) : `## New task from \`${brief.from}\`\n\n${brief.text}`, "End with a new structured report."].join("\n\n");
1529
- /** A first-round resume is a session the run was cut off in (`ork resume`). */
1530
- const sentBack = (round, input) => round === 1 ? "Ork was stopped while you worked on this task, and the run is resumed. Your earlier turns are above: check what you finished, and carry on from there." : `The workflow sent your task back to you.${reentryNote(round, input)}`;
1531
- /** What a session is told when messages came in as it finished. */
1532
- const lateMessage = (count) => `[ork] ${String(count)} message${count === 1 ? "" : "s"} came in while you finished. Call read_inbox, act on ${count === 1 ? "it" : "them"} if needed, and end with a new structured report.`;
1533
1907
  /** A re-entered leader resumes; anyone else starts with the full brief. */
1534
1908
  const specFor = (spec, context) => {
1535
1909
  if (context.resume !== void 0) return {
@@ -1543,24 +1917,9 @@ const specFor = (spec, context) => {
1543
1917
  };
1544
1918
  return spec;
1545
1919
  };
1546
- /** Runs a `claude.leader` or `claude.worker` as one session. */
1920
+ /** Runs an `agent.leader` or `agent.worker` as one session. */
1547
1921
  const claudeExecutor = (options) => async (context) => {
1548
- const planOptions = {
1549
- cwd: context.cwd,
1550
- inputs: context.inputs,
1551
- outputs: context.outputs,
1552
- inPlace: options.inPlace,
1553
- workflowDir: context.workflowDir,
1554
- projectRoot: context.projectRoot,
1555
- ...context.orkDir === void 0 ? {} : { orkDir: context.orkDir },
1556
- pluginDir: path.join(context.runDir, "plugins", context.id)
1557
- };
1558
- if (context.checkout !== void 0) planOptions.checkout = context.checkout;
1559
- if (context.brief !== void 0) planOptions.brief = context.brief;
1560
- if (context.template !== void 0) planOptions.template = context.template;
1561
- if (context.ownership !== void 0) planOptions.ownership = context.ownership;
1562
- if (context.mailbox !== void 0) planOptions.mailbox = context.mailbox;
1563
- if (context.claims !== void 0) planOptions.claims = context.claims;
1922
+ const planOptions = planOptionsOf(context, options.inPlace);
1564
1923
  const before = beforeOf(context.node, context.workflow.defaults);
1565
1924
  if (before !== void 0) {
1566
1925
  const ran = await runStep(options.steps ?? noSteps, context, before, {
@@ -1569,49 +1928,53 @@ const claudeExecutor = (options) => async (context) => {
1569
1928
  });
1570
1929
  if (!ran.ok) return beforeFailed(ran);
1571
1930
  await context.hold?.wait("start", context.signal);
1572
- if (context.signal.aborted) return beforeFailed({
1573
- ...ran,
1574
- error: "the node was stopped"
1575
- });
1931
+ if (context.signal.aborted) {
1932
+ const end = stoppedEnd(context.signal);
1933
+ return beforeFailed({
1934
+ ...ran,
1935
+ ok: false,
1936
+ error: end.message,
1937
+ end
1938
+ });
1939
+ }
1576
1940
  planOptions.before = ran.output;
1577
1941
  }
1578
1942
  const blocked = context.quota?.().blocked;
1579
- if (blocked !== void 0) return notStarted(blocked, blocked);
1580
- const budget = budgetNow(context);
1581
- if (budget !== void 0 && budget <= 0) return notStarted("nothing is left of the run's budget");
1582
- if (budget !== void 0) planOptions.budgetLeftUsd = budget;
1943
+ if (blocked !== void 0) return notStarted("plan_limit", blocked, blocked);
1944
+ const refused = await startBudgets(context, planOptions);
1945
+ if (refused !== null) return refused;
1583
1946
  const plan = planNodeSession(context.workflow, context.id, planOptions);
1584
- if (!plan.ok) return {
1585
- ok: false,
1586
- output: {
1587
- status: "fail",
1588
- summary: plan.error
1589
- },
1590
- error: plan.error
1947
+ if (!plan.ok) return notStarted("invalid_node", plan.error);
1948
+ const servers = namedMcpEntries(context.projectRoot, plan.mcp);
1949
+ if (!servers.ok) return notStarted("invalid_node", servers.error);
1950
+ const spec = {
1951
+ ...specFor(plan.spec, context),
1952
+ ...servers.value
1591
1953
  };
1592
- const spec = specFor(plan.spec, context);
1593
1954
  if (options.machineClaude === true) spec.machineClaude = true;
1594
- const driver = sessionDriver({
1595
- override: options.driver,
1596
- named: plan.driver,
1597
- ready: options.readyDriver
1598
- });
1599
- if (driver === void 0) return notStarted("no Claude driver is ready; see `ork doctor`");
1955
+ const driver = sessionDriverOf(context, plan, options);
1956
+ if (typeof driver !== "string") return driver;
1600
1957
  const runtime = await options.runtimeFor(driver);
1601
- const ports = { human: context.owner === void 0 ? context.human : ownerFirst(context, context.owner, {
1958
+ const ports = { human: context.owner === void 0 ? withdrawnOnStop(context) : ownerFirst(context, context.owner, {
1602
1959
  runtimeFor: options.runtimeFor,
1603
1960
  driver: options.driver,
1604
1961
  readyDriver: options.readyDriver,
1605
1962
  inPlace: options.inPlace
1606
1963
  }) };
1607
1964
  if (context.hold !== void 0) ports.hold = context.hold;
1965
+ if (context.machine !== void 0) ports.machine = context.machine;
1608
1966
  if (context.ownership !== void 0) ports.ownership = context.ownership;
1609
1967
  if (context.mailbox !== void 0) ports.mailbox = context.mailbox;
1610
1968
  if (context.claims !== void 0) ports.claims = context.claims;
1611
1969
  if (context.feedback !== void 0) ports.feedback = context.feedback;
1612
- const watch = limitWatch(context);
1613
- const run = async (asked) => {
1614
- const watching = watch.session(asked);
1970
+ if (context.followUps !== void 0) ports.followUps = context.followUps;
1971
+ const whole = context.round > 1 ? wholeInput(context.input) : void 0;
1972
+ if (whole !== void 0) ports.input = whole;
1973
+ const watch = limitWatch(context, runCapsOf(context, planOptions));
1974
+ const run = async (asked, prior = 0) => {
1975
+ const watching = watch.session(asked, prior);
1976
+ context.reserveBudget?.(asked.budgetUsd);
1977
+ context.reserveTokens?.(asked.budgetTokens);
1615
1978
  const session = runtime.start(asked, ports);
1616
1979
  const stop = () => {
1617
1980
  session.interrupt();
@@ -1629,6 +1992,8 @@ const claudeExecutor = (options) => async (context) => {
1629
1992
  context.emit(event);
1630
1993
  }
1631
1994
  } finally {
1995
+ context.reserveBudget?.(0);
1996
+ context.reserveTokens?.(0);
1632
1997
  disconnect?.();
1633
1998
  context.signal.removeEventListener("abort", stop);
1634
1999
  }
@@ -1636,7 +2001,7 @@ const claudeExecutor = (options) => async (context) => {
1636
2001
  return outcome;
1637
2002
  };
1638
2003
  const first = await run(spec);
1639
- const limitsAfter = ({ sessionId, usage }) => {
2004
+ const limitsAfter = ({ sessionId, usage }, turnsAtLeast = 0) => {
1640
2005
  const quota = context.quota?.();
1641
2006
  if (quota?.blocked !== void 0) {
1642
2007
  watch.stoppedBy(quota.blocked);
@@ -1644,15 +2009,19 @@ const claudeExecutor = (options) => async (context) => {
1644
2009
  }
1645
2010
  return lateLimits(spec, {
1646
2011
  spent: context.ownSpend?.(sessionId, usage.costUsd) ?? usage.costUsd,
1647
- turns: usage.turns
1648
- }, context.budgetLeftNow?.(), quota?.leftUsd);
2012
+ turns: usage.turns,
2013
+ tokens: countedTokens(usage)
2014
+ }, context.budgetLeftNow?.(), quota?.leftUsd, {
2015
+ run: context.tokensLeftNow?.(),
2016
+ quota: quota?.leftTokens
2017
+ }, turnsAtLeast);
1649
2018
  };
1650
2019
  const resumeWith = (outcome, prompt, limits) => run({
1651
2020
  ...spec,
1652
2021
  ...limits,
1653
2022
  resume: outcome.sessionId,
1654
2023
  prompt
1655
- });
2024
+ }, outcome.usage.costUsd);
1656
2025
  const lateTurn = (current) => {
1657
2026
  const late = context.mailbox?.pending() ?? 0;
1658
2027
  const limits = limitsAfter(current);
@@ -1678,7 +2047,7 @@ const claudeExecutor = (options) => async (context) => {
1678
2047
  },
1679
2048
  late: lateTurn,
1680
2049
  resume: async (current, check) => {
1681
- const limits = limitsAfter(current);
2050
+ const limits = limitsAfter(current, 20);
1682
2051
  if (limits === null) return null;
1683
2052
  await context.hold?.wait("start", context.signal);
1684
2053
  return context.signal.aborted ? null : resumeWith(current, checkFailedMessage(check, {
@@ -1691,10 +2060,11 @@ const claudeExecutor = (options) => async (context) => {
1691
2060
  };
1692
2061
  // ../nodes/src/gate.ts
1693
2062
  /**
1694
- * @file The `human.gate` node type: asks the human the rendered question,
1695
- * outputs their choice with the note they gave, and answers with the
1696
- * `default` when the `timeout` passes. The timeout counts only while the run
1697
- * and the gate are not paused (ADR-041).
2063
+ * @file The `human.gate` node type: asks the human the rendered question, with
2064
+ * its document if it has one, both in readable text (no raw JSON), outputs
2065
+ * their choice with the note they gave, and answers with the `default` when
2066
+ * the `timeout` passes. The timeout counts only while the run and the gate
2067
+ * are not paused (ADR-041).
1698
2068
  */
1699
2069
  /** A promise that never settles: the losing side of a race. */
1700
2070
  const NEVER = new Promise(() => {});
@@ -1716,8 +2086,13 @@ const gateExecutor = async (context) => {
1716
2086
  output: {},
1717
2087
  error: `${context.id} is not a gate`
1718
2088
  };
1719
- const template = parseTemplate(node.question);
1720
- const question = template.ok ? renderTemplate(template.parts, runScope(context.inputs, context.outputs, context.runId)) : node.question;
2089
+ const scope = runScope(context.inputs, context.outputs, context.runId);
2090
+ const render = (text) => {
2091
+ const template = parseTemplate(text);
2092
+ return template.ok ? renderTemplate(template.parts, scope, { readable: true }) : text;
2093
+ };
2094
+ const question = render(node.question);
2095
+ const document = render(node.document ?? "");
1721
2096
  const options = node.options.map((o) => choiceOf(o));
1722
2097
  const withdraw = new AbortController();
1723
2098
  context.signal.addEventListener("abort", () => {
@@ -1728,12 +2103,14 @@ const gateExecutor = async (context) => {
1728
2103
  nodeId: context.id,
1729
2104
  question,
1730
2105
  options,
2106
+ waits: true,
1731
2107
  signal: withdraw.signal,
1732
2108
  onNote: (given) => {
1733
2109
  note = given;
1734
2110
  }
1735
2111
  };
1736
2112
  if (node.recommended !== void 0) asked.recommended = node.recommended;
2113
+ if (document.trim() !== "") asked.document = document;
1737
2114
  const answer = context.human.ask(asked).then((choice) => ({
1738
2115
  choice,
1739
2116
  answeredBy: "user",
@@ -1752,21 +2129,22 @@ const gateExecutor = async (context) => {
1752
2129
  })];
1753
2130
  try {
1754
2131
  const output = await Promise.race([answer, ...expired]);
1755
- if (!options.some((o) => o.id === output.choice)) return {
1756
- ok: false,
1757
- output,
1758
- error: "no one answered the gate"
1759
- };
2132
+ if (!options.some((o) => o.id === output.choice)) {
2133
+ const facts = node.timeout === void 0 ? {} : { timeout: node.timeout };
2134
+ return endedWith({
2135
+ ok: false,
2136
+ output
2137
+ }, endFor("timed_out", facts, "no one answered the gate"));
2138
+ }
1760
2139
  return {
1761
2140
  ok: true,
1762
2141
  output
1763
2142
  };
1764
2143
  } catch {
1765
- return {
2144
+ return endedWith({
1766
2145
  ok: false,
1767
- output: {},
1768
- error: "the gate was withdrawn"
1769
- };
2146
+ output: {}
2147
+ }, endFor("interrupted", {}, "the gate was withdrawn"));
1770
2148
  } finally {
1771
2149
  answered.abort();
1772
2150
  }
@@ -1781,18 +2159,16 @@ const gateExecutor = async (context) => {
1781
2159
  */
1782
2160
  /** A node's `timeout:` is what limits a call; this only lifts the SDK's 60 s. */
1783
2161
  const NO_LIMIT_MS = 864e5;
1784
- const failed = (error, output = {}) => ({
2162
+ const failed = (kind, facts, detail, output = {}) => endedWith({
1785
2163
  ok: false,
1786
- output,
1787
- error
1788
- });
1789
- /** A failure another try might not have, so a `watch:` looks again. */
1790
- const missed = (error, output = {}) => ({
1791
- ok: false,
1792
- transient: true,
1793
- output,
1794
- error
1795
- });
2164
+ output
2165
+ }, endFor(kind, facts, detail));
2166
+ /** How a call that threw ended: a sign-in, a server that won't start, or else. */
2167
+ const thrownKind = (error) => {
2168
+ const code = typeof error === "object" && error !== null && "code" in error ? error.code : void 0;
2169
+ if (code === 401 || error?.constructor.name === "UnauthorizedError") return "auth";
2170
+ return code === "ENOENT" ? "tool_missing" : "command_failed";
2171
+ };
1796
2172
  /**
1797
2173
  * A string's templates rendered. A string that is one `{{ … }}` and nothing
1798
2174
  * else keeps its value's type, so a tool that asks for a number gets one.
@@ -1836,19 +2212,22 @@ const reasonOf = (error, server) => {
1836
2212
  };
1837
2213
  const mcpCallExecutor = (options = {}) => async (context) => {
1838
2214
  const { node } = context;
1839
- if (node.type !== "mcp.call") return failed(`${context.id} is not an MCP call`);
2215
+ if (node.type !== "mcp.call") {
2216
+ const said = `${context.id} is not an MCP call`;
2217
+ return failed("unknown", {}, said);
2218
+ }
1840
2219
  const environ = options.environ ?? process.env;
1841
2220
  const declared = readMcpServer(context.projectRoot, node.server);
1842
- if (!declared.ok) return failed(declared.error);
2221
+ if (!declared.ok) return failed("invalid_node", { reason: declared.error });
1843
2222
  const nodeEnv = renderEnv(node, context, environ);
1844
- if (!nodeEnv.ok) return failed(nodeEnv.error);
2223
+ if (!nodeEnv.ok) return failed("invalid_node", { reason: nodeEnv.error });
1845
2224
  const expanded = expandServer(declared.value, {
1846
2225
  ...environ,
1847
2226
  ...nodeEnv.values
1848
2227
  }, node.plainEnv);
1849
- if (!expanded.ok) return failed(expanded.error);
2228
+ if (!expanded.ok) return failed("invalid_node", { reason: expanded.error });
1850
2229
  const scrub = scrubberOf([...secretsOf(context.workflow, environ, options.maskedNames), ...expanded.value.values]);
1851
- const scope = runScope(context.inputs, context.outputs, context.runId);
2230
+ const scope = runScope(context.inputs, context.outputs, context.runId, context.item);
1852
2231
  const args = Object.fromEntries(Object.entries(node.arguments ?? {}).map(([key, value]) => [key, renderValue(value, scope)]));
1853
2232
  const name = `${node.server}.${node.tool}`;
1854
2233
  context.emit({
@@ -1889,19 +2268,22 @@ const mcpCallExecutor = (options = {}) => async (context) => {
1889
2268
  id: context.id,
1890
2269
  isError: reported || tooLong || problem !== null
1891
2270
  });
1892
- if (reported) return missed(`${name} failed: ${clipError(said)}`, output);
1893
- if (tooLong) return failed(`${name}'s result is over 64 KiB`, output);
1894
- return problem === null ? {
2271
+ if (reported) return failed("command_failed", { program: name }, said, output);
2272
+ if (tooLong) {
2273
+ const reason = `${name}'s result is over 64 KiB`;
2274
+ return failed("invalid_output", { reason }, void 0, output);
2275
+ }
2276
+ return problem === null ? endedWith({
1895
2277
  ok: true,
1896
2278
  output
1897
- } : failed(problem, output);
2279
+ }, endFor("done")) : failed("invalid_output", { reason: problem }, void 0, output);
1898
2280
  } catch (error) {
1899
2281
  context.emit({
1900
2282
  type: "tool.result",
1901
2283
  id: context.id,
1902
2284
  isError: true
1903
2285
  });
1904
- return missed(clipError(scrub(reasonOf(error, node.server))));
2286
+ return failed(thrownKind(error), { program: `MCP server \`${node.server}\`` }, clipError(scrub(reasonOf(error, node.server))));
1905
2287
  } finally {
1906
2288
  await client.close().catch(() => {});
1907
2289
  }
@@ -1912,13 +2294,42 @@ const mcpCallExecutor = (options = {}) => async (context) => {
1912
2294
  * text. A program runs in its own process group, so aborting it reaches
1913
2295
  * whatever it started: SIGTERM, then SIGKILL for what ignores that. A stop or
1914
2296
  * a timeout therefore leaves nothing running, and an escaped process that
1915
- * keeps the output pipes open cannot hold the node.
2297
+ * keeps the output pipes open cannot hold the node. Off Windows it runs under
2298
+ * `sh`, whose `times` says the CPU the program and its children used, beside
2299
+ * the orphan guard, which ends the group when `ork` dies, and at the
2300
+ * project's priority: `low` starts it below Ork's own (nice, and on macOS
2301
+ * utility QoS).
1916
2302
  */
1917
- /** How long a program may take to end after SIGTERM. */
1918
- const KILL_AFTER_MS = 2e3;
1919
2303
  /** How long to wait for the pipes to close after SIGKILL. */
1920
2304
  const CLOSE_AFTER_MS = 500;
1921
- const spawnTool = (program, args, { cwd, signal, env }) => {
2305
+ /** The fd of the orphan guard's pipe from `ork`. */
2306
+ const WATCH_FD = 4;
2307
+ /**
2308
+ * Runs `$0 "$@"` beside the orphan guard, which ends the group if `ork` dies,
2309
+ * and stops the guard once the program ends, so its leftovers live on as
2310
+ * before. Then writes the shell's `times` to fd 3: its children's user and
2311
+ * system time, the program's whole tree that it waited for. A program that
2312
+ * isn't there says so on fd 3, as spawn's ENOENT would.
2313
+ */
2314
+ const TIMED = String.raw`command -v "$0" >/dev/null 2>&1 || { echo ork-missing >&3; exit 127; }; ${orphanGuard(WATCH_FD)}w=$!; "$0" "$@" 3>&- ${String(WATCH_FD)}<&-; s=$?; kill -s KILL $w 2>/dev/null; times >&3; exit $s`;
2315
+ const MISSING = "ork-missing";
2316
+ /** The children's line of `times` (`0m1.250s 0m0.300s`), in milliseconds. */
2317
+ const cpuOfTimes = (text) => {
2318
+ const [, , user, system] = [...text.matchAll(/(\d+)m([\d.]+)s/gu)].map(([, m = "0", s = "0"]) => Number(m) * 60 + Number(s));
2319
+ return user === void 0 || system === void 0 ? void 0 : {
2320
+ userMs: Math.round(user * 1e3),
2321
+ systemMs: Math.round(system * 1e3)
2322
+ };
2323
+ };
2324
+ const timed = process.platform !== "win32";
2325
+ /** What to start: the program, under `sh` to time it, at the priority. */
2326
+ const startOf = (program, args, priority) => timed ? startAt(priority, "sh", [
2327
+ "-c",
2328
+ TIMED,
2329
+ program,
2330
+ ...args
2331
+ ]) : [program, [...args]];
2332
+ const spawnTool = (program, args, { cwd, signal, env, priority }) => {
1922
2333
  if (signal.aborted) return Promise.resolve({
1923
2334
  exitCode: -1,
1924
2335
  stdout: "",
@@ -1928,7 +2339,9 @@ const spawnTool = (program, args, { cwd, signal, env }) => {
1928
2339
  const stdout = [];
1929
2340
  const stderr = [];
1930
2341
  const timers = [];
1931
- const child = spawn(program, args, {
2342
+ const times = [];
2343
+ const [bin, argv] = startOf(program, args, priority);
2344
+ const child = spawn(bin, argv, {
1932
2345
  cwd,
1933
2346
  detached: true,
1934
2347
  env: {
@@ -1938,9 +2351,17 @@ const spawnTool = (program, args, { cwd, signal, env }) => {
1938
2351
  stdio: [
1939
2352
  "ignore",
1940
2353
  "pipe",
2354
+ "pipe",
2355
+ "pipe",
1941
2356
  "pipe"
1942
2357
  ]
1943
2358
  });
2359
+ lowerChild(priority, child.pid);
2360
+ holdGuard(child.stdio[WATCH_FD]);
2361
+ const fd3 = child.stdio[3];
2362
+ if (fd3 instanceof Readable) fd3.setEncoding("utf8").on("data", (chunk) => {
2363
+ times.push(chunk);
2364
+ });
1944
2365
  const killGroup = (kill) => {
1945
2366
  if (child.pid === void 0) return;
1946
2367
  try {
@@ -1952,11 +2373,21 @@ const spawnTool = (program, args, { cwd, signal, env }) => {
1952
2373
  signal.removeEventListener("abort", abort);
1953
2374
  resolve(run);
1954
2375
  };
1955
- const collected = (exitCode) => ({
1956
- exitCode,
1957
- stdout: stdout.join(""),
1958
- stderr: stderr.join("")
1959
- });
2376
+ const collected = (exitCode) => {
2377
+ const said = times.join("");
2378
+ if (said.includes(MISSING)) return {
2379
+ exitCode: -1,
2380
+ stdout: stdout.join(""),
2381
+ stderr: `could not run ${program}: spawn ${program} ENOENT`
2382
+ };
2383
+ const cpu = cpuOfTimes(said);
2384
+ return {
2385
+ exitCode,
2386
+ stdout: stdout.join(""),
2387
+ stderr: stderr.join(""),
2388
+ ...cpu === void 0 ? {} : { cpu }
2389
+ };
2390
+ };
1960
2391
  const abort = () => {
1961
2392
  killGroup("SIGTERM");
1962
2393
  timers.push(setTimeout(() => {
@@ -1967,10 +2398,10 @@ const spawnTool = (program, args, { cwd, signal, env }) => {
1967
2398
  }, KILL_AFTER_MS));
1968
2399
  };
1969
2400
  signal.addEventListener("abort", abort, { once: true });
1970
- child.stdout.setEncoding("utf8").on("data", (chunk) => {
2401
+ child.stdout?.setEncoding("utf8").on("data", (chunk) => {
1971
2402
  stdout.push(chunk);
1972
2403
  });
1973
- child.stderr.setEncoding("utf8").on("data", (chunk) => {
2404
+ child.stderr?.setEncoding("utf8").on("data", (chunk) => {
1974
2405
  stderr.push(chunk);
1975
2406
  });
1976
2407
  child.on("error", (error) => {
@@ -1990,6 +2421,7 @@ const spawnTool = (program, args, { cwd, signal, env }) => {
1990
2421
  const builtinExecutors = (options) => {
1991
2422
  const tool = toolExecutor(options.tools ?? spawnTool, {
1992
2423
  ...options.orkBin === void 0 ? {} : { orkBin: options.orkBin },
2424
+ ...options.store === void 0 ? {} : { store: options.store },
1993
2425
  ...options.maskedNames === void 0 ? {} : { maskedNames: options.maskedNames }
1994
2426
  });
1995
2427
  const claude = claudeExecutor({
@@ -1997,8 +2429,8 @@ const builtinExecutors = (options) => {
1997
2429
  steps: tool
1998
2430
  });
1999
2431
  const executors = {
2000
- "claude.leader": claude,
2001
- "claude.worker": claude,
2432
+ "agent.leader": claude,
2433
+ "agent.worker": claude,
2002
2434
  "human.gate": gateExecutor,
2003
2435
  "workflow.call": callExecutor,
2004
2436
  "mcp.call": mcpCallExecutor(options.maskedNames === void 0 ? {} : { maskedNames: options.maskedNames })
@@ -2130,4 +2562,4 @@ const missingPrograms = (workflow, installed = (program) => onPath(program)) =>
2130
2562
  }
2131
2563
  return [...missing.values()];
2132
2564
  };
2133
- export { resultOf as _, builtinExecutors as a, toolExecutor as c, maskedEnv as d, maskedNamesOfRun as f, clearOutputFiles as g, sessionEnv as h, onPath as i, clearStateFiles as l, runScrubber as m, missingProgramMessage as n, spawnTool as o, runMarkerIn as p, missingPrograms as r, mcpCallExecutor as s, envProblems as t, inheritedScrubber as u };
2565
+ export { clearOutputFiles as _, builtinExecutors as a, runScrubber as b, toolExecutor as c, maskedEnv as d, maskedNamesOfRun as f, storeGuarded as g, runMarkerIn as h, onPath as i, clearStateFiles as l, sessionEnv as m, missingProgramMessage as n, spawnTool as o, scrubberOfRun as p, missingPrograms as r, mcpCallExecutor as s, envProblems as t, inheritedScrubber as u, namedMcpEntries as v, resultOf as y };