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

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 (75) hide show
  1. package/README.md +12 -7
  2. package/dist/__build_info__-0wqQ-rtf.mjs +180 -0
  3. package/dist/apply-Mb0fNpCX.mjs +17 -0
  4. package/dist/argument-error-CihH46Le.mjs +27 -0
  5. package/dist/attach-C0-YyNoY.mjs +92 -0
  6. package/dist/attach-CAKNqnUK.mjs +1259 -0
  7. package/dist/bin.mjs +1885 -1609
  8. package/dist/brief-C2hew8cS.mjs +116 -0
  9. package/dist/{decide-CGyx9rv7.mjs → builtin-files-YYAnbZGD.mjs} +3 -51
  10. package/dist/chips-CP_pgKlj.mjs +48 -0
  11. package/dist/command-tree-CEkzWNy6.mjs +63 -0
  12. package/dist/{completion-CSU5vkEt.mjs → completion-K4rAVsrf.mjs} +200 -30
  13. package/dist/context-B-fjPJky.mjs +106 -0
  14. package/dist/context-DMK0U1w0.mjs +160 -0
  15. package/dist/detached-q0AYG0Bs.mjs +39 -0
  16. package/dist/extra-arguments-BxQMXARO.mjs +13 -0
  17. package/dist/{find-D6Gj1sxg.mjs → find-Ct5CNl1J.mjs} +2 -2
  18. package/dist/flags-C90N4J1i.mjs +145 -0
  19. package/dist/follow-ups-D3HPyNYM.mjs +568 -0
  20. package/dist/format-CiPhQqbX.mjs +260 -0
  21. package/dist/{help-groups-BwYoAWsh.mjs → help-groups-BQvcKQ9H.mjs} +49 -78
  22. package/dist/in-a-run-D_wg4iJi.mjs +27 -0
  23. package/dist/install-Hji6LIei.mjs +259 -0
  24. package/dist/{limits-B1-DYc3d.mjs → limits-BD6RZRCS.mjs} +2 -2
  25. package/dist/{mcp-DE_EmyVm.mjs → mcp-CpbDmBLG.mjs} +6 -6
  26. package/dist/once-J8Qn5fTG.mjs +112 -0
  27. package/dist/orphan-guard-BFKg8VSd.mjs +58 -0
  28. package/dist/output-DXZbnFt_.mjs +458 -0
  29. package/dist/outputs-text-CNTJ7Qq_.mjs +19 -0
  30. package/dist/{pause-CL6N_Xrj.mjs → pause-Cl4ZYA35.mjs} +7 -4
  31. package/dist/print-DzugGZQB.mjs +19 -0
  32. package/dist/project-records-CtkmlsHN.mjs +233 -0
  33. package/dist/refusals-DQQX3AIb.mjs +116 -0
  34. package/dist/{relay-0SD0erwf.mjs → relay-CiP77vga.mjs} +2 -2
  35. package/dist/run-CtuNY3mE.mjs +234 -0
  36. package/dist/send-DRYGks8K.mjs +104 -0
  37. package/dist/short-flags-DkJLWVqh.mjs +1 -0
  38. package/dist/short-flags-Qf21as0E.mjs +83 -0
  39. package/dist/src-BMFQKHz4.mjs +3122 -0
  40. package/dist/{src-BbyEoMn4.mjs → src-BrFfDKux.mjs} +800 -235
  41. package/dist/{src-Dujmvv4s.mjs → src-BuF9Lbow.mjs} +12580 -5505
  42. package/dist/{src-DOcK8Jfp.mjs → src-k0A_QpST.mjs} +1024 -590
  43. package/dist/store-rTgRu8xy.mjs +25 -0
  44. package/dist/ui/THIRD_PARTY_LICENSES.txt +612 -0
  45. package/dist/ui/assets/app-BYxpKJRh.css +1 -0
  46. package/dist/ui/assets/app-SEFEJLkF.js +112 -0
  47. package/dist/ui/assets/{index-9dFha9Qn.js → index-Cb6Voq_W.js} +3 -3
  48. package/dist/ui/assets/theme-CdXvqTQ6.js +1 -0
  49. package/dist/ui/index.html +2 -2
  50. package/dist/{ui-CTmO2Xzu.mjs → ui-C2f37TSb.mjs} +57 -25
  51. package/dist/usage-BuvGcQSk.mjs +256 -0
  52. package/dist/validate-ObP5lt29.mjs +1 -0
  53. package/dist/validate-_HjACtk9.mjs +632 -0
  54. package/dist/verdict-B592j1W3.mjs +84 -0
  55. package/dist/{watch-CtbgDAcz.mjs → watch-DV3ixCYj.mjs} +45 -17
  56. package/dist/{workflows-D1_WVukk.mjs → workflows-QLjBhGNS.mjs} +30 -5
  57. package/dist/worktrees-D3ubG_Qw.mjs +302 -0
  58. package/dist/yolo-BSH1E_kE.mjs +33 -0
  59. package/package.json +10 -8
  60. package/dist/__build_info__-fy6L1Mrd.mjs +0 -615
  61. package/dist/clean-DaHjs__2.mjs +0 -88
  62. package/dist/context-DT8MfEIR.mjs +0 -274
  63. package/dist/format-Dnfv8zMT.mjs +0 -427
  64. package/dist/in-a-run-pLAZeNys.mjs +0 -22
  65. package/dist/list-BrH8KRSJ.mjs +0 -110
  66. package/dist/output-BLQWD6MJ.mjs +0 -950
  67. package/dist/remove-BeGdqroy.mjs +0 -194
  68. package/dist/report-DK4LGqwF.mjs +0 -129
  69. package/dist/send-C8tunWhv.mjs +0 -163
  70. package/dist/src-CCUKRX51.mjs +0 -1204
  71. package/dist/ui/assets/app-BNT0SuIz.css +0 -1
  72. package/dist/ui/assets/app-T7wVAhgx.js +0 -67
  73. package/dist/ui/assets/theme-3xH5jxKs.js +0 -1
  74. package/dist/usage-DYUmP0i4.mjs +0 -419
  75. 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 { Ai as TOOL_PREFIX, At as heldTimeout, Bt as lowerChild, Ci as isClaudeNode, Cr as parseTemplate, Ct as reportSchemaFor, Ft as failedWith, Ht as startAt, It as stoppedEnd, Kn as countedTokens, Lt as declaredProblem, Mt as calledWorkflows, Oi as timeoutMs, Pt as endedWith, Q as sessionRefusals, Ri as AGENT_DRIVERS, Rt as slotClock, Sr as evaluate, ar as END_DETAIL_LIMIT, di as beforeOf, dn as declaredOutputOf, fi as programsRunBy, fn as MCP_FILE, ft as DETACHED_ENV, gi as markValue, ir as outcomeEnd, ji as integrationFor, jt as runScope, ki as INTEGRATIONS, ln as sessionDriver, mi as parseWorkflow, mn as serversIn, on as envReadsOf, pn as mcpNotText, qr as placeFor, rr as endOf, sr as endFor, vt as planReason, wi as isToolNode, wr as renderTemplate, xt as planNodeSession, z as storePath } from "./src-BuF9Lbow.mjs";
2
+ import { t as runOnce } from "./run-CtuNY3mE.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,10 @@ 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",
1379
+ DETACHED_ENV
1002
1380
  ]);
1003
1381
  /**
1004
1382
  * `env` without Ork's identity variables. A run started by a program in another
@@ -1006,27 +1384,32 @@ const IDENTITY_KEYS = /* @__PURE__ */ new Set([
1006
1384
  */
1007
1385
  const withoutIdentity = (env) => Object.fromEntries(Object.entries(env).flatMap(([key, value]) => value === void 0 || IDENTITY_KEYS.has(key) ? [] : [[key, value]]));
1008
1386
  /**
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`.
1387
+ * What a Claude session of a run in `store` (default: the user's) runs with: no
1388
+ * identity, only the marker that stops its Bash from speaking as the user
1389
+ * through `ork chat`, and the stores it must not do that in.
1011
1390
  */
1012
- const sessionEnv = (env) => ({
1391
+ const sessionEnv = (env, store) => ({
1013
1392
  ...withoutIdentity(env),
1014
- [SESSION_ENV]: "1"
1393
+ [SESSION_ENV]: "1",
1394
+ ...guardOf(env, store)
1015
1395
  });
1016
1396
  /**
1017
- * The variable that says a program in a run or a session started this process,
1018
- * or `null` when none does.
1397
+ * Who a node's program is, and the stores its run guards (none listed without
1398
+ * `store`, which guards every one). It is attribution, not a boundary.
1019
1399
  */
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) => ({
1400
+ const identityOf = (context, orkBin, environ = {}, store) => ({
1401
+ ...store === void 0 ? {} : guardOf(environ, store),
1023
1402
  [RUN_ENV]: context.runId,
1024
1403
  ORK_NODE: context.id,
1025
1404
  ORK_ROUND: String(context.round),
1026
1405
  ORK_RUN_DIR: context.runDir,
1027
1406
  ORK_PROJECT: context.projectRoot,
1028
1407
  ORK_WORKFLOW: context.workflow.name,
1029
- ...orkBin === void 0 ? {} : { ORK_BIN: orkBin }
1408
+ ...orkBin === void 0 ? {} : { ORK_BIN: orkBin },
1409
+ ...context.item === void 0 ? {} : {
1410
+ ORK_ITEM: typeof context.item.value === "string" ? context.item.value : JSON.stringify(context.item.value),
1411
+ ORK_INDEX: String(context.item.index)
1412
+ }
1030
1413
  });
1031
1414
  /** The variables a workflow's nodes read that no `plainEnv` excuses. */
1032
1415
  const secretNamesOf = (workflow) => Object.values(workflow.nodes).flatMap((node) => programsRunBy(node, workflow.defaults)).flatMap(({ program }) => {
@@ -1043,6 +1426,23 @@ const inheritedNames = (environ) => (environ[MASKED_ENV] ?? "").split(",").filte
1043
1426
  */
1044
1427
  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
1428
  /**
1429
+ * The env-value scrubber of a recorded run, from its workflow file as it is
1430
+ * now, for what is written to it from outside the run (a person's note);
1431
+ * `undefined` when the file is gone or no longer parses.
1432
+ */
1433
+ const scrubberOfRun = (run, registered, environ = process.env) => {
1434
+ let text;
1435
+ try {
1436
+ text = readFileSync(run.file, "utf8");
1437
+ } catch {
1438
+ return;
1439
+ }
1440
+ const parsed = parseWorkflow(text);
1441
+ if (!parsed.ok) return void 0;
1442
+ const own = placeFor(path.dirname(run.file), registered);
1443
+ return runScrubber(maskedNamesOfRun(parsed.workflow, own, registered, environ), environ);
1444
+ };
1445
+ /**
1046
1446
  * A program learns the names too, never their values: `ork send` masks with
1047
1447
  * them before it writes to the store, and a run the program starts passes them
1048
1448
  * on.
@@ -1052,23 +1452,17 @@ const namesToMask = (workflow, environ, also = []) => [.../* @__PURE__ */ new Se
1052
1452
  ...also,
1053
1453
  ...inheritedNames(environ)
1054
1454
  ])];
1055
- const valuesOf = (names, environ) => names.flatMap((name) => {
1056
- const value = readEnv(environ, name);
1057
- return value === void 0 ? [] : [value];
1058
- });
1059
1455
  /** 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));
1456
+ const inheritedScrubber = (environ) => scrubberOf(envValuesOf(inheritedNames(environ), environ));
1063
1457
  /** What a run tells a run it calls: names, never values. */
1064
1458
  const maskedEnv = (names) => names.length === 0 ? {} : { [MASKED_ENV]: names.join(",") };
1065
1459
  /** The values to mask: what the workflow reads, and the names given. */
1066
- const secretsOf = (workflow, environ, also = []) => valuesOf(namesToMask(workflow, environ, also), environ);
1460
+ const secretsOf = (workflow, environ, also = []) => envValuesOf(namesToMask(workflow, environ, also), environ);
1067
1461
  /** A node's `env:`, rendered. */
1068
1462
  const renderEnv = (node, context, environ) => {
1069
1463
  const values = {};
1070
1464
  const scope = {
1071
- ...runScope(context.inputs, context.outputs, context.runId),
1465
+ ...runScope(context.inputs, context.outputs, context.runId, context.item),
1072
1466
  output: { env: environ }
1073
1467
  };
1074
1468
  for (const [key, text] of Object.entries(node.env ?? {})) {
@@ -1141,26 +1535,22 @@ const clearStateFiles = (runDir, warn) => {
1141
1535
  * without a shell, checked against `allow` (or the integration's default),
1142
1536
  * its arguments and observed output fields from the integration's adapter,
1143
1537
  * 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`).
1538
+ * (`tool-env.ts`), may return JSON (`tool-output.ts`), and may run once per
1539
+ * set of files (`runUnlessPassed` in `tool-command.ts`).
1145
1540
  */
1146
1541
  /** How much of stdout and stderr a node's output keeps. */
1147
1542
  const OUTPUT_LIMIT = 65536;
1148
1543
  const ERROR_LIMIT = 1024;
1544
+ /** The interpreters whose script's commands a heavy check reads. */
1545
+ const SHELLS = /* @__PURE__ */ new Set([
1546
+ "sh",
1547
+ "bash",
1548
+ "zsh"
1549
+ ]);
1149
1550
  /** Whether `args` starts with one of the allowed prefixes (`"pr merge"`). */
1150
1551
  const isAllowed = (args, allow) => allow === void 0 || allow.some((prefix) => {
1151
1552
  return prefix.trim().split(/\s+/u).every((word, i) => args[i] === word);
1152
1553
  });
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
1554
  /** The program and arguments a tool node runs, before the allow check. */
1165
1555
  const commandOf = (node, context) => {
1166
1556
  const line = commandLineOf(node);
@@ -1171,12 +1561,14 @@ const commandOf = (node, context) => {
1171
1561
  const tokens = tokenize(line);
1172
1562
  if (!tokens.ok) return tokens;
1173
1563
  const args = [];
1564
+ let afterSeparator = false;
1174
1565
  for (const token of tokens.args) {
1175
1566
  if (node.script !== void 0) {
1176
1567
  args.push(token);
1177
1568
  continue;
1178
1569
  }
1179
- const rendered = render(token, context);
1570
+ const rendered = renderArg(token, context, !afterSeparator);
1571
+ afterSeparator ||= token === "--";
1180
1572
  if (!rendered.ok) return rendered;
1181
1573
  args.push(rendered.value);
1182
1574
  }
@@ -1212,19 +1604,10 @@ const capped = (text, file) => {
1212
1604
  * what a session is told. All of it is kept in the output's fields and files.
1213
1605
  */
1214
1606
  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
1607
  /** Runs a user's CLI through its integration's adapter, with its own login. */
1225
1608
  const toolExecutor = (given, options = {}) => async (context) => {
1226
1609
  const { node } = context;
1227
- if (!isToolNode(node)) return failed$1(`${context.id} is not a tool node`);
1610
+ if (!isToolNode(node)) return failed$1(`${context.id} is not a tool`, "unknown");
1228
1611
  const environ = options.environ ?? process.env;
1229
1612
  const scrub = scrubberOf(secretsOf(context.workflow, environ, options.maskedNames));
1230
1613
  const command = commandOf(node, context);
@@ -1235,7 +1618,7 @@ const toolExecutor = (given, options = {}) => async (context) => {
1235
1618
  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
1619
  let cwd = context.cwd;
1237
1620
  if (node.cwd !== void 0) {
1238
- const rendered = render(node.cwd, context);
1621
+ const rendered = renderArg(node.cwd, context);
1239
1622
  if (!rendered.ok) return failed$1(rendered.error);
1240
1623
  cwd = path.resolve(context.cwd, rendered.value);
1241
1624
  }
@@ -1245,32 +1628,49 @@ const toolExecutor = (given, options = {}) => async (context) => {
1245
1628
  const outputFile = node.output?.format === "json" ? prepareOutputFile(context.runDir, context.id, context.round) : void 0;
1246
1629
  const env = {
1247
1630
  ...nodeEnv.values,
1248
- ...identityOf(context, options.orkBin),
1631
+ ...identityOf(context, options.orkBin, environ, options.store),
1249
1632
  ...maskedEnv(namesToMask(context.workflow, environ, options.maskedNames)),
1250
1633
  ...outputFile === void 0 ? {} : { [OUTPUT_ENV]: outputFile },
1251
1634
  ...node.watch === void 0 ? {} : { [STATE_ENV]: prepareStateFile(context.runDir, context.id) }
1252
1635
  };
1253
1636
  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], {
1637
+ const shown = scrub([
1638
+ command.program,
1639
+ ...command.args,
1640
+ ...node.script === void 0 ? [] : ["<script>"]
1641
+ ].join(" "));
1642
+ const execute = async () => {
1643
+ 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 } : {});
1644
+ context.emit({
1645
+ type: "tool.called",
1646
+ id: context.id,
1647
+ name: scrub(command.program),
1648
+ input: {
1649
+ command: shown,
1650
+ ...names.length === 0 ? {} : { env: names }
1651
+ }
1652
+ });
1653
+ const scriptFile = node.script === void 0 ? void 0 : writeScriptFile(context.runDir, context.id, context.round, node.script);
1654
+ const priority = context.machine?.priority;
1655
+ return runner(command.program, scriptFile === void 0 ? command.args : [...command.args, scriptFile], {
1656
+ cwd,
1657
+ signal: context.signal,
1658
+ env,
1659
+ ...priority === void 0 ? {} : { priority }
1660
+ }).finally(() => {
1661
+ release?.();
1662
+ if (scriptFile !== void 0) removeOutputFile(scriptFile);
1663
+ });
1664
+ };
1665
+ const raw = await runUnlessPassed({
1666
+ node,
1667
+ context,
1269
1668
  cwd,
1270
- signal: context.signal,
1271
- env
1669
+ shown,
1670
+ environ,
1671
+ execute
1272
1672
  });
1273
- if (scriptFile !== void 0) removeOutputFile(scriptFile);
1673
+ if (raw === null) return skippedCheck(outputFile);
1274
1674
  const run = {
1275
1675
  exitCode: raw.exitCode,
1276
1676
  stdout: scrub(raw.stdout),
@@ -1306,30 +1706,14 @@ const toolExecutor = (given, options = {}) => async (context) => {
1306
1706
  context.emit({
1307
1707
  type: "tool.result",
1308
1708
  id: context.id,
1309
- isError: run.exitCode !== 0 || json?.ok === false || declared !== null
1709
+ isError: run.exitCode !== 0 || json?.ok === false || declared !== null,
1710
+ ...raw.cpu === void 0 ? {} : { cpu: raw.cpu }
1310
1711
  });
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
- };
1712
+ if (json?.ok === false) return invalidOutput(output, json.error);
1713
+ if (declared !== null) return invalidOutput(output, declared);
1321
1714
  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
- };
1715
+ const signedOut = integration?.signedOut;
1716
+ return exitEnd(output, run, context, scrub(command.program), signedOut);
1333
1717
  };
1334
1718
  // ../nodes/src/verify.ts
1335
1719
  /**
@@ -1341,15 +1725,17 @@ const toolExecutor = (given, options = {}) => async (context) => {
1341
1725
  /** A setup that failed: no session started, so nothing was spent on it. */
1342
1726
  const beforeFailed = (ran) => {
1343
1727
  const error = `its before step failed: ${ran.error ?? "no reason given"}`;
1344
- return {
1728
+ return endedWith({
1345
1729
  ok: false,
1346
- error,
1347
1730
  output: {
1348
1731
  status: "fail",
1349
1732
  summary: error,
1350
1733
  before: ran.output
1351
1734
  }
1352
- };
1735
+ }, endOf({
1736
+ ...ran,
1737
+ ok: false
1738
+ }));
1353
1739
  };
1354
1740
  /** With nothing to run a step, a report cannot be accepted. */
1355
1741
  const noSteps = () => Promise.resolve({
@@ -1370,20 +1756,23 @@ const runStep = async (tool, given, { timeout, ...fields }, step) => {
1370
1756
  type: "tool.shell",
1371
1757
  ...fields
1372
1758
  };
1373
- const context = {
1374
- ...given,
1375
- step
1376
- };
1377
1759
  const ms = timeoutMs(timeout);
1378
1760
  if (ms === void 0) return tool({
1379
- ...context,
1761
+ ...given,
1762
+ step,
1380
1763
  node
1381
1764
  });
1765
+ const { clock, machine } = slotClock(given.hold, given.machine);
1766
+ const context = {
1767
+ ...given,
1768
+ step,
1769
+ ...machine === void 0 ? {} : { machine }
1770
+ };
1382
1771
  const limit = new AbortController();
1383
1772
  const finished = new AbortController();
1384
1773
  let expired = false;
1385
1774
  const watch = async () => {
1386
- if (await heldTimeout(context.hold, ms, AbortSignal.any([context.signal, finished.signal]))) {
1775
+ if (await heldTimeout(clock, ms, AbortSignal.any([context.signal, finished.signal]))) {
1387
1776
  expired = true;
1388
1777
  limit.abort();
1389
1778
  }
@@ -1494,42 +1883,28 @@ const checkedResult = (result, { check, resumes }) => {
1494
1883
  };
1495
1884
  const reason = `its check failed: ${check.error ?? "no reason given"}`;
1496
1885
  const error = result.ok ? reason : `${reason} (then: ${result.error ?? "no report"})`;
1497
- return {
1886
+ return endedWith({
1498
1887
  ...result,
1499
1888
  ok: false,
1500
- error,
1501
1889
  output: {
1502
1890
  ...result.output,
1503
1891
  status: "fail",
1504
1892
  summary: error,
1505
1893
  verify
1506
1894
  }
1507
- };
1895
+ }, endOf(result.ok ? {
1896
+ ...check,
1897
+ ok: false
1898
+ } : result));
1508
1899
  };
1509
1900
  // ../nodes/src/claude.ts
1510
1901
  /**
1511
- * @file The Claude node type: runs a `claude.leader` or `claude.worker` as one
1902
+ * @file The Claude node type: runs an `agent.leader` or `agent.worker` as one
1512
1903
  * session, planned with the run's outputs (and the brief its owner gave it),
1513
1904
  * and turns its report into the node's output. A leader gets its children
1514
1905
  * through the session's ports, and resumes its session on re-entry
1515
1906
  * (ADR-019).
1516
1907
  */
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
1908
  /** A re-entered leader resumes; anyone else starts with the full brief. */
1534
1909
  const specFor = (spec, context) => {
1535
1910
  if (context.resume !== void 0) return {
@@ -1543,24 +1918,9 @@ const specFor = (spec, context) => {
1543
1918
  };
1544
1919
  return spec;
1545
1920
  };
1546
- /** Runs a `claude.leader` or `claude.worker` as one session. */
1921
+ /** Runs an `agent.leader` or `agent.worker` as one session. */
1547
1922
  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;
1923
+ const planOptions = planOptionsOf(context, options.inPlace);
1564
1924
  const before = beforeOf(context.node, context.workflow.defaults);
1565
1925
  if (before !== void 0) {
1566
1926
  const ran = await runStep(options.steps ?? noSteps, context, before, {
@@ -1569,49 +1929,54 @@ const claudeExecutor = (options) => async (context) => {
1569
1929
  });
1570
1930
  if (!ran.ok) return beforeFailed(ran);
1571
1931
  await context.hold?.wait("start", context.signal);
1572
- if (context.signal.aborted) return beforeFailed({
1573
- ...ran,
1574
- error: "the node was stopped"
1575
- });
1932
+ if (context.signal.aborted) {
1933
+ const end = stoppedEnd(context.signal);
1934
+ return beforeFailed({
1935
+ ...ran,
1936
+ ok: false,
1937
+ error: end.message,
1938
+ end
1939
+ });
1940
+ }
1576
1941
  planOptions.before = ran.output;
1577
1942
  }
1578
1943
  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;
1944
+ if (blocked !== void 0) return notStarted("plan_limit", blocked, blocked);
1945
+ const refused = await startBudgets(context, planOptions);
1946
+ if (refused !== null) return refused;
1583
1947
  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
1948
+ if (!plan.ok) return notStarted("invalid_node", plan.error);
1949
+ const servers = namedMcpEntries(context.projectRoot, plan.mcp);
1950
+ if (!servers.ok) return notStarted("invalid_node", servers.error);
1951
+ const spec = {
1952
+ ...specFor(plan.spec, context),
1953
+ ...servers.value
1591
1954
  };
1592
- const spec = specFor(plan.spec, context);
1593
1955
  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`");
1956
+ if (options.yolo === true) spec.yolo = true;
1957
+ const driver = sessionDriverOf(context, plan, options);
1958
+ if (typeof driver !== "string") return driver;
1600
1959
  const runtime = await options.runtimeFor(driver);
1601
- const ports = { human: context.owner === void 0 ? context.human : ownerFirst(context, context.owner, {
1960
+ const ports = { human: context.owner === void 0 ? withdrawnOnStop(context) : ownerFirst(context, context.owner, {
1602
1961
  runtimeFor: options.runtimeFor,
1603
1962
  driver: options.driver,
1604
1963
  readyDriver: options.readyDriver,
1605
1964
  inPlace: options.inPlace
1606
1965
  }) };
1607
1966
  if (context.hold !== void 0) ports.hold = context.hold;
1967
+ if (context.machine !== void 0) ports.machine = context.machine;
1608
1968
  if (context.ownership !== void 0) ports.ownership = context.ownership;
1609
1969
  if (context.mailbox !== void 0) ports.mailbox = context.mailbox;
1610
1970
  if (context.claims !== void 0) ports.claims = context.claims;
1611
1971
  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);
1972
+ if (context.followUps !== void 0) ports.followUps = context.followUps;
1973
+ const whole = context.round > 1 ? wholeInput(context.input) : void 0;
1974
+ if (whole !== void 0) ports.input = whole;
1975
+ const watch = limitWatch(context, runCapsOf(context, planOptions));
1976
+ const run = async (asked, prior = 0) => {
1977
+ const watching = watch.session(asked, prior);
1978
+ context.reserveBudget?.(asked.budgetUsd);
1979
+ context.reserveTokens?.(asked.budgetTokens);
1615
1980
  const session = runtime.start(asked, ports);
1616
1981
  const stop = () => {
1617
1982
  session.interrupt();
@@ -1629,6 +1994,8 @@ const claudeExecutor = (options) => async (context) => {
1629
1994
  context.emit(event);
1630
1995
  }
1631
1996
  } finally {
1997
+ context.reserveBudget?.(0);
1998
+ context.reserveTokens?.(0);
1632
1999
  disconnect?.();
1633
2000
  context.signal.removeEventListener("abort", stop);
1634
2001
  }
@@ -1636,7 +2003,7 @@ const claudeExecutor = (options) => async (context) => {
1636
2003
  return outcome;
1637
2004
  };
1638
2005
  const first = await run(spec);
1639
- const limitsAfter = ({ sessionId, usage }) => {
2006
+ const limitsAfter = ({ sessionId, usage }, turnsAtLeast = 0) => {
1640
2007
  const quota = context.quota?.();
1641
2008
  if (quota?.blocked !== void 0) {
1642
2009
  watch.stoppedBy(quota.blocked);
@@ -1644,15 +2011,19 @@ const claudeExecutor = (options) => async (context) => {
1644
2011
  }
1645
2012
  return lateLimits(spec, {
1646
2013
  spent: context.ownSpend?.(sessionId, usage.costUsd) ?? usage.costUsd,
1647
- turns: usage.turns
1648
- }, context.budgetLeftNow?.(), quota?.leftUsd);
2014
+ turns: usage.turns,
2015
+ tokens: countedTokens(usage)
2016
+ }, context.budgetLeftNow?.(), quota?.leftUsd, {
2017
+ run: context.tokensLeftNow?.(),
2018
+ quota: quota?.leftTokens
2019
+ }, turnsAtLeast);
1649
2020
  };
1650
2021
  const resumeWith = (outcome, prompt, limits) => run({
1651
2022
  ...spec,
1652
2023
  ...limits,
1653
2024
  resume: outcome.sessionId,
1654
2025
  prompt
1655
- });
2026
+ }, outcome.usage.costUsd);
1656
2027
  const lateTurn = (current) => {
1657
2028
  const late = context.mailbox?.pending() ?? 0;
1658
2029
  const limits = limitsAfter(current);
@@ -1678,7 +2049,7 @@ const claudeExecutor = (options) => async (context) => {
1678
2049
  },
1679
2050
  late: lateTurn,
1680
2051
  resume: async (current, check) => {
1681
- const limits = limitsAfter(current);
2052
+ const limits = limitsAfter(current, 20);
1682
2053
  if (limits === null) return null;
1683
2054
  await context.hold?.wait("start", context.signal);
1684
2055
  return context.signal.aborted ? null : resumeWith(current, checkFailedMessage(check, {
@@ -1691,10 +2062,11 @@ const claudeExecutor = (options) => async (context) => {
1691
2062
  };
1692
2063
  // ../nodes/src/gate.ts
1693
2064
  /**
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).
2065
+ * @file The `human.gate` node type: asks the human the rendered question, with
2066
+ * its document if it has one, both in readable text (no raw JSON), outputs
2067
+ * their choice with the note they gave, and answers with the `default` when
2068
+ * the `timeout` passes. The timeout counts only while the run and the gate
2069
+ * are not paused (ADR-041).
1698
2070
  */
1699
2071
  /** A promise that never settles: the losing side of a race. */
1700
2072
  const NEVER = new Promise(() => {});
@@ -1716,8 +2088,13 @@ const gateExecutor = async (context) => {
1716
2088
  output: {},
1717
2089
  error: `${context.id} is not a gate`
1718
2090
  };
1719
- const template = parseTemplate(node.question);
1720
- const question = template.ok ? renderTemplate(template.parts, runScope(context.inputs, context.outputs, context.runId)) : node.question;
2091
+ const scope = runScope(context.inputs, context.outputs, context.runId);
2092
+ const render = (text) => {
2093
+ const template = parseTemplate(text);
2094
+ return template.ok ? renderTemplate(template.parts, scope, { readable: true }) : text;
2095
+ };
2096
+ const question = render(node.question);
2097
+ const document = render(node.document ?? "");
1721
2098
  const options = node.options.map((o) => choiceOf(o));
1722
2099
  const withdraw = new AbortController();
1723
2100
  context.signal.addEventListener("abort", () => {
@@ -1728,12 +2105,14 @@ const gateExecutor = async (context) => {
1728
2105
  nodeId: context.id,
1729
2106
  question,
1730
2107
  options,
2108
+ waits: true,
1731
2109
  signal: withdraw.signal,
1732
2110
  onNote: (given) => {
1733
2111
  note = given;
1734
2112
  }
1735
2113
  };
1736
2114
  if (node.recommended !== void 0) asked.recommended = node.recommended;
2115
+ if (document.trim() !== "") asked.document = document;
1737
2116
  const answer = context.human.ask(asked).then((choice) => ({
1738
2117
  choice,
1739
2118
  answeredBy: "user",
@@ -1752,21 +2131,22 @@ const gateExecutor = async (context) => {
1752
2131
  })];
1753
2132
  try {
1754
2133
  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
- };
2134
+ if (!options.some((o) => o.id === output.choice)) {
2135
+ const facts = node.timeout === void 0 ? {} : { timeout: node.timeout };
2136
+ return endedWith({
2137
+ ok: false,
2138
+ output
2139
+ }, endFor("timed_out", facts, "no one answered the gate"));
2140
+ }
1760
2141
  return {
1761
2142
  ok: true,
1762
2143
  output
1763
2144
  };
1764
2145
  } catch {
1765
- return {
2146
+ return endedWith({
1766
2147
  ok: false,
1767
- output: {},
1768
- error: "the gate was withdrawn"
1769
- };
2148
+ output: {}
2149
+ }, endFor("interrupted", {}, "the gate was withdrawn"));
1770
2150
  } finally {
1771
2151
  answered.abort();
1772
2152
  }
@@ -1781,18 +2161,16 @@ const gateExecutor = async (context) => {
1781
2161
  */
1782
2162
  /** A node's `timeout:` is what limits a call; this only lifts the SDK's 60 s. */
1783
2163
  const NO_LIMIT_MS = 864e5;
1784
- const failed = (error, output = {}) => ({
2164
+ const failed = (kind, facts, detail, output = {}) => endedWith({
1785
2165
  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
- });
2166
+ output
2167
+ }, endFor(kind, facts, detail));
2168
+ /** How a call that threw ended: a sign-in, a server that won't start, or else. */
2169
+ const thrownKind = (error) => {
2170
+ const code = typeof error === "object" && error !== null && "code" in error ? error.code : void 0;
2171
+ if (code === 401 || error?.constructor.name === "UnauthorizedError") return "auth";
2172
+ return code === "ENOENT" ? "tool_missing" : "command_failed";
2173
+ };
1796
2174
  /**
1797
2175
  * A string's templates rendered. A string that is one `{{ … }}` and nothing
1798
2176
  * else keeps its value's type, so a tool that asks for a number gets one.
@@ -1836,19 +2214,22 @@ const reasonOf = (error, server) => {
1836
2214
  };
1837
2215
  const mcpCallExecutor = (options = {}) => async (context) => {
1838
2216
  const { node } = context;
1839
- if (node.type !== "mcp.call") return failed(`${context.id} is not an MCP call`);
2217
+ if (node.type !== "mcp.call") {
2218
+ const said = `${context.id} is not an MCP call`;
2219
+ return failed("unknown", {}, said);
2220
+ }
1840
2221
  const environ = options.environ ?? process.env;
1841
2222
  const declared = readMcpServer(context.projectRoot, node.server);
1842
- if (!declared.ok) return failed(declared.error);
2223
+ if (!declared.ok) return failed("invalid_node", { reason: declared.error });
1843
2224
  const nodeEnv = renderEnv(node, context, environ);
1844
- if (!nodeEnv.ok) return failed(nodeEnv.error);
2225
+ if (!nodeEnv.ok) return failed("invalid_node", { reason: nodeEnv.error });
1845
2226
  const expanded = expandServer(declared.value, {
1846
2227
  ...environ,
1847
2228
  ...nodeEnv.values
1848
2229
  }, node.plainEnv);
1849
- if (!expanded.ok) return failed(expanded.error);
2230
+ if (!expanded.ok) return failed("invalid_node", { reason: expanded.error });
1850
2231
  const scrub = scrubberOf([...secretsOf(context.workflow, environ, options.maskedNames), ...expanded.value.values]);
1851
- const scope = runScope(context.inputs, context.outputs, context.runId);
2232
+ const scope = runScope(context.inputs, context.outputs, context.runId, context.item);
1852
2233
  const args = Object.fromEntries(Object.entries(node.arguments ?? {}).map(([key, value]) => [key, renderValue(value, scope)]));
1853
2234
  const name = `${node.server}.${node.tool}`;
1854
2235
  context.emit({
@@ -1889,19 +2270,22 @@ const mcpCallExecutor = (options = {}) => async (context) => {
1889
2270
  id: context.id,
1890
2271
  isError: reported || tooLong || problem !== null
1891
2272
  });
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 ? {
2273
+ if (reported) return failed("command_failed", { program: name }, said, output);
2274
+ if (tooLong) {
2275
+ const reason = `${name}'s result is over 64 KiB`;
2276
+ return failed("invalid_output", { reason }, void 0, output);
2277
+ }
2278
+ return problem === null ? endedWith({
1895
2279
  ok: true,
1896
2280
  output
1897
- } : failed(problem, output);
2281
+ }, endFor("done")) : failed("invalid_output", { reason: problem }, void 0, output);
1898
2282
  } catch (error) {
1899
2283
  context.emit({
1900
2284
  type: "tool.result",
1901
2285
  id: context.id,
1902
2286
  isError: true
1903
2287
  });
1904
- return missed(clipError(scrub(reasonOf(error, node.server))));
2288
+ return failed(thrownKind(error), { program: `MCP server \`${node.server}\`` }, clipError(scrub(reasonOf(error, node.server))));
1905
2289
  } finally {
1906
2290
  await client.close().catch(() => {});
1907
2291
  }
@@ -1912,13 +2296,42 @@ const mcpCallExecutor = (options = {}) => async (context) => {
1912
2296
  * text. A program runs in its own process group, so aborting it reaches
1913
2297
  * whatever it started: SIGTERM, then SIGKILL for what ignores that. A stop or
1914
2298
  * a timeout therefore leaves nothing running, and an escaped process that
1915
- * keeps the output pipes open cannot hold the node.
2299
+ * keeps the output pipes open cannot hold the node. Off Windows it runs under
2300
+ * `sh`, whose `times` says the CPU the program and its children used, beside
2301
+ * the orphan guard, which ends the group when `ork` dies, and at the
2302
+ * project's priority: `low` starts it below Ork's own (nice, and on macOS
2303
+ * utility QoS).
1916
2304
  */
1917
- /** How long a program may take to end after SIGTERM. */
1918
- const KILL_AFTER_MS = 2e3;
1919
2305
  /** How long to wait for the pipes to close after SIGKILL. */
1920
2306
  const CLOSE_AFTER_MS = 500;
1921
- const spawnTool = (program, args, { cwd, signal, env }) => {
2307
+ /** The fd of the orphan guard's pipe from `ork`. */
2308
+ const WATCH_FD = 4;
2309
+ /**
2310
+ * Runs `$0 "$@"` beside the orphan guard, which ends the group if `ork` dies,
2311
+ * and stops the guard once the program ends, so its leftovers live on as
2312
+ * before. Then writes the shell's `times` to fd 3: its children's user and
2313
+ * system time, the program's whole tree that it waited for. A program that
2314
+ * isn't there says so on fd 3, as spawn's ENOENT would.
2315
+ */
2316
+ 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`;
2317
+ const MISSING = "ork-missing";
2318
+ /** The children's line of `times` (`0m1.250s 0m0.300s`), in milliseconds. */
2319
+ const cpuOfTimes = (text) => {
2320
+ const [, , user, system] = [...text.matchAll(/(\d+)m([\d.]+)s/gu)].map(([, m = "0", s = "0"]) => Number(m) * 60 + Number(s));
2321
+ return user === void 0 || system === void 0 ? void 0 : {
2322
+ userMs: Math.round(user * 1e3),
2323
+ systemMs: Math.round(system * 1e3)
2324
+ };
2325
+ };
2326
+ const timed = process.platform !== "win32";
2327
+ /** What to start: the program, under `sh` to time it, at the priority. */
2328
+ const startOf = (program, args, priority) => timed ? startAt(priority, "sh", [
2329
+ "-c",
2330
+ TIMED,
2331
+ program,
2332
+ ...args
2333
+ ]) : [program, [...args]];
2334
+ const spawnTool = (program, args, { cwd, signal, env, priority }) => {
1922
2335
  if (signal.aborted) return Promise.resolve({
1923
2336
  exitCode: -1,
1924
2337
  stdout: "",
@@ -1928,7 +2341,9 @@ const spawnTool = (program, args, { cwd, signal, env }) => {
1928
2341
  const stdout = [];
1929
2342
  const stderr = [];
1930
2343
  const timers = [];
1931
- const child = spawn(program, args, {
2344
+ const times = [];
2345
+ const [bin, argv] = startOf(program, args, priority);
2346
+ const child = spawn(bin, argv, {
1932
2347
  cwd,
1933
2348
  detached: true,
1934
2349
  env: {
@@ -1938,9 +2353,17 @@ const spawnTool = (program, args, { cwd, signal, env }) => {
1938
2353
  stdio: [
1939
2354
  "ignore",
1940
2355
  "pipe",
2356
+ "pipe",
2357
+ "pipe",
1941
2358
  "pipe"
1942
2359
  ]
1943
2360
  });
2361
+ lowerChild(priority, child.pid);
2362
+ holdGuard(child.stdio[WATCH_FD]);
2363
+ const fd3 = child.stdio[3];
2364
+ if (fd3 instanceof Readable) fd3.setEncoding("utf8").on("data", (chunk) => {
2365
+ times.push(chunk);
2366
+ });
1944
2367
  const killGroup = (kill) => {
1945
2368
  if (child.pid === void 0) return;
1946
2369
  try {
@@ -1952,11 +2375,21 @@ const spawnTool = (program, args, { cwd, signal, env }) => {
1952
2375
  signal.removeEventListener("abort", abort);
1953
2376
  resolve(run);
1954
2377
  };
1955
- const collected = (exitCode) => ({
1956
- exitCode,
1957
- stdout: stdout.join(""),
1958
- stderr: stderr.join("")
1959
- });
2378
+ const collected = (exitCode) => {
2379
+ const said = times.join("");
2380
+ if (said.includes(MISSING)) return {
2381
+ exitCode: -1,
2382
+ stdout: stdout.join(""),
2383
+ stderr: `could not run ${program}: spawn ${program} ENOENT`
2384
+ };
2385
+ const cpu = cpuOfTimes(said);
2386
+ return {
2387
+ exitCode,
2388
+ stdout: stdout.join(""),
2389
+ stderr: stderr.join(""),
2390
+ ...cpu === void 0 ? {} : { cpu }
2391
+ };
2392
+ };
1960
2393
  const abort = () => {
1961
2394
  killGroup("SIGTERM");
1962
2395
  timers.push(setTimeout(() => {
@@ -1967,10 +2400,10 @@ const spawnTool = (program, args, { cwd, signal, env }) => {
1967
2400
  }, KILL_AFTER_MS));
1968
2401
  };
1969
2402
  signal.addEventListener("abort", abort, { once: true });
1970
- child.stdout.setEncoding("utf8").on("data", (chunk) => {
2403
+ child.stdout?.setEncoding("utf8").on("data", (chunk) => {
1971
2404
  stdout.push(chunk);
1972
2405
  });
1973
- child.stderr.setEncoding("utf8").on("data", (chunk) => {
2406
+ child.stderr?.setEncoding("utf8").on("data", (chunk) => {
1974
2407
  stderr.push(chunk);
1975
2408
  });
1976
2409
  child.on("error", (error) => {
@@ -1990,6 +2423,7 @@ const spawnTool = (program, args, { cwd, signal, env }) => {
1990
2423
  const builtinExecutors = (options) => {
1991
2424
  const tool = toolExecutor(options.tools ?? spawnTool, {
1992
2425
  ...options.orkBin === void 0 ? {} : { orkBin: options.orkBin },
2426
+ ...options.store === void 0 ? {} : { store: options.store },
1993
2427
  ...options.maskedNames === void 0 ? {} : { maskedNames: options.maskedNames }
1994
2428
  });
1995
2429
  const claude = claudeExecutor({
@@ -1997,8 +2431,8 @@ const builtinExecutors = (options) => {
1997
2431
  steps: tool
1998
2432
  });
1999
2433
  const executors = {
2000
- "claude.leader": claude,
2001
- "claude.worker": claude,
2434
+ "agent.leader": claude,
2435
+ "agent.worker": claude,
2002
2436
  "human.gate": gateExecutor,
2003
2437
  "workflow.call": callExecutor,
2004
2438
  "mcp.call": mcpCallExecutor(options.maskedNames === void 0 ? {} : { maskedNames: options.maskedNames })
@@ -2130,4 +2564,4 @@ const missingPrograms = (workflow, installed = (program) => onPath(program)) =>
2130
2564
  }
2131
2565
  return [...missing.values()];
2132
2566
  };
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 };
2567
+ 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 };