@krapht/ork 1.0.0-alpha.10 → 1.0.0-alpha.11

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 (35) hide show
  1. package/dist/{__build_info__-DFF18K8y.mjs → __build_info__-CIchF6nr.mjs} +3 -72
  2. package/dist/bin.mjs +1066 -387
  3. package/dist/chips-BA7SrrH5.mjs +43 -0
  4. package/dist/{clean-DVaEH9e7.mjs → clean-Di-KT7Mf.mjs} +1 -1
  5. package/dist/{completion-Dv5kf598.mjs → completion-C5D0sEs5.mjs} +22 -15
  6. package/dist/{context-CEaKOVmG.mjs → context-BaNshWr9.mjs} +43 -31
  7. package/dist/{decide-3l9q8UtX.mjs → decide-B6PnKeqo.mjs} +1 -1
  8. package/dist/{find-DEjjOlTG.mjs → find-BicBbGMg.mjs} +1 -1
  9. package/dist/{format-Cb5_m-Qd.mjs → format-lWhN-jYR.mjs} +130 -35
  10. package/dist/{in-a-run-BS9Nc-JW.mjs → in-a-run-CgkVouyG.mjs} +1 -1
  11. package/dist/{limits-BSwEjQST.mjs → limits-jZQp9ZJC.mjs} +1 -1
  12. package/dist/{list-Qxr6rwsY.mjs → list-CEaJ07jd.mjs} +1 -1
  13. package/dist/{mcp-CslnG5wf.mjs → mcp-DYgVA9f5.mjs} +6 -6
  14. package/dist/output-CM95njYt.mjs +421 -0
  15. package/dist/{pause-D7OVB5Ac.mjs → pause-D6PuSlNl.mjs} +4 -4
  16. package/dist/{relay-EceRIebO.mjs → relay-DNx7be9k.mjs} +2 -2
  17. package/dist/{remove-0IQZVWmI.mjs → remove-CpMb4NDl.mjs} +3 -3
  18. package/dist/{report-DK4LGqwF.mjs → report-CEN8VoUb.mjs} +31 -20
  19. package/dist/{send-C0uwfUYi.mjs → send-CLs_fyg7.mjs} +3 -3
  20. package/dist/{src-cmmXKHn1.mjs → src-9iGkjnsy.mjs} +234 -150
  21. package/dist/{src-i5k2Y-kf.mjs → src-B6KvTm7B.mjs} +78 -11
  22. package/dist/{src-oaJYIddp.mjs → src-BnZgZGrf.mjs} +523 -81
  23. package/dist/{src-Df6KOIDC.mjs → src-D6_xjm-x.mjs} +1722 -825
  24. package/dist/ui/assets/{app-qs8Lofv6.js → app-CxURph7a.js} +16 -16
  25. package/dist/ui/assets/app-QoHVi-mb.css +1 -0
  26. package/dist/ui/assets/{index-DV1ZaOk-.js → index-By_8KKG6.js} +2 -2
  27. package/dist/ui/index.html +1 -1
  28. package/dist/{ui-CBkHJdqk.mjs → ui-CIci4UBW.mjs} +4 -4
  29. package/dist/{usage-Cd8pEYyK.mjs → usage-GceBGMjU.mjs} +43 -32
  30. package/dist/{watch-DW5R2OPO.mjs → watch-BmoybMQg.mjs} +28 -16
  31. package/dist/{workflows-BT1gDRIV.mjs → workflows-DZvW3i1e.mjs} +3 -3
  32. package/dist/{worktrees-g1MQMGie.mjs → worktrees-Be51E2rB.mjs} +11 -10
  33. package/package.json +1 -1
  34. package/dist/output-Bqljtmp_.mjs +0 -40
  35. package/dist/ui/assets/app-C8uhHVYp.css +0 -1
@@ -3,7 +3,7 @@ import { LineCounter, YAMLMap, YAMLSeq, isAlias, isCollection, isMap, isNode, is
3
3
  import { z } from "zod";
4
4
  import { closeSync, cpSync, existsSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readdirSync, readlinkSync, realpathSync, renameSync, rmSync, rmdirSync, statSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs";
5
5
  import { createHash, randomUUID } from "node:crypto";
6
- import { homedir, tmpdir } from "node:os";
6
+ import { constants as constants$1, homedir, setPriority, tmpdir } from "node:os";
7
7
  import { createConnection, createServer } from "node:net";
8
8
  import { createInterface } from "node:readline";
9
9
  import { execFile, execFileSync, spawn } from "node:child_process";
@@ -86,6 +86,7 @@ const aws = defineIntegration({
86
86
  region: z.string().optional()
87
87
  },
88
88
  args: ({ profile, region }) => [...profile === void 0 ? [] : ["--profile", profile], ...region === void 0 ? [] : ["--region", region]],
89
+ signedOut: ({ stderr }) => /Unable to locate credentials|ExpiredToken|InvalidClientTokenId|Token has expired|aws sso login|SSO session .* expired/iu.test(stderr),
89
90
  doctor: async (exec, env) => {
90
91
  if (!(await exec("aws", ["--version"])).ok) return {
91
92
  name: "aws",
@@ -227,7 +228,7 @@ const installedCli = (id, program, login, rest) => ({
227
228
  const unproven = rest.proven ? "" : ", unproven: not yet proven on a real session";
228
229
  if (!version.ok) return {
229
230
  name,
230
- status: rest.proven ? "warn" : "skip",
231
+ status: "skip",
231
232
  detail: `${program} CLI not found${unproven}`,
232
233
  ...rest.proven ? { hint: `Install ${program} to use the ${id} driver.` } : {}
233
234
  };
@@ -247,7 +248,7 @@ const AGENT_DRIVERS = [
247
248
  claudeCode,
248
249
  installedCli("open-code", "opencode", "opencode auth login", {
249
250
  label: "the `opencode` CLI",
250
- proven: false,
251
+ proven: true,
251
252
  onePerCheckout: false,
252
253
  capabilities: {
253
254
  questions: true,
@@ -255,13 +256,13 @@ const AGENT_DRIVERS = [
255
256
  price: true,
256
257
  midTurnMessages: false,
257
258
  reportSchema: false,
258
- turnLimit: false,
259
+ turnLimit: true,
259
260
  mcpServers: true
260
261
  }
261
262
  }),
262
263
  installedCli("command-code", "cmd", "cmd login", {
263
264
  label: "the `cmd` CLI of Command Code",
264
- proven: false,
265
+ proven: true,
265
266
  onePerCheckout: true,
266
267
  capabilities: {
267
268
  questions: true,
@@ -299,6 +300,7 @@ const gh = defineIntegration({
299
300
  program: "gh",
300
301
  options: {},
301
302
  args: () => [],
303
+ signedOut: ({ exitCode, stderr }) => exitCode === 4 || /\bgh auth login\b/u.test(stderr),
302
304
  doctor: async (exec) => {
303
305
  if (!(await exec("gh", ["--version"])).ok) return {
304
306
  name: "gh",
@@ -1076,6 +1078,8 @@ const RulesSchema = z.strictObject({
1076
1078
  permissions: ToolRulesSchema.optional().describe("Tool rules every Claude node gets on top of its own, e.g. deny: ['Bash(git push *)']."),
1077
1079
  links: z.enum(["on", "off"]).optional().describe("Default: on, the user can link a running node to a node of another run. off: its nodes get no message tools unless they have someone to write to, and cannot be linked.")
1078
1080
  });
1081
+ /** The OS priority Ork starts a run's sessions and tool commands at. */
1082
+ const PrioritySchema = z.enum(["low", "normal"]);
1079
1083
  const ProjectSchema = z.strictObject({
1080
1084
  schemaVersion: z.literal(1),
1081
1085
  ...RulesSchema.shape,
@@ -1083,7 +1087,9 @@ const ProjectSchema = z.strictObject({
1083
1087
  quotas: QuotasSchema.optional().describe("Most this project may spend on this machine. The user's own caps apply too."),
1084
1088
  calls: z.strictObject({ accept: z.array(z.string()).describe("The registered projects whose workflows may call this one's.") }).optional().describe("Default: any registered project may call this one's workflows."),
1085
1089
  context: z.array(z.string()).optional().describe("Context files every node reads, relative to the project root; they come before the workflow's."),
1086
- experimental: z.array(z.string()).optional().describe("Experimental features this project turns on.")
1090
+ experimental: z.array(z.string()).optional().describe("Experimental features this project turns on."),
1091
+ heavy: z.array(z.string().min(1)).optional().describe("The project's full-suite commands, as Bash permission patterns without Bash(…), e.g. pnpm check. A node with verify: may not run them in its session; vitest run does not cover vitest run <files>."),
1092
+ priority: PrioritySchema.optional().describe("Default: low, sessions and tool commands start at a lower OS priority. normal: as the ork process.")
1087
1093
  });
1088
1094
  /** `~/.ork/settings.yaml`: the user's own settings, and never a credential. */
1089
1095
  const UserSettingsSchema = z.strictObject({
@@ -3213,7 +3219,7 @@ const filesOf = (dir, name) => ({
3213
3219
  markdown: path.join(dir, `${name}.md`),
3214
3220
  sidecar: path.join(dir, `${name}${AGENT_SIDECAR_SUFFIX}`)
3215
3221
  });
3216
- const write = (name, agent, options, mode) => {
3222
+ const write$1 = (name, agent, options, mode) => {
3217
3223
  if (!AgentNameSchema.safeParse(name).success) return {
3218
3224
  ok: false,
3219
3225
  error: `\`${name}\` is not an agent name (lowercase letters, digits, '-' and '_')`
@@ -3253,7 +3259,7 @@ const write = (name, agent, options, mode) => {
3253
3259
  };
3254
3260
  };
3255
3261
  /** Creates an agent in Ork's folder for the scope; never overwrites. */
3256
- const createAgent = (name, agent, options) => write(name, agent, options, "create");
3262
+ const createAgent = (name, agent, options) => write$1(name, agent, options, "create");
3257
3263
  /** Deletes an agent from Ork's folder for the scope. */
3258
3264
  const deleteAgent = (name, options) => {
3259
3265
  const dir = agentFolder(options);
@@ -4824,6 +4830,359 @@ const FEEDBACK_SEVERITIES = [
4824
4830
  "medium",
4825
4831
  "high"
4826
4832
  ];
4833
+ // ../core/src/runtime/ends.ts
4834
+ /**
4835
+ * @file How a node ended, as one of a closed set of kinds (specs/runtime/
4836
+ * session-ends.md): the registry that gives each kind its label, Ork's own
4837
+ * message, who should act and what to do, and `endFor`, which builds an end
4838
+ * with its raw detail scrubbed and capped. ends-legacy.ts reads one from runs
4839
+ * stored before ends existed.
4840
+ */
4841
+ /** The most of a detail kept, in characters. */
4842
+ const END_DETAIL_LIMIT = 4096;
4843
+ const RETRY = "This may pass: `ork resume` tries again.";
4844
+ const usd$1 = (n) => `$${Number.isInteger(n) ? String(n) : n.toFixed(2)}`;
4845
+ const paren = (value) => value === void 0 ? "" : ` (${value})`;
4846
+ const colon = (reason) => reason === void 0 ? "" : `: ${reason}`;
4847
+ const info = (label, actor, message, hint, flags = {}) => ({
4848
+ label,
4849
+ message,
4850
+ actor,
4851
+ transient: flags.transient ?? false,
4852
+ stopsRun: flags.stopsRun ?? false,
4853
+ hint,
4854
+ docsAnchor: label.toLowerCase().replaceAll(" ", "-")
4855
+ });
4856
+ /** Each kind's label, message, actor, flags, hint and docs anchor. */
4857
+ const END_KINDS = {
4858
+ done: info("Done", null, () => "finished", ""),
4859
+ reported_failure: info("Reported failure", "agent", () => "reported that it failed", "Read its summary, fix what it names, and run it again."),
4860
+ no_report: info("No report", "agent", () => "ended without handing in a report", "Check that its brief says what to report; its session is in the Logs panel."),
4861
+ invalid_report: info("Invalid report", "agent", (f) => `handed in a report that breaks its declared output${colon(f.reason)}`, "Check that its brief asks for every field its `output.schema` declares."),
4862
+ turn_limit: info("Turn limit", "workflow", (f) => `reached its turn limit${paren(f.maxTurns === void 0 ? void 0 : String(f.maxTurns))}`, "Raise its `maxTurns`, or give it a smaller task."),
4863
+ budget_limit: info("Budget limit", "workflow", (f) => `reached its budget${paren(f.budgetUsd === void 0 ? void 0 : usd$1(f.budgetUsd))}`, "Raise `budgetUsd` on the node or `limits.budgetUsd` on the workflow, then `ork resume`."),
4864
+ timed_out: info("Timed out", "workflow", (f) => `reached its timeout${paren(f.timeout)}`, "Raise its `timeout`, or give it a smaller task."),
4865
+ context_too_long: info("Context too long", "workflow", () => "outgrew its model's context", "Give it a smaller task or less context, or a model with a larger context."),
4866
+ plan_limit: info("Plan limit", "account", (f) => `ran into a usage limit${paren(f.window?.replaceAll("_", " "))}`, `Wait until the limit resets. ${RETRY}`, {
4867
+ transient: true,
4868
+ stopsRun: true
4869
+ }),
4870
+ rate_limited: info("Rate limited", "provider", () => "was asked by its provider to slow down", RETRY, { transient: true }),
4871
+ provider_unavailable: info("Provider unavailable", "provider", () => "could not reach its provider, or the provider was overloaded", RETRY, { transient: true }),
4872
+ auth: info("Not signed in", "account", () => "could not sign in to its provider", "Sign in to the driver's tool again (`ork doctor` says how), then `ork resume`."),
4873
+ out_of_credits: info("Out of credits", "account", () => "found no credit left on its account", "Add credit to the account, or use another, then `ork resume`."),
4874
+ model_unavailable: info("Model unavailable", "account", () => "asked for something its model or provider can't serve", "Use another model or driver for the node."),
4875
+ interrupted: info("Interrupted", "user", () => "was stopped", "`ork resume` goes on from where it stopped."),
4876
+ tool_missing: info("Tool missing", "account", (f) => `could not start ${f.program ?? "its program"}: it isn't installed`, "Install it (`ork doctor` checks the drivers), then `ork resume`."),
4877
+ command_failed: info("Command failed", "workflow", (f) => `${f.program ?? "its command"} exited with ${f.exitCode === void 0 ? "an error" : String(f.exitCode)}`, "Its output is under Details."),
4878
+ invalid_output: info("Invalid output", "workflow", (f) => `gave JSON that breaks its declared output${colon(f.reason)}`, "Make the command print what its `output.schema` declares."),
4879
+ invalid_node: info("Invalid node", "workflow", (f) => `could not start${colon(f.reason)}`, "Fix the node in the workflow file (`ork validate` checks it)."),
4880
+ driver_crashed: info("Driver crashed", "ork", () => "lost its driver in a way Ork can't read", "Its last output is under Details; Ork files it as a bug."),
4881
+ unknown: info("Unknown", "ork", () => "ended for a reason Ork doesn't recognize", "Its detail is under Details; Ork files it as a bug, so a rule can be added.")
4882
+ };
4883
+ /**
4884
+ * An end of `kind`, its message from `facts`. The detail is scrubbed (with the
4885
+ * feedback redactor when `scrub` is given, secrets masked always) and capped.
4886
+ */
4887
+ const endFor = (kind, facts = {}, detail, scrub) => {
4888
+ const end = {
4889
+ kind,
4890
+ message: END_KINDS[kind].message(facts)
4891
+ };
4892
+ if (detail === void 0 || detail.trim() === "") return end;
4893
+ const clean = scrub === void 0 ? maskSecrets(detail) : redact(detail, scrub);
4894
+ end.detail = clean.length > 4096 ? `${clean.slice(0, 4095)}…` : clean;
4895
+ return end;
4896
+ };
4897
+ // ../core/src/runtime/ends-legacy.ts
4898
+ /**
4899
+ * @file The legacy readers of how a node ended: a run stored before ends
4900
+ * existed gets a kind from the text it kept (specs/runtime/session-ends.md),
4901
+ * and the stored event is never rewritten.
4902
+ */
4903
+ /**
4904
+ * Text Ork stored before ends existed, to a kind, in the plan's precedence:
4905
+ * interrupt, plan limit, budget, timeout, turn limit, provider error, missing
4906
+ * tool, failed command, crash. Frozen: old runs' text never changes, so this is
4907
+ * not the live provider classifier.
4908
+ */
4909
+ const LEGACY_TEXT = [
4910
+ ["interrupted", /\bstopped by the user\b|\bthe node was stopped\b|\bstopped while watching\b|\bwas interrupted\b|\bended interrupted\b|\bthe gate was withdrawn\b/u],
4911
+ ["plan_limit", /\bClaude plan's .* limit was reached\b|\bquota of \$[\d.]+ is spent\b|\bended quota_exhausted\b/u],
4912
+ ["budget_limit", /\(error_max_budget_usd\)|\bmaximum budget\b|\bnothing is left of the run's budget\b|\bbudget of \$[\d.]+ is spent\b|\bended budget_exhausted\b/u],
4913
+ ["timed_out", /\btimed out after\b|\bno one answered the gate\b|\btimeout of \S+ passed\b|\bended timed_out\b/u],
4914
+ ["turn_limit", /\(error_max_turns\)|\bmaximum number of turns\b/iu],
4915
+ ["context_too_long", /\bprompt is too long\b|\bcontext[ _](?:length|window)\b/iu],
4916
+ ["rate_limited", /\brate_limit_error\b|\brate.?limit(?:ed)?\b|\b(?:status|HTTP)\W{0,3}429\b/iu],
4917
+ ["out_of_credits", /\bcredit balance\b|\binsufficient[ _](?:credit|funds|quota)\b/iu],
4918
+ ["auth", /\bauthentication_error\b|\binvalid (?:x-)?api.?key\b|\bnot logged in\b|\/login\b|\b(?:status|HTTP)\W{0,3}401\b/iu],
4919
+ ["provider_unavailable", /\boverloaded(?:_error)?\b|\b(?:status|HTTP)\W{0,3}5\d\d\b|\bapi_error\b|\bECONNRESET\b|\bETIMEDOUT\b|\bENOTFOUND\b|\bfetch failed\b/u],
4920
+ ["model_unavailable", /\bassistant message prefill\b|\bInvalid `signature` in `thinking` block\b|\bnot_found_error\b|\binvalid_request_error\b/u],
4921
+ ["tool_missing", /\bENOENT\b|\bcommand not found\b|\bno agent driver is ready\b/u],
4922
+ ["invalid_output", /\$ORK_OUTPUT\b|\bthe JSON result is\b/u],
4923
+ ["command_failed", /\bexited with \d+|^[\w.-]+ [\w-]+ failed: /u],
4924
+ ["driver_crashed", /\bthe session ended without a result\b/u]
4925
+ ];
4926
+ const textKind = (text) => LEGACY_TEXT.find(([, pattern]) => pattern.test(text))?.[0] ?? null;
4927
+ /** What `fieldProblems` writes (run/declared.ts). */
4928
+ const DECLARED_PROBLEM = /\bis missing, expected\b|\bis .+, expected\b|^the result isn't a JSON object$/u;
4929
+ const WITHOUT_REPORT = /^ended without a report \(([\w-]+)\): ([\s\S]*)$/u;
4930
+ /** A driver's subtype, read with the session's error text. */
4931
+ const subtypeKind = (subtype, text) => {
4932
+ if (subtype === "error_max_turns") return "turn_limit";
4933
+ if (subtype === "error_max_budget_usd") return "budget_limit";
4934
+ if (subtype === "error_max_structured_output_retries") return "invalid_report";
4935
+ const said = textKind(text);
4936
+ if (said !== null) return said;
4937
+ if (subtype === "error_driver") return "driver_crashed";
4938
+ return text === "no valid report" || subtype === "success" ? "no_report" : "unknown";
4939
+ };
4940
+ /**
4941
+ * How a finished node ended: its own `end`, or, for an event stored before ends
4942
+ * existed, a kind read from its `error` text (`unknown` when nothing matches),
4943
+ * with that text as the detail.
4944
+ */
4945
+ const endOf = (finished) => {
4946
+ if (finished.end !== void 0) return finished.end;
4947
+ if (finished.ok) return endFor("done");
4948
+ const error = finished.error ?? "";
4949
+ return endFor(legacyKind(error, finished.output), {}, error);
4950
+ };
4951
+ const legacyKind = (error, output) => {
4952
+ if ("artifacts" in output) return DECLARED_PROBLEM.test(error) ? "invalid_report" : "reported_failure";
4953
+ const without = WITHOUT_REPORT.exec(error);
4954
+ if (without !== null) return subtypeKind(without[1] ?? "", without[2] ?? "");
4955
+ return textKind(error) ?? "unknown";
4956
+ };
4957
+ /**
4958
+ * How a session ended: its `end`, or, for an outcome stored before ends existed
4959
+ * (or a driver that does not set one yet), one read from its subtype, report
4960
+ * and errors.
4961
+ */
4962
+ const outcomeEnd = (outcome) => {
4963
+ if (outcome.end !== void 0) return outcome.end;
4964
+ const { report } = outcome;
4965
+ if (report !== null && outcome.subtype === "success") return endFor(report.status === "fail" ? "reported_failure" : "done");
4966
+ const text = outcome.errors[0] ?? (report === null ? "no valid report" : "");
4967
+ return endFor(subtypeKind(outcome.subtype, text), {}, outcome.errors.join("\n"));
4968
+ };
4969
+ // ../core/src/feedback/end-signals.ts
4970
+ /**
4971
+ * @file The feedback a node's end files (specs/feedback.md,
4972
+ * specs/runtime/session-ends.md), by who should act on it: Ork's own bug, the
4973
+ * workflow's, or the agent's context. An account's, a provider's or the
4974
+ * user's end is a setup hint on the screen, not feedback.
4975
+ */
4976
+ /**
4977
+ * Ends that are a node's work saying no, not a problem to act on: an agent that
4978
+ * reports `fail`, a command that exits non-zero. Neither is filed.
4979
+ */
4980
+ const ANSWERS = /* @__PURE__ */ new Set(["reported_failure", "command_failed"]);
4981
+ /**
4982
+ * The feedback a node's end files, by who should act on it: Ork's own bug (with
4983
+ * the scrubbed detail, so a rule can be added), the workflow's, or the agent's
4984
+ * context. An account's, a provider's or the user's is not feedback.
4985
+ */
4986
+ const endSignal = (node, end) => {
4987
+ const info = END_KINDS[end.kind];
4988
+ if (ANSWERS.has(end.kind)) return null;
4989
+ const base = {
4990
+ code: `end.${end.kind}`,
4991
+ title: `${node} ${end.message}`,
4992
+ body: info.hint,
4993
+ ...end.detail === void 0 ? {} : { evidence: [end.detail] }
4994
+ };
4995
+ if (info.actor === "ork") return {
4996
+ ...base,
4997
+ kind: "bug",
4998
+ target: "ork",
4999
+ severity: "medium"
5000
+ };
5001
+ if (info.actor === "workflow") return {
5002
+ ...base,
5003
+ kind: "workflow",
5004
+ target: "workflow",
5005
+ severity: "low"
5006
+ };
5007
+ return info.actor === "agent" ? {
5008
+ ...base,
5009
+ kind: "context-gap",
5010
+ target: "workflow",
5011
+ severity: "low"
5012
+ } : null;
5013
+ };
5014
+ // ../core/src/feedback/signals.ts
5015
+ const ORK_TOOL = "mcp__ork__";
5016
+ /**
5017
+ * An observer for one run's events: give it each event, and record what it
5018
+ * returns, in order (the event, its origin filled in when it is feedback, then
5019
+ * any feedback it signals).
5020
+ */
5021
+ const createSignals = (options = {}) => {
5022
+ let workflow;
5023
+ const nodeTypes = /* @__PURE__ */ new Map();
5024
+ const sessions = /* @__PURE__ */ new Map();
5025
+ const tools = /* @__PURE__ */ new Map();
5026
+ const asked = /* @__PURE__ */ new Map();
5027
+ const seen = /* @__PURE__ */ new Set();
5028
+ const originOf = (node) => {
5029
+ const origin = {};
5030
+ if (workflow !== void 0) origin.workflow = workflow;
5031
+ if (options.workflowHash !== void 0) origin.workflowHash = options.workflowHash;
5032
+ const type = node === void 0 ? void 0 : nodeTypes.get(node);
5033
+ if (type !== void 0) origin.nodeType = type;
5034
+ const session = node === void 0 ? void 0 : sessions.get(node);
5035
+ if (session !== void 0) origin.model = session.model;
5036
+ if (session?.driver !== void 0) origin.driver = session.driver;
5037
+ if (options.version !== void 0) origin.version = options.version;
5038
+ return origin;
5039
+ };
5040
+ const record = (node, signal) => {
5041
+ const print = fingerprint(signal);
5042
+ if (seen.has(print)) return [];
5043
+ seen.add(print);
5044
+ return [{
5045
+ type: "feedback.recorded",
5046
+ ...node === void 0 ? {} : { node },
5047
+ feedback: {
5048
+ source: "system",
5049
+ kind: signal.kind,
5050
+ target: signal.target,
5051
+ severity: signal.severity,
5052
+ title: signal.title,
5053
+ body: signal.body,
5054
+ evidence: signal.evidence ?? [],
5055
+ code: signal.code,
5056
+ fingerprint: print,
5057
+ origin: originOf(node)
5058
+ }
5059
+ }];
5060
+ };
5061
+ const answered = (question, answer) => {
5062
+ if (question.kind === "permission") {
5063
+ if (answer !== "deny") return null;
5064
+ const tool = question.question.replace(/^Allow /u, "").replace(/\?$/u, "");
5065
+ return [question.node, {
5066
+ code: "permission.denied",
5067
+ kind: "friction",
5068
+ target: "workflow",
5069
+ severity: "low",
5070
+ title: `${question.node} was denied ${tool}`,
5071
+ body: "Allow it in the node's `tools.allow` if it needs it, or tell it in its prompt not to use it.",
5072
+ evidence: [tool]
5073
+ }];
5074
+ }
5075
+ if (question.recommended === void 0 || answer === question.recommended) return null;
5076
+ return [question.node, {
5077
+ code: "recommendation.overridden",
5078
+ kind: "context-gap",
5079
+ target: "workflow",
5080
+ severity: "low",
5081
+ title: `The answer to ${question.node}'s question was not the one it recommended`,
5082
+ body: `Asked: ${question.question} Recommended: ${question.recommended}. Answered: ${answer}.`
5083
+ }];
5084
+ };
5085
+ /** The feedback an event signals, if any, and the node it is about. */
5086
+ const signalOf = (event) => {
5087
+ if (event.type === "loop.exhausted") return [event.from, {
5088
+ code: "loop.exhausted",
5089
+ kind: "workflow",
5090
+ target: "workflow",
5091
+ severity: "medium",
5092
+ title: `The loop ${event.from} -> ${event.to} ran out of its ${String(event.maxRounds)} rounds`,
5093
+ body: "Raise `maxRounds`, or make what the loop's nodes are asked to fix clearer so it settles sooner."
5094
+ }];
5095
+ if (event.type === "run.finished" && event.result.status === "budget_exhausted") return [void 0, {
5096
+ code: "budget.exhausted",
5097
+ kind: "workflow",
5098
+ target: "workflow",
5099
+ severity: "medium",
5100
+ title: "The run's budget ran out",
5101
+ body: `The run stopped after spending $${event.result.costUsd.toFixed(4)}. Raise \`limits.budgetUsd\`, or give nodes cheaper models or smaller budgets.`
5102
+ }];
5103
+ if (event.type === "decision.answered") {
5104
+ const question = asked.get(event.decision);
5105
+ return question === void 0 ? null : answered(question, event.answer);
5106
+ }
5107
+ if (event.type === "node.event" && event.event.type === "tool.result") {
5108
+ const name = tools.get(`${event.id}\0${event.event.id}`);
5109
+ if (!event.event.isError || name?.startsWith(ORK_TOOL) !== true) return null;
5110
+ const tool = name.slice(10);
5111
+ if (tool === "report") return [event.id, {
5112
+ code: "report.rejected",
5113
+ kind: "context-gap",
5114
+ target: "workflow",
5115
+ severity: "low",
5116
+ title: `${event.id}'s report was not accepted`,
5117
+ body: END_KINDS.invalid_report.hint,
5118
+ evidence: [name]
5119
+ }];
5120
+ return [event.id, {
5121
+ code: "tool.error",
5122
+ kind: "bug",
5123
+ target: "ork",
5124
+ severity: "medium",
5125
+ title: `Ork's ${tool} tool returned an error`,
5126
+ body: `A call to \`${tool}\` failed; the node's events show the call.`,
5127
+ evidence: [name]
5128
+ }];
5129
+ }
5130
+ if (event.type === "node.finished" && nodeTypes.get(event.id) === "human.gate" && event.output["answeredBy"] === "timeout") return [event.id, {
5131
+ code: "gate.timeout",
5132
+ kind: "workflow",
5133
+ target: "workflow",
5134
+ severity: "low",
5135
+ title: `The gate ${event.id} timed out and took its default`,
5136
+ body: "No one answered in time. Lengthen its `timeout`, or keep it if the default was right."
5137
+ }];
5138
+ if (event.type === "node.finished" && !event.ok) {
5139
+ const signal = endSignal(event.id, endOf(event));
5140
+ return signal === null ? null : [event.id, signal];
5141
+ }
5142
+ return null;
5143
+ };
5144
+ /** Keeps what later events and origins need. */
5145
+ const observe = (event) => {
5146
+ if (event.type === "run.started") workflow = event.workflow;
5147
+ if (event.type === "node.started") nodeTypes.set(event.id, event.nodeType);
5148
+ if (event.type === "decision.opened") {
5149
+ const question = {
5150
+ node: event.node,
5151
+ kind: event.kind,
5152
+ question: event.question
5153
+ };
5154
+ if (event.recommended !== void 0) question.recommended = event.recommended;
5155
+ asked.set(event.decision, question);
5156
+ }
5157
+ if (event.type !== "node.event") return;
5158
+ const inner = event.event;
5159
+ if (inner.type === "session.started" && inner.fork !== true) sessions.set(event.id, {
5160
+ model: inner.model,
5161
+ ...inner.driver === void 0 ? {} : { driver: inner.driver }
5162
+ });
5163
+ if (inner.type === "tool.called") tools.set(`${event.id}\0${inner.id}`, inner.name);
5164
+ };
5165
+ return (event) => {
5166
+ observe(event);
5167
+ if (event.type === "feedback.recorded") {
5168
+ if (seen.has(event.feedback.fingerprint)) return [];
5169
+ seen.add(event.feedback.fingerprint);
5170
+ const origin = {
5171
+ ...originOf(event.node),
5172
+ ...event.feedback.origin
5173
+ };
5174
+ return [{
5175
+ ...event,
5176
+ feedback: {
5177
+ ...event.feedback,
5178
+ origin
5179
+ }
5180
+ }];
5181
+ }
5182
+ const signal = signalOf(event);
5183
+ return signal === null ? [event] : [event, ...record(...signal)];
5184
+ };
5185
+ };
4827
5186
  // ../core/src/project/mcp-file.ts
4828
5187
  /**
4829
5188
  * @file The project's `.mcp.json`, the file Claude Code reads too, so a server
@@ -4937,6 +5296,48 @@ const checkMcpServers = (workflow, options) => {
4937
5296
  });
4938
5297
  });
4939
5298
  };
5299
+ // ../core/src/project/heavy.ts
5300
+ /**
5301
+ * @file A project's rules for the machine its runs share: the heavy commands
5302
+ * (its full-suite checks), denied to the session of a node with `verify:`
5303
+ * since Ork runs that check, and the OS priority, each with Ork's default.
5304
+ */
5305
+ /** A `heavy` pattern as the Bash rule that denies it to a session. */
5306
+ const heavyRule = (pattern) => `Bash(${pattern})`;
5307
+ /**
5308
+ * The nodes, with the heavy commands denied to each Claude node that has
5309
+ * `verify:`; other nodes are left as they are. A pattern without `*` matches
5310
+ * only that command, so `vitest run` leaves `vitest run <files>` allowed.
5311
+ */
5312
+ const withoutHeavy = (nodes, heavy) => {
5313
+ if (heavy.length === 0) return nodes;
5314
+ const rules = heavy.map((pattern) => heavyRule(pattern));
5315
+ return Object.fromEntries(Object.entries(nodes).map(([id, node]) => isClaudeNode(node) && "verify" in node && node.verify !== void 0 ? [id, {
5316
+ ...node,
5317
+ tools: {
5318
+ ...node.tools,
5319
+ deny: [.../* @__PURE__ */ new Set([...node.tools?.deny ?? [], ...rules])]
5320
+ }
5321
+ }] : [id, node]));
5322
+ };
5323
+ const words = (text) => text.trim().replaceAll(/\s+/gu, " ");
5324
+ /**
5325
+ * Whether `command` matches a `heavy` pattern, in the permission patterns'
5326
+ * syntax: `*` is any text, a trailing ` *` (or `:*`) also matches nothing, and
5327
+ * a pattern without `*` matches only that command.
5328
+ */
5329
+ const matchesPattern = (pattern, command) => {
5330
+ const tail = words(pattern).replace(/:\*$/u, " *");
5331
+ const open = tail.endsWith(" *");
5332
+ const body = (open ? tail.slice(0, -2) : tail).split("*").map((part) => part.replaceAll(/[.+?^${}()|[\]\\]/gu, "\\$&")).join(".*");
5333
+ return new RegExp(`^${body}${open ? "(?: .*)?" : ""}$`, "su").test(words(command));
5334
+ };
5335
+ /** Whether `command` is one of the project's heavy commands. */
5336
+ const isHeavy = (heavy, command) => heavy.some((pattern) => matchesPattern(pattern, command));
5337
+ const machineRulesOf = (project) => ({
5338
+ heavy: project?.heavy ?? [],
5339
+ priority: project?.priority ?? "low"
5340
+ });
4940
5341
  // ../core/src/project/templates.ts
4941
5342
  /**
4942
5343
  * @file Git name templates (`ork/{run}/{node}`, `{inputs.type}/{inputs.slug}`):
@@ -5028,7 +5429,8 @@ const dropUndefined = (value) => Object.fromEntries(Object.entries(value).filter
5028
5429
  /**
5029
5430
  * The workflow with its project's rules under it: git rules key by key and
5030
5431
  * defaults key by key, practices one by one (the workflow's win), permissions
5031
- * added together, and the project's context files before the workflow's.
5432
+ * added together, the project's context files before the workflow's, and its
5433
+ * heavy commands denied to nodes with `verify:`.
5032
5434
  */
5033
5435
  const applyProject = (workflow, options) => {
5034
5436
  const { project } = options;
@@ -5062,6 +5464,7 @@ const applyProject = (workflow, options) => {
5062
5464
  });
5063
5465
  return dropUndefined({
5064
5466
  ...workflow,
5467
+ nodes: withoutHeavy(workflow.nodes, project.heavy ?? []),
5065
5468
  defaults: Object.keys(defaults).length === 0 ? void 0 : defaults,
5066
5469
  rules: Object.keys(rules).length === 0 ? void 0 : rules,
5067
5470
  layers: Object.keys(layers).length === 0 ? void 0 : layers
@@ -6646,6 +7049,138 @@ const compileWorkflow = (authored, options = {}) => {
6646
7049
  diagnostics
6647
7050
  };
6648
7051
  };
7052
+ // ../core/src/machine/priority.ts
7053
+ /**
7054
+ * @file Starting a child at the project's priority (`priority:`): `low`, the
7055
+ * default, starts it below Ork's own (under `nice`; on macOS also utility QoS
7056
+ * through `taskpolicy`), so the user's own apps come first; `normal` leaves
7057
+ * it. Only children: the `ork` process itself stays at normal priority.
7058
+ */
7059
+ /** MacOS's tool for a program's QoS. */
7060
+ const TASKPOLICY = "/usr/sbin/taskpolicy";
7061
+ /** The POSIX tool that starts a program at a lower priority. */
7062
+ const NICE = "/usr/bin/nice";
7063
+ /** How far `nice` lowers a `low` child: the same as `PRIORITY_BELOW_NORMAL`. */
7064
+ const NICE_STEP = String(constants$1.priority.PRIORITY_BELOW_NORMAL);
7065
+ /**
7066
+ * Whether `bin` is a program `spawn` would find, so a wrapper can't hide its
7067
+ * absence.
7068
+ */
7069
+ const found = (bin, env) => bin.includes("/") ? existsSync(bin) : (env["PATH"] ?? "").split(path.delimiter).some((dir) => dir !== "" && existsSync(path.join(dir, bin)));
7070
+ /**
7071
+ * What to spawn for `bin args` at `priority`. For `low`, when the program is
7072
+ * there: under `nice`, so it and what it starts run lower from the first
7073
+ * instruction, and on macOS under utility QoS as well; else the program itself,
7074
+ * which {@link lowerChild} lowers once it has started.
7075
+ */
7076
+ const startAt = (priority, bin, args, env = process.env) => {
7077
+ if (priority !== "low" || !found(bin, env)) return [bin, [...args]];
7078
+ const niced = existsSync(NICE) ? [
7079
+ NICE,
7080
+ "-n",
7081
+ NICE_STEP,
7082
+ bin
7083
+ ] : [bin];
7084
+ const [first = bin, ...rest] = process.platform === "darwin" && existsSync(TASKPOLICY) ? [
7085
+ TASKPOLICY,
7086
+ "-c",
7087
+ "utility",
7088
+ ...niced
7089
+ ] : niced;
7090
+ return [first, [...rest, ...args]];
7091
+ };
7092
+ /**
7093
+ * Lowers a started child's priority for `low`, best effort; what it starts
7094
+ * later inherits it.
7095
+ */
7096
+ const lowerChild = (priority, pid) => {
7097
+ if (priority !== "low" || pid === void 0) return;
7098
+ try {
7099
+ setPriority(pid, constants$1.priority.PRIORITY_BELOW_NORMAL);
7100
+ } catch {}
7101
+ };
7102
+ // ../core/src/run/machine.ts
7103
+ /**
7104
+ * @file A node's share of the machine (the `machine` of its context): the
7105
+ * project's priority for its commands, and a machine-wide slot around each
7106
+ * heavy one. A wait is never silent: `slot.waiting` names who holds the
7107
+ * slots, `slot.taken` ends it, and the node's clock (its owners' and the
7108
+ * run's too) stands still meanwhile, so a timeout counts from the slot.
7109
+ */
7110
+ /** A wait for a slot, as `ork logs`, `ork tree` and the UI say it. */
7111
+ const slotWaitLine = (holders) => holders.length === 0 ? "waiting for a check slot" : `waiting for a check slot, held by ${holders.join(", ")}`;
7112
+ const none = () => {};
7113
+ /** The port for one activation; none for a run that shares nothing. */
7114
+ const machinePort = (options, runId, id, clock) => {
7115
+ const { machine } = options;
7116
+ if (machine === void 0) return void 0;
7117
+ const { rules, ledger } = machine;
7118
+ return {
7119
+ priority: rules.priority,
7120
+ slot: async (command, signal) => {
7121
+ if (ledger === void 0 || !isHeavy(rules.heavy, command)) return none;
7122
+ let waited = false;
7123
+ const release = await clock.waitFor(id, () => ledger.take({
7124
+ run: runId,
7125
+ node: id,
7126
+ pid: process.pid
7127
+ }, {
7128
+ signal,
7129
+ onWait: (holders) => {
7130
+ waited = true;
7131
+ options.onEvent({
7132
+ type: "slot.waiting",
7133
+ id,
7134
+ holders: holders.map((held) => `${held.run}/${held.node}`)
7135
+ });
7136
+ }
7137
+ }));
7138
+ if (release === null) return none;
7139
+ if (waited) options.onEvent({
7140
+ type: "slot.taken",
7141
+ id
7142
+ });
7143
+ return release;
7144
+ }
7145
+ };
7146
+ };
7147
+ /**
7148
+ * A step's clock that also stands still while its command waits for a slot, and
7149
+ * the machine port that tells it so; for a step's own timeout.
7150
+ */
7151
+ const slotClock = (base, machine) => {
7152
+ let waiting = 0;
7153
+ const listeners = /* @__PURE__ */ new Set();
7154
+ const tell = () => {
7155
+ for (const listener of listeners) listener();
7156
+ };
7157
+ return {
7158
+ clock: {
7159
+ held: () => waiting > 0 || base?.held() === true,
7160
+ watch: (listener) => {
7161
+ listeners.add(listener);
7162
+ const unwatch = base?.watch(listener);
7163
+ return () => {
7164
+ listeners.delete(listener);
7165
+ unwatch?.();
7166
+ };
7167
+ }
7168
+ },
7169
+ machine: machine === void 0 ? void 0 : {
7170
+ ...machine,
7171
+ slot: async (command, signal) => {
7172
+ waiting += 1;
7173
+ tell();
7174
+ try {
7175
+ return await machine.slot(command, signal);
7176
+ } finally {
7177
+ waiting -= 1;
7178
+ tell();
7179
+ }
7180
+ }
7181
+ }
7182
+ };
7183
+ };
6649
7184
  // ../core/src/project/calls.ts
6650
7185
  /**
6651
7186
  * @file `ork validate`'s check of `workflow.call` nodes against what they call
@@ -7055,6 +7590,66 @@ const createLoops = (options) => {
7055
7590
  overList
7056
7591
  };
7057
7592
  };
7593
+ // ../core/src/run/ended.ts
7594
+ /**
7595
+ * @file A node's result as it ended (specs/runtime/session-ends.md): one place
7596
+ * that sets `end`, makes a failure's `error` its message and marks a
7597
+ * transient kind, and that the engine's modules build ends through.
7598
+ */
7599
+ /**
7600
+ * The result as it ended: a failure's `error` is the end's message (what the
7601
+ * run summary lists), and a transient kind marks it `transient`.
7602
+ */
7603
+ const endedWith = (result, end) => ({
7604
+ ...result,
7605
+ end,
7606
+ ...result.ok ? {} : { error: end.message },
7607
+ ...END_KINDS[end.kind].transient ? { transient: true } : {}
7608
+ });
7609
+ /** The reason a run's signal is aborted with when `timeout` passed. */
7610
+ const runTimedOut = (timeout) => timeout === void 0 ? { kind: "timed_out" } : {
7611
+ kind: "timed_out",
7612
+ timeout
7613
+ };
7614
+ const isTimedOut = (reason) => typeof reason === "object" && reason !== null && "kind" in reason && reason.kind === "timed_out";
7615
+ /**
7616
+ * The end of a node its run's signal stopped: `timed_out` when the run's
7617
+ * timeout aborted it, `interrupted` otherwise.
7618
+ */
7619
+ const stoppedEnd = (signal, detail) => {
7620
+ const reason = signal.reason;
7621
+ if (!isTimedOut(reason)) return endFor("interrupted", {}, detail);
7622
+ const facts = reason.timeout === void 0 ? {} : { timeout: reason.timeout };
7623
+ return endFor("timed_out", facts, detail);
7624
+ };
7625
+ /** A failure that ended as `end`, its message the output's summary. */
7626
+ const failedWith = (end, output) => endedWith({
7627
+ ok: false,
7628
+ output: output ?? {
7629
+ status: "fail",
7630
+ summary: end.message
7631
+ }
7632
+ }, end);
7633
+ /**
7634
+ * The result with its end's detail scrubbed with the feedback redactor (the
7635
+ * run's `roots` and the home folder), as a session's is
7636
+ * (specs/runtime/session-ends.md), whichever node type set it.
7637
+ */
7638
+ const scrubbedEnd = (result, roots) => {
7639
+ const { end } = result;
7640
+ if (end?.detail === void 0) return result;
7641
+ const detail = redact(end.detail, {
7642
+ roots,
7643
+ home: homedir()
7644
+ });
7645
+ return {
7646
+ ...result,
7647
+ end: {
7648
+ ...end,
7649
+ detail
7650
+ }
7651
+ };
7652
+ };
7058
7653
  // ../core/src/run/declared.ts
7059
7654
  /**
7060
7655
  * @file Holding a node to what it declares of its output (RFC-010): a missing
@@ -7127,514 +7722,112 @@ const declaredProblem = (declared, value) => {
7127
7722
  * still masks to the record is the file's, with its real values.
7128
7723
  */
7129
7724
  const unmasked = (forEach, recorded) => "items" in forEach && JSON.stringify(forEach.items, maskReplacer) === JSON.stringify(recorded) ? [...forEach.items] : recorded;
7130
- // ../core/src/run/contacts.ts
7131
- const SESSION_TYPES$5 = /* @__PURE__ */ new Set(["claude.leader", "claude.worker"]);
7132
- const isSession$1 = (node) => SESSION_TYPES$5.has(node?.type ?? "");
7133
- const isTool = (node) => node?.type.startsWith("tool.") ?? false;
7134
- const channelPeersOf = (id, channels) => channels.flatMap(([a, b]) => {
7135
- if (a === id) return [b];
7136
- return b === id ? [a] : [];
7137
- });
7138
- /** Every declared node, with its owner, as a run records them. */
7139
- const declaredNodes = (graph) => [...graph.nodes.values()].map(({ id, node }) => node.parent === void 0 ? {
7140
- id,
7141
- type: node.type
7142
- } : {
7143
- id,
7144
- type: node.type,
7145
- parent: node.parent
7146
- });
7147
- /** Whom a session may write to, by how it knows them. */
7148
- const contactsOf = (id, nodes, channels) => {
7149
- const byId = new Map(nodes.map((node) => [node.id, node]));
7725
+ // ../core/src/run/slots.ts
7726
+ /**
7727
+ * @file Session slots: at most `limits.concurrency` Claude sessions run at
7728
+ * once. Queued activations take a free slot when the scheduler pumps;
7729
+ * delegated children and leaders returning from `delegate` wait for one and
7730
+ * are served first, so a leader waiting on its children never holds a slot
7731
+ * they need.
7732
+ */
7733
+ /** Only a Claude session takes a slot; tools and gates are not limited. */
7734
+ const needsSlot = (node) => isClaudeNode(node);
7735
+ /** A call spends through its run, so it needs budget as a session does. */
7736
+ const spendsBudget = (node) => needsSlot(node) || node.type === "workflow.call";
7737
+ const createSlots = (limit, onFree) => {
7738
+ let used = 0;
7739
+ const waiting = [];
7150
7740
  return {
7151
- owner: byId.get(id)?.parent,
7152
- children: nodes.filter((node) => node.parent === id && isSession$1(node)).map((node) => node.id),
7153
- channels: channelPeersOf(id, channels).filter((peer) => !isTool(byId.get(peer)))
7741
+ limit,
7742
+ tryTake: () => {
7743
+ if (used >= limit || waiting.length > 0) return false;
7744
+ used += 1;
7745
+ return true;
7746
+ },
7747
+ take: () => {
7748
+ if (used < limit && waiting.length === 0) {
7749
+ used += 1;
7750
+ return Promise.resolve();
7751
+ }
7752
+ const { promise, resolve } = Promise.withResolvers();
7753
+ waiting.push(resolve);
7754
+ return promise;
7755
+ },
7756
+ release: () => {
7757
+ const next = waiting.shift();
7758
+ if (next === void 0) {
7759
+ used -= 1;
7760
+ onFree();
7761
+ } else next();
7762
+ }
7154
7763
  };
7155
7764
  };
7156
- /** Whom a program in the tool node `id` may write to. */
7157
- const scriptContactsOf = (id, nodes, channels) => {
7158
- const byId = new Map(nodes.map((node) => [node.id, node]));
7159
- const owner = byId.get(id)?.parent;
7160
- return [.../* @__PURE__ */ new Set([...owner !== void 0 && isSession$1(byId.get(owner)) ? [owner] : [], ...channelPeersOf(id, channels).filter((peer) => isSession$1(byId.get(peer)))])];
7765
+ // ../core/src/run/fan-out.ts
7766
+ /** The node's `forEach`, whatever its type; `undefined` when it has none. */
7767
+ const forEachOf = (node) => node !== void 0 && "forEach" in node ? node.forEach : void 0;
7768
+ /** The list a node runs over, or why there isn't one. */
7769
+ const listOf = (forEach, scope) => {
7770
+ if ("items" in forEach) return {
7771
+ ok: true,
7772
+ list: forEach.items
7773
+ };
7774
+ const read = templateValue(forEach.from, scope);
7775
+ if (!read.ok) return read;
7776
+ const { value } = read;
7777
+ if (Array.isArray(value)) return {
7778
+ ok: true,
7779
+ list: value
7780
+ };
7781
+ const name = String(templatePath(forEach.from)?.at(-1) ?? forEach.from);
7782
+ return {
7783
+ ok: false,
7784
+ error: declaredProblem({ [name]: { list: {} } }, { [name]: value }) ?? `${name} is not a list`
7785
+ };
7161
7786
  };
7162
- /** Whether a program in a tool node may write to the session `id`. */
7163
- const hearsFromScripts = (id, nodes, channels) => nodes.some((node) => isTool(node) && scriptContactsOf(node.id, nodes, channels).includes(id));
7164
- // ../core/src/usage/spend.ts
7165
- /** What a report would add to the session's earlier ones, counting nothing. */
7166
- const unreported = (reported, key, total) => {
7167
- if (total <= 0) return 0;
7168
- const before = reported.get(key) ?? 0;
7169
- return total < before ? total : total - before;
7787
+ /** The output a `from` list comes from, whose freshness resume asks about. */
7788
+ const sourceOf = (forEach, scope) => {
7789
+ if (!("from" in forEach)) return void 0;
7790
+ const [root, id] = templatePath(forEach.from) ?? [];
7791
+ return root === "nodes" ? scope.nodes[String(id)]?.output : void 0;
7170
7792
  };
7793
+ const isRecord$1 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
7171
7794
  /**
7172
- * What a report adds to the session's earlier ones, keeping the total in
7173
- * `reported`. A zero says nothing, so it is ignored; a lower total is a session
7174
- * that started counting again, so all of it is new.
7795
+ * What `results` keeps of an item: its declared fields (a program's are under
7796
+ * `json`), its summary, Ork's own branch and worktree, and how a program
7797
+ * failed. The full output stays in the item's own `node.finished`.
7175
7798
  */
7176
- const ownSpend = (reported, key, total) => {
7177
- const own = unreported(reported, key, total);
7178
- if (total > 0) reported.set(key, total);
7179
- return own;
7180
- };
7181
- /**
7182
- * What a run's events say it spent, for `ork resume`. The total is what its
7183
- * nodes recorded. A session's report counts once its node finished, or when
7184
- * `ork chat` made it outside any node: a session cut off before its node
7185
- * finished has recorded nothing, so its next total counts the part that was cut
7186
- * off. A leader's fork is never resumed, so its report counts when it comes,
7187
- * whether or not its leader finished.
7188
- */
7189
- const earlierSpend = (events) => {
7190
- const reported = /* @__PURE__ */ new Map();
7191
- const open = /* @__PURE__ */ new Map();
7192
- const forks = /* @__PURE__ */ new Map();
7193
- let total = 0;
7194
- for (const event of events) if (event.type === "node.started") open.set(event.id, null);
7195
- else if (event.type === "node.finished") {
7196
- total += event.usage?.costUsd ?? 0;
7197
- const last = open.get(event.id);
7198
- if (last !== null && last !== void 0) ownSpend(reported, last.key, last.cost);
7199
- open.delete(event.id);
7200
- } else if (event.type === "node.event" && event.event.type === "session.finished") {
7201
- const { sessionId, usage } = event.event.outcome;
7202
- const key = `${event.id}/${sessionId}`;
7203
- if (event.event.fork === true) {
7204
- const own = ownSpend(reported, key, usage.costUsd);
7205
- total += own;
7206
- forks.set(event.id, (forks.get(event.id) ?? 0) + own);
7207
- } else if (open.has(event.id)) open.set(event.id, {
7208
- key,
7209
- cost: usage.costUsd
7210
- });
7211
- else ownSpend(reported, key, usage.costUsd);
7212
- }
7213
- return {
7214
- total,
7215
- reported,
7216
- forks
7217
- };
7218
- };
7219
- // ../core/src/run/reserves.ts
7220
- const createReserves = () => {
7221
- const held = /* @__PURE__ */ new Map();
7222
- const waiters = /* @__PURE__ */ new Set();
7223
- const wake = () => {
7224
- const woken = [...waiters];
7225
- waiters.clear();
7226
- for (const resolve of woken) resolve();
7227
- };
7228
- return {
7229
- /** All that is set aside now. */
7230
- total: () => [...held.values()].reduce((sum, reserve) => sum + reserve.usd, 0),
7231
- /**
7232
- * `id` sets aside `usd` (nothing, or 0, lets it go). `waitable` is false
7233
- * for a leader, whose children must never wait on it.
7234
- */
7235
- reserve: (id, usd, waitable) => {
7236
- const before = held.get(id)?.usd ?? 0;
7237
- if (usd === void 0 || usd <= 0) held.delete(id);
7238
- else held.set(id, {
7239
- usd,
7240
- waitable
7241
- });
7242
- if ((usd ?? 0) < before) wake();
7243
- },
7244
- /** The node ended: whatever it still held is let go. */
7245
- drop: (id) => {
7246
- if (held.delete(id)) wake();
7247
- },
7248
- /**
7249
- * Waits until a reserve shrinks. `false` at once when no other node holds
7250
- * one worth waiting on, or when `signal` aborts.
7251
- */
7252
- freed: (id, signal) => {
7253
- if (![...held].some(([other, reserve]) => other !== id && reserve.waitable) || signal.aborted) return Promise.resolve(false);
7254
- return new Promise((resolve) => {
7255
- const done = (freed) => () => {
7256
- waiters.delete(onFree);
7257
- signal.removeEventListener("abort", onAbort);
7258
- resolve(freed);
7259
- };
7260
- const onFree = done(true);
7261
- const onAbort = done(false);
7262
- waiters.add(onFree);
7263
- signal.addEventListener("abort", onAbort, { once: true });
7264
- });
7265
- }
7266
- };
7267
- };
7268
- // ../core/src/run/spending.ts
7269
- /**
7270
- * A result with only what this activation spent (ADR-046): a session's report
7271
- * holds its total so far, while its tokens are this process's own. `reported`
7272
- * holds each session's total.
7273
- */
7274
- const withOwnSpend = (id, result, reported) => {
7275
- const { sessionId, usage } = result;
7276
- if (sessionId === void 0 || usage === void 0) return result;
7277
- const own = {
7278
- ...usage,
7279
- costUsd: ownSpend(reported, `${id}/${sessionId}`, usage.costUsd)
7280
- };
7281
- return {
7282
- ...result,
7283
- usage: own,
7284
- output: "usage" in result.output ? {
7285
- ...result.output,
7286
- usage: own
7287
- } : result.output
7288
- };
7289
- };
7290
- /** A run's spending so far; a resumed run starts from what it had spent. */
7291
- const createSpend = (budgetUsd, earlier, limits) => {
7292
- const reported = new Map(earlier?.reported);
7293
- let spent = earlier?.total ?? 0;
7294
- /** What running sessions last reported, not yet in `spent`, by `node/session`. */
7295
- const inFlight = /* @__PURE__ */ new Map();
7296
- const committed = () => spent + [...inFlight.values()].reduce((sum, cost) => sum + cost, 0);
7297
- /** Caps running sessions were given (ADR-077): out of what is offered. */
7298
- const reserves = createReserves();
7299
- const budgetLeft = () => budgetUsd === void 0 ? void 0 : budgetUsd - committed() - reserves.total();
7300
- return {
7301
- total: () => spent,
7302
- /** A node sets aside its session's cap; nothing or 0 lets it go. */
7303
- reserve: reserves.reserve,
7304
- /** Waits until another node's reserve frees; `false` when none can. */
7305
- freed: reserves.freed,
7306
- /** What a session has spent in this activation, from its total so far. */
7307
- ownOf: (id, sessionId, total) => unreported(reported, `${id}/${sessionId}`, total),
7308
- /** A running session said what it has spent so far. */
7309
- report: (id, sessionId, total) => {
7310
- const key = `${id}/${sessionId}`;
7311
- inFlight.set(key, unreported(reported, key, total));
7312
- },
7313
- /** The node ended: `count` has what its sessions last reported. */
7314
- settled: (id) => {
7315
- for (const key of inFlight.keys()) if (key.startsWith(`${id}/`)) inFlight.delete(key);
7316
- reserves.drop(id);
7317
- },
7318
- /** What a session may spend: the least of the budget and any quota. */
7319
- left: () => {
7320
- const known = [budgetLeft(), limits?.().leftUsd].filter((left) => left !== void 0);
7321
- return known.length === 0 ? void 0 : Math.min(...known);
7322
- },
7323
- budgetLeft,
7324
- /** What the limits across runs allow now. */
7325
- quota: () => limits?.() ?? {},
7326
- /**
7327
- * What stops anything new from starting: a quota first, as a budget can be
7328
- * raised.
7329
- */
7330
- spentOut: () => {
7331
- const blocked = limits?.().blocked;
7332
- if (blocked !== void 0) return {
7333
- status: "quota_exhausted",
7334
- reason: blocked
7335
- };
7336
- return budgetUsd !== void 0 && committed() >= budgetUsd ? {
7337
- status: "budget_exhausted",
7338
- reason: `the run's budget of $${String(budgetUsd)} is spent`
7339
- } : null;
7340
- },
7341
- /**
7342
- * Counts a result, and gives it back with only its own spend. A replayed
7343
- * result is as stored, and already in what the run started from.
7344
- */
7345
- count: (id, result, replayed) => {
7346
- if (replayed) return result;
7347
- const own = withOwnSpend(id, result, reported);
7348
- spent += own.usage?.costUsd ?? 0;
7349
- return own;
7350
- }
7351
- };
7352
- };
7353
- /** A run's spending, from what the scheduler is given. */
7354
- const spendOf = (budgetUsd, options) => createSpend(budgetUsd, options.replay?.earlier, options.limits);
7355
- // ../core/src/run/gather.ts
7356
- const asList = (value) => Array.isArray(value) ? value : [];
7357
- /** `before` then `now`, leaving out an entry already there. */
7358
- const joined = (before, now) => {
7359
- const seen = /* @__PURE__ */ new Set();
7360
- const out = [];
7361
- for (const entry of [...asList(before), ...asList(now)]) {
7362
- const key = JSON.stringify(entry);
7363
- if (seen.has(key)) continue;
7364
- seen.add(key);
7365
- out.push(entry);
7366
- }
7367
- return out;
7368
- };
7369
- /**
7370
- * `result` with each gathered field holding every round's entries so far.
7371
- * `previous` is the node's output from its last round, already gathered.
7372
- */
7373
- const joinRounds = (fields, previous, result) => {
7374
- if (fields.length === 0 || previous === void 0) return result;
7375
- const output = { ...result.output };
7376
- for (const field of fields) output[field] = joined(previous[field], result.output[field]);
7377
- return {
7378
- ...result,
7379
- output
7380
- };
7381
- };
7382
- /** `result` with `node`'s `output.gather` fields joined to its earlier rounds. */
7383
- const gatherRounds = (node, previous, result) => joinRounds(gatheredFieldsOf(node), previous, result);
7384
- /**
7385
- * What `activation`'s rounds reported so far, to gather onto. A spawned
7386
- * instance gathers nothing earlier: its id (`impl-2`) is reused by a different
7387
- * worker in the leader's next round. A delegated child that hasn't run since a
7388
- * resume starts from what it reported before it, since its leader's recorded
7389
- * rounds are handed back whole and never run it again.
7390
- */
7391
- const previousOf = (activation, current, replay) => {
7392
- const { brief, template, id } = activation;
7393
- if (brief !== void 0 && template !== void 0) return void 0;
7394
- if (current !== void 0 || brief === void 0) return current;
7395
- return replay?.lastOutput?.(id);
7396
- };
7397
- /** Gathers each activation's result onto what its node reported before. */
7398
- const createGatherer = (outputs, replay, nodeOf) => (activation, result) => gatherRounds(nodeOf(activation), previousOf(activation, outputs[activation.id], replay), result);
7399
- // ../core/src/run/results.ts
7400
- /** Control nodes pass their input through; a join's input is its output. */
7401
- const passThrough = (input) => ({
7402
- output: input ?? {},
7403
- ok: true
7404
- });
7405
- const recordRun = (entry, result) => {
7406
- entry.runs += 1;
7407
- entry.ok = result.ok;
7408
- entry.output = result.output;
7409
- entry.costUsd += result.usage?.costUsd ?? 0;
7410
- if (result.error === void 0) delete entry.error;
7411
- else entry.error = result.error;
7412
- };
7413
- const startedEvent = (activation, nodeType, round, agent) => {
7414
- const event = {
7415
- type: "node.started",
7416
- id: activation.id,
7417
- nodeType,
7418
- round
7419
- };
7420
- if (activation.brief !== void 0) event.owner = activation.brief.from;
7421
- if (agent !== void 0) event.agent = agent;
7422
- const { item } = activation;
7423
- if (item !== void 0) event.item = {
7424
- of: item.of,
7425
- index: item.index
7426
- };
7427
- return event;
7428
- };
7429
- const finishedEvent = (id, round, result) => {
7430
- const event = {
7431
- type: "node.finished",
7432
- id,
7433
- round,
7434
- ok: result.ok,
7435
- output: result.output
7436
- };
7437
- if (result.usage !== void 0) event.usage = result.usage;
7438
- if (result.error !== void 0) event.error = result.error;
7439
- return event;
7440
- };
7441
- const failure = (error) => ({
7442
- output: {
7443
- status: "fail",
7444
- summary: error
7445
- },
7446
- ok: false,
7447
- error
7448
- });
7449
- /** The node's entry in the run summary, created on first use. */
7450
- const summaryOf = (summaries, id) => {
7451
- const existing = summaries.get(id);
7452
- if (existing !== void 0) return existing;
7453
- const created = {
7454
- id,
7455
- runs: 0,
7456
- ok: null,
7457
- output: null,
7458
- costUsd: 0
7459
- };
7460
- summaries.set(id, created);
7461
- return created;
7462
- };
7463
- /** A run's summary; a resumed run's leaders keep their forks' spend (ADR-063). */
7464
- const createSummaries = (earlier) => {
7465
- const summaries = /* @__PURE__ */ new Map();
7466
- const summary = (id) => summaryOf(summaries, id);
7467
- for (const [id, costUsd] of earlier?.forks ?? []) summary(id).costUsd += costUsd;
7468
- return {
7469
- summaries,
7470
- summary
7471
- };
7472
- };
7473
- /** The tighter of the workflow's budget and what a caller left it, if any. */
7474
- const runBudget = (graph, settings) => {
7475
- const limits = [graph.workflow.limits?.budgetUsd, settings?.budgetCap].filter((limit) => limit !== void 0);
7476
- return limits.length === 0 ? {} : { budgetUsd: Math.min(...limits) };
7477
- };
7478
- /** The `run.started` event: the workflow, its entries, nodes and inputs. */
7479
- const runStartedEvent = (graph, runId, options) => ({
7480
- type: "run.started",
7481
- runId,
7482
- workflow: graph.workflow.name,
7483
- entries: graph.entries,
7484
- nodes: declaredNodes(graph),
7485
- edges: graph.edges.map(({ from, to, when, maxRounds }) => ({
7486
- from,
7487
- to,
7488
- ...when === void 0 ? {} : { when },
7489
- ...maxRounds === void 0 ? {} : { maxRounds }
7490
- })),
7491
- channels: graph.channels,
7492
- inputs: options.inputs,
7493
- ...options.settings === void 0 ? {} : { settings: options.settings },
7494
- ...runBudget(graph, options.settings),
7495
- ...options.orkVersion === void 0 ? {} : { ork: options.orkVersion }
7496
- });
7497
- /** The unread mail of nodes that never ran; a node that ran saw its notice. */
7498
- const unreadOf = (nodes, unread) => {
7499
- const ran = new Set(nodes.filter((node) => node.runs > 0).map((node) => node.id));
7500
- const left = [...unread ?? []].filter(([node]) => !ran.has(node)).map(([node, messages]) => ({
7501
- node,
7502
- messages
7503
- }));
7504
- return left.length === 0 ? {} : { unread: left };
7505
- };
7506
- /**
7507
- * The run's result: failed when any reason was given, unless it stopped. A
7508
- * skipped node or a handled failure leaves it `succeeded`, and is counted.
7509
- */
7510
- const runResult = (run) => ({
7511
- runId: run.runId,
7512
- status: run.stopped ?? (run.reasons.length > 0 ? "failed" : "succeeded"),
7513
- costUsd: run.costUsd,
7514
- nodes: run.nodes,
7515
- reasons: run.reasons,
7516
- worktrees: run.worktrees,
7517
- ...run.outputs === void 0 ? {} : { outputs: run.outputs },
7518
- skipped: run.nodes.filter((node) => node.runs === 0).length,
7519
- failuresHandled: run.failuresHandled,
7520
- ...unreadOf(run.nodes, run.unread)
7521
- });
7522
- /** A workflow's `outputs`, rendered as the run ends. */
7523
- const renderOutputs = (outputs, scope) => outputs === void 0 ? void 0 : Object.fromEntries(Object.entries(outputs).map(([name, text]) => {
7524
- const template = parseTemplate(text);
7525
- return [name, template.ok ? renderTemplate(template.parts, scope) : text];
7526
- }));
7527
- /** Closes the run's worktrees; a failure is one more reason, not a crash. */
7528
- const closeWorkspace = (workspace, reasons) => (workspace?.close() ?? Promise.resolve([])).catch((error) => {
7529
- reasons.push(`cleaning up worktrees: ${String(error)}`);
7530
- return [];
7531
- });
7532
- // ../core/src/run/slots.ts
7533
- /**
7534
- * @file Session slots: at most `limits.concurrency` Claude sessions run at
7535
- * once. Queued activations take a free slot when the scheduler pumps;
7536
- * delegated children and leaders returning from `delegate` wait for one and
7537
- * are served first, so a leader waiting on its children never holds a slot
7538
- * they need.
7539
- */
7540
- /** Only a Claude session takes a slot; tools and gates are not limited. */
7541
- const needsSlot = (node) => isClaudeNode(node);
7542
- /** A call spends through its run, so it needs budget as a session does. */
7543
- const spendsBudget = (node) => needsSlot(node) || node.type === "workflow.call";
7544
- const createSlots = (limit, onFree) => {
7545
- let used = 0;
7546
- const waiting = [];
7547
- return {
7548
- limit,
7549
- tryTake: () => {
7550
- if (used >= limit || waiting.length > 0) return false;
7551
- used += 1;
7552
- return true;
7553
- },
7554
- take: () => {
7555
- if (used < limit && waiting.length === 0) {
7556
- used += 1;
7557
- return Promise.resolve();
7558
- }
7559
- const { promise, resolve } = Promise.withResolvers();
7560
- waiting.push(resolve);
7561
- return promise;
7562
- },
7563
- release: () => {
7564
- const next = waiting.shift();
7565
- if (next === void 0) {
7566
- used -= 1;
7567
- onFree();
7568
- } else next();
7569
- }
7570
- };
7571
- };
7572
- // ../core/src/run/fan-out.ts
7573
- /** The node's `forEach`, whatever its type; `undefined` when it has none. */
7574
- const forEachOf = (node) => node !== void 0 && "forEach" in node ? node.forEach : void 0;
7575
- /** The list a node runs over, or why there isn't one. */
7576
- const listOf = (forEach, scope) => {
7577
- if ("items" in forEach) return {
7578
- ok: true,
7579
- list: forEach.items
7580
- };
7581
- const read = templateValue(forEach.from, scope);
7582
- if (!read.ok) return read;
7583
- const { value } = read;
7584
- if (Array.isArray(value)) return {
7585
- ok: true,
7586
- list: value
7587
- };
7588
- const name = String(templatePath(forEach.from)?.at(-1) ?? forEach.from);
7589
- return {
7590
- ok: false,
7591
- error: declaredProblem({ [name]: { list: {} } }, { [name]: value }) ?? `${name} is not a list`
7592
- };
7593
- };
7594
- /** The output a `from` list comes from, whose freshness resume asks about. */
7595
- const sourceOf = (forEach, scope) => {
7596
- if (!("from" in forEach)) return void 0;
7597
- const [root, id] = templatePath(forEach.from) ?? [];
7598
- return root === "nodes" ? scope.nodes[String(id)]?.output : void 0;
7599
- };
7600
- const isRecord$1 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
7601
- /**
7602
- * What `results` keeps of an item: its declared fields (a program's are under
7603
- * `json`), its summary, Ork's own branch and worktree, and how a program
7604
- * failed. The full output stays in the item's own `node.finished`.
7605
- */
7606
- const entryOf = (declared, { ok, output, error }) => {
7607
- const json = isRecord$1(output["json"]) ? output["json"] : {};
7608
- const fields = Object.keys(declared ?? {}).map((field) => [field, Object.hasOwn(json, field) ? json[field] : output[field]]);
7609
- const own = [
7610
- "summary",
7611
- "branch",
7612
- "worktree",
7613
- "merge"
7614
- ].map((field) => [field, output[field]]);
7615
- const failed = !ok && typeof output["exitCode"] === "number" ? [["exitCode", output["exitCode"]], ["error", error]] : [];
7616
- return Object.fromEntries([
7617
- ...fields,
7618
- ...own,
7619
- ...failed
7620
- ].filter(([, value]) => value !== void 0));
7621
- };
7622
- /** Whether an item passed but its work could not reach its owner. */
7623
- const mergeFailed = ({ output }) => isRecord$1(output["merge"]) && output["merge"]["ok"] === false;
7624
- const statusOf$2 = (result) => {
7625
- if (mergeFailed(result)) return "fail";
7626
- const { ok, output } = result;
7627
- const status = output["status"];
7628
- if (typeof status === "string") return status;
7629
- return ok ? "pass" : "fail";
7630
- };
7631
- /** Why a fan-out did not succeed, naming the first item that failed. */
7632
- const whyNot = (count, failures, skipped) => {
7633
- const parts = [`${String(failures.length)} of ${String(count)} items failed`];
7634
- const [first] = failures;
7635
- if (first !== void 0) parts[0] = `${parts[0] ?? ""}; first, ${first}`;
7636
- if (skipped > 0) parts.push(`${String(skipped)} not started`);
7637
- return parts.join("; ");
7799
+ const entryOf = (declared, { ok, output, error }) => {
7800
+ const json = isRecord$1(output["json"]) ? output["json"] : {};
7801
+ const fields = Object.keys(declared ?? {}).map((field) => [field, Object.hasOwn(json, field) ? json[field] : output[field]]);
7802
+ const own = [
7803
+ "summary",
7804
+ "branch",
7805
+ "worktree",
7806
+ "merge"
7807
+ ].map((field) => [field, output[field]]);
7808
+ const failed = !ok && typeof output["exitCode"] === "number" ? [["exitCode", output["exitCode"]], ["error", error]] : [];
7809
+ return Object.fromEntries([
7810
+ ...fields,
7811
+ ...own,
7812
+ ...failed
7813
+ ].filter(([, value]) => value !== void 0));
7814
+ };
7815
+ /** Whether an item passed but its work could not reach its owner. */
7816
+ const mergeFailed = ({ output }) => isRecord$1(output["merge"]) && output["merge"]["ok"] === false;
7817
+ const statusOf$2 = (result) => {
7818
+ if (mergeFailed(result)) return "fail";
7819
+ const { ok, output } = result;
7820
+ const status = output["status"];
7821
+ if (typeof status === "string") return status;
7822
+ return ok ? "pass" : "fail";
7823
+ };
7824
+ /** Why a fan-out did not succeed, naming the first item that failed. */
7825
+ const whyNot = (count, failures, skipped) => {
7826
+ const parts = [`${String(failures.length)} of ${String(count)} items failed`];
7827
+ const [first] = failures;
7828
+ if (first !== void 0) parts[0] = `${parts[0] ?? ""}; first, ${first}`;
7829
+ if (skipped > 0) parts.push(`${String(skipped)} not started`);
7830
+ return parts.join("; ");
7638
7831
  };
7639
7832
  /**
7640
7833
  * Waits until the item may start: not while it is held, and without keeping a
@@ -7659,7 +7852,7 @@ const createFanOut = (deps) => {
7659
7852
  ok: true,
7660
7853
  list: unmasked(forEach, recorded)
7661
7854
  };
7662
- if (!listed.ok) return failure(listed.error);
7855
+ if (!listed.ok) return failedWith(endFor("invalid_node", { reason: listed.error }));
7663
7856
  let { list } = listed;
7664
7857
  const recordList = () => {
7665
7858
  options.onEvent({
@@ -7685,7 +7878,10 @@ const createFanOut = (deps) => {
7685
7878
  if (chosen === null) {
7686
7879
  const reason = `the list of ${id} has ${String(list.length)} items, over maxItems ${String(forEach.maxItems)}`;
7687
7880
  return stops.ended(id, {
7688
- ...failure(reason),
7881
+ ...failedWith(endFor("invalid_node", { reason }), {
7882
+ status: "fail",
7883
+ summary: reason
7884
+ }),
7689
7885
  limited: reason,
7690
7886
  stopsAs: "failed"
7691
7887
  });
@@ -7701,6 +7897,8 @@ const createFanOut = (deps) => {
7701
7897
  const results = list.map(() => ({}));
7702
7898
  const statuses = list.map(() => "skipped");
7703
7899
  const failures = [];
7900
+ /** How the first item that failed ended: the fanned node's own end. */
7901
+ let firstEnd;
7704
7902
  const mayStart = () => !signal.aborted && !deps.isStopped() && (forEach.onFailure !== "stop" || failures.length === 0);
7705
7903
  const runItem = async (index) => {
7706
7904
  const itemId = `${id}.${String(index)}`;
@@ -7721,8 +7919,10 @@ const createFanOut = (deps) => {
7721
7919
  });
7722
7920
  results[index] = entryOf(declared, result);
7723
7921
  statuses[index] = statusOf$2(result);
7724
- if (!result.ok) failures.push(`${itemId}: ${result.error ?? "failed"}`);
7725
- else if (mergeFailed(result)) failures.push(`${itemId}: its work could not be merged`);
7922
+ if (!result.ok) {
7923
+ firstEnd ??= endOf(result);
7924
+ failures.push(`${itemId}: ${result.error ?? "failed"}`);
7925
+ } else if (mergeFailed(result)) failures.push(`${itemId}: its work could not be merged`);
7726
7926
  } finally {
7727
7927
  if (session) slots.release();
7728
7928
  }
@@ -7748,6 +7948,7 @@ const createFanOut = (deps) => {
7748
7948
  count: list.length
7749
7949
  },
7750
7950
  ...ok ? {} : { error: whyNot(list.length, failures, skipped) },
7951
+ ...ok || firstEnd === void 0 ? {} : { end: firstEnd },
7751
7952
  ...!ok && deps.isStopped() ? { limited: deps.reasons[0] ?? "the run stopped" } : {}
7752
7953
  };
7753
7954
  return {
@@ -7764,7 +7965,14 @@ const createRunChild = (hooks) => async (child, brief) => {
7764
7965
  await hooks.slots.take();
7765
7966
  try {
7766
7967
  await hooks.holds.forNode(child.id, child.template).wait("start", hooks.signal);
7767
- if (hooks.isStopped() || hooks.budgetSpent()) return failure(`not started: ${hooks.reasons[0] ?? "the run stopped"}`);
7968
+ if (hooks.isStopped() || hooks.budgetSpent()) {
7969
+ const why = `not started: ${hooks.reasons[0] ?? "the run stopped"}`;
7970
+ const end = hooks.budgetSpent() ? endFor("budget_limit", {}, why) : stoppedEnd(hooks.signal, why);
7971
+ return failedWith(end, {
7972
+ status: "fail",
7973
+ summary: why
7974
+ });
7975
+ }
7768
7976
  return await hooks.perform({
7769
7977
  ...child,
7770
7978
  input: void 0,
@@ -7876,154 +8084,561 @@ const createOwnership = (hooks) => {
7876
8084
  brief: task.brief
7877
8085
  };
7878
8086
  });
7879
- return runJobs(jobs);
8087
+ return runJobs(jobs);
8088
+ };
8089
+ return {
8090
+ children,
8091
+ fanned: (owner?.children ?? []).filter((id) => isFanned(id)),
8092
+ templates,
8093
+ workersLeft: () => Math.max(0, maxWorkers - spawned.length),
8094
+ delegate,
8095
+ spawn
8096
+ };
8097
+ };
8098
+ // ../core/src/run/scope.ts
8099
+ /**
8100
+ * @file A run's id and folder, and the scope templates and `when:` conditions
8101
+ * read during it.
8102
+ */
8103
+ /** The run's id (given, or a new one) and its `.ork/runs/<run>` folder. */
8104
+ const runPlace = (options) => {
8105
+ const runId = options.runId ?? randomUUID().slice(0, 8);
8106
+ return {
8107
+ runId,
8108
+ runDir: options.runDir ?? path.join(options.cwd, ".ork", "runs", runId)
8109
+ };
8110
+ };
8111
+ /**
8112
+ * The folders every node of a run reads from: the workflow's, the project's,
8113
+ * Ork's.
8114
+ */
8115
+ const foldersOf = (options) => ({
8116
+ workflowDir: options.workflowDir ?? options.cwd,
8117
+ projectRoot: options.projectRoot ?? options.cwd,
8118
+ ...options.orkDir === void 0 ? {} : { orkDir: options.orkDir }
8119
+ });
8120
+ /** What templates and `when:` read during a run. */
8121
+ const runScope = (inputs, outputs, runId, item) => {
8122
+ const scope = {
8123
+ inputs,
8124
+ nodes: Object.fromEntries(Object.entries(outputs).map(([id, output]) => [id, { output }]))
8125
+ };
8126
+ if (runId !== void 0) scope.run = { id: runId };
8127
+ if (item !== void 0) {
8128
+ scope.item = item.value;
8129
+ scope.index = item.index;
8130
+ }
8131
+ return scope;
8132
+ };
8133
+ // ../core/src/run/context.ts
8134
+ /**
8135
+ * @file One activation's execute context: what the scheduler hands a node's
8136
+ * executor. Its inputs and the outputs so far, where it works, the budget
8137
+ * left, the human, its hold and its own stop signal (ADR-041), its ports on
8138
+ * the bus, the session it resumes, its share of the machine, and, for a
8139
+ * leader, its ownership of its children.
8140
+ */
8141
+ const nodeContext = (deps, activation, cwd, round, clock) => {
8142
+ const { options, runId } = deps;
8143
+ const { graph } = options;
8144
+ const { id, template } = activation;
8145
+ const context = {
8146
+ runId,
8147
+ id,
8148
+ workflow: graph.workflow,
8149
+ inputs: options.inputs,
8150
+ outputs: deps.outputs,
8151
+ input: activation.input,
8152
+ round,
8153
+ cwd,
8154
+ runDir: deps.runDir,
8155
+ ...foldersOf(options),
8156
+ budgetLeftUsd: deps.spend.left(),
8157
+ budgetLeftNow: deps.spend.budgetLeft,
8158
+ reserveBudget: (usd) => {
8159
+ deps.spend.reserve(id, usd, deps.nodeOf(activation)?.type !== "claude.leader");
8160
+ },
8161
+ budgetFreed: (signal) => deps.spend.freed(id, signal),
8162
+ quota: deps.spend.quota,
8163
+ ownSpend: (sessionId, total) => deps.spend.ownOf(id, sessionId, total),
8164
+ human: deps.human,
8165
+ hold: deps.holds.forNode(id, template),
8166
+ ...deps.ports.forNode(id, template, cwd),
8167
+ ...options.calls === void 0 ? {} : { calls: options.calls },
8168
+ emit: (event) => {
8169
+ deps.ports.owners.seen(id, event, cwd);
8170
+ if (event.type === "session.finished" && event.fork !== true) {
8171
+ const { sessionId, usage } = event.outcome;
8172
+ deps.spend.report(id, sessionId, usage.costUsd);
8173
+ }
8174
+ options.onEvent({
8175
+ type: "node.event",
8176
+ id,
8177
+ event
8178
+ });
8179
+ },
8180
+ signal: deps.stops.signalFor(id, template)
8181
+ };
8182
+ const owner = ownerPort(deps, id, template);
8183
+ if (owner !== void 0) context.owner = owner;
8184
+ if (template !== void 0) context.template = template;
8185
+ const machine = clock === void 0 ? void 0 : machinePort(options, runId, id, clock);
8186
+ if (machine !== void 0) context.machine = machine;
8187
+ if (activation.brief !== void 0) context.brief = activation.brief;
8188
+ if (activation.item !== void 0) context.item = activation.item;
8189
+ const resume = options.replay?.cutOff?.(activation) ?? resumeOf(deps.nodeOf(activation), deps.sessionIds.get(id) ?? options.replay?.previousSession?.(activation));
8190
+ if (resume !== void 0) context.resume = resume;
8191
+ if (deps.nodeOf(activation)?.type === "claude.leader") context.ownership = createOwnership({
8192
+ graph,
8193
+ leader: id,
8194
+ runChild: deps.runChild,
8195
+ isRunning: (other) => deps.running.has(other),
8196
+ checkpoint: () => options.workspace?.checkpoint(id) ?? Promise.resolve(null),
8197
+ releaseClaims: () => {
8198
+ const held = context.claims?.held() ?? [];
8199
+ for (const resource of held) context.claims?.release(resource);
8200
+ return held;
8201
+ },
8202
+ lend: deps.slots.release,
8203
+ reclaim: deps.slots.take
8204
+ });
8205
+ return context;
8206
+ };
8207
+ /** Where `id`'s questions go first, if its leader takes them (ADR-063). */
8208
+ const ownerPort = (deps, id, template) => deps.ports.owners.portFor(id, template, {
8209
+ budgetLeftUsd: deps.spend.left,
8210
+ charge: (owner, sessionId, usage) => {
8211
+ const own = deps.spend.count(owner, {
8212
+ ok: true,
8213
+ output: {},
8214
+ sessionId,
8215
+ usage
8216
+ }, false);
8217
+ deps.summary(owner).costUsd += own.usage?.costUsd ?? 0;
8218
+ },
8219
+ emit: (owner, event) => {
8220
+ deps.options.onEvent({
8221
+ type: "node.event",
8222
+ id: owner,
8223
+ event
8224
+ });
8225
+ }
8226
+ });
8227
+ /**
8228
+ * The session a node that runs again continues, when it resumes (ADR-033): by
8229
+ * `session: resume`, the default for a leader; a worker starts fresh unless it
8230
+ * says `resume`.
8231
+ */
8232
+ const resumeOf = (node, sessionId) => {
8233
+ if (node === void 0 || !isClaudeNode(node)) return void 0;
8234
+ if (node.type === "claude.subagent") return void 0;
8235
+ return (node.session ?? (node.type === "claude.leader" ? "resume" : "fresh")) === "resume" ? sessionId : void 0;
8236
+ };
8237
+ // ../core/src/run/contacts.ts
8238
+ const SESSION_TYPES$5 = /* @__PURE__ */ new Set(["claude.leader", "claude.worker"]);
8239
+ const isSession$1 = (node) => SESSION_TYPES$5.has(node?.type ?? "");
8240
+ const isTool = (node) => node?.type.startsWith("tool.") ?? false;
8241
+ const channelPeersOf = (id, channels) => channels.flatMap(([a, b]) => {
8242
+ if (a === id) return [b];
8243
+ return b === id ? [a] : [];
8244
+ });
8245
+ /** Every declared node, with its owner, as a run records them. */
8246
+ const declaredNodes = (graph) => [...graph.nodes.values()].map(({ id, node }) => node.parent === void 0 ? {
8247
+ id,
8248
+ type: node.type
8249
+ } : {
8250
+ id,
8251
+ type: node.type,
8252
+ parent: node.parent
8253
+ });
8254
+ /** Whom a session may write to, by how it knows them. */
8255
+ const contactsOf = (id, nodes, channels) => {
8256
+ const byId = new Map(nodes.map((node) => [node.id, node]));
8257
+ return {
8258
+ owner: byId.get(id)?.parent,
8259
+ children: nodes.filter((node) => node.parent === id && isSession$1(node)).map((node) => node.id),
8260
+ channels: channelPeersOf(id, channels).filter((peer) => !isTool(byId.get(peer)))
8261
+ };
8262
+ };
8263
+ /** Whom a program in the tool node `id` may write to. */
8264
+ const scriptContactsOf = (id, nodes, channels) => {
8265
+ const byId = new Map(nodes.map((node) => [node.id, node]));
8266
+ const owner = byId.get(id)?.parent;
8267
+ return [.../* @__PURE__ */ new Set([...owner !== void 0 && isSession$1(byId.get(owner)) ? [owner] : [], ...channelPeersOf(id, channels).filter((peer) => isSession$1(byId.get(peer)))])];
8268
+ };
8269
+ /** Whether a program in a tool node may write to the session `id`. */
8270
+ const hearsFromScripts = (id, nodes, channels) => nodes.some((node) => isTool(node) && scriptContactsOf(node.id, nodes, channels).includes(id));
8271
+ // ../core/src/usage/spend.ts
8272
+ /** What a report would add to the session's earlier ones, counting nothing. */
8273
+ const unreported = (reported, key, total) => {
8274
+ if (total <= 0) return 0;
8275
+ const before = reported.get(key) ?? 0;
8276
+ return total < before ? total : total - before;
8277
+ };
8278
+ /**
8279
+ * What a report adds to the session's earlier ones, keeping the total in
8280
+ * `reported`. A zero says nothing, so it is ignored; a lower total is a session
8281
+ * that started counting again, so all of it is new.
8282
+ */
8283
+ const ownSpend = (reported, key, total) => {
8284
+ const own = unreported(reported, key, total);
8285
+ if (total > 0) reported.set(key, total);
8286
+ return own;
8287
+ };
8288
+ /**
8289
+ * What a run's events say it spent, for `ork resume`. The total is what its
8290
+ * nodes recorded. A session's report counts once its node finished, or when
8291
+ * `ork chat` made it outside any node: a session cut off before its node
8292
+ * finished has recorded nothing, so its next total counts the part that was cut
8293
+ * off. A leader's fork is never resumed, so its report counts when it comes,
8294
+ * whether or not its leader finished.
8295
+ */
8296
+ const earlierSpend = (events) => {
8297
+ const reported = /* @__PURE__ */ new Map();
8298
+ const open = /* @__PURE__ */ new Map();
8299
+ const forks = /* @__PURE__ */ new Map();
8300
+ let total = 0;
8301
+ for (const event of events) if (event.type === "node.started") open.set(event.id, null);
8302
+ else if (event.type === "node.finished") {
8303
+ total += event.usage?.costUsd ?? 0;
8304
+ const last = open.get(event.id);
8305
+ if (last !== null && last !== void 0) ownSpend(reported, last.key, last.cost);
8306
+ open.delete(event.id);
8307
+ } else if (event.type === "node.event" && event.event.type === "session.finished") {
8308
+ const { sessionId, usage } = event.event.outcome;
8309
+ const key = `${event.id}/${sessionId}`;
8310
+ if (event.event.fork === true) {
8311
+ const own = ownSpend(reported, key, usage.costUsd);
8312
+ total += own;
8313
+ forks.set(event.id, (forks.get(event.id) ?? 0) + own);
8314
+ } else if (open.has(event.id)) open.set(event.id, {
8315
+ key,
8316
+ cost: usage.costUsd
8317
+ });
8318
+ else ownSpend(reported, key, usage.costUsd);
8319
+ }
8320
+ return {
8321
+ total,
8322
+ reported,
8323
+ forks
8324
+ };
8325
+ };
8326
+ // ../core/src/run/reserves.ts
8327
+ const createReserves = () => {
8328
+ const held = /* @__PURE__ */ new Map();
8329
+ const waiters = /* @__PURE__ */ new Set();
8330
+ const wake = () => {
8331
+ const woken = [...waiters];
8332
+ waiters.clear();
8333
+ for (const resolve of woken) resolve();
7880
8334
  };
7881
8335
  return {
7882
- children,
7883
- fanned: (owner?.children ?? []).filter((id) => isFanned(id)),
7884
- templates,
7885
- workersLeft: () => Math.max(0, maxWorkers - spawned.length),
7886
- delegate,
7887
- spawn
8336
+ /** All that is set aside now. */
8337
+ total: () => [...held.values()].reduce((sum, reserve) => sum + reserve.usd, 0),
8338
+ /**
8339
+ * `id` sets aside `usd` (nothing, or 0, lets it go). `waitable` is false
8340
+ * for a leader, whose children must never wait on it.
8341
+ */
8342
+ reserve: (id, usd, waitable) => {
8343
+ const before = held.get(id)?.usd ?? 0;
8344
+ if (usd === void 0 || usd <= 0) held.delete(id);
8345
+ else held.set(id, {
8346
+ usd,
8347
+ waitable
8348
+ });
8349
+ if ((usd ?? 0) < before) wake();
8350
+ },
8351
+ /** The node ended: whatever it still held is let go. */
8352
+ drop: (id) => {
8353
+ if (held.delete(id)) wake();
8354
+ },
8355
+ /**
8356
+ * Waits until a reserve shrinks. `false` at once when no other node holds
8357
+ * one worth waiting on, or when `signal` aborts.
8358
+ */
8359
+ freed: (id, signal) => {
8360
+ if (![...held].some(([other, reserve]) => other !== id && reserve.waitable) || signal.aborted) return Promise.resolve(false);
8361
+ return new Promise((resolve) => {
8362
+ const done = (freed) => () => {
8363
+ waiters.delete(onFree);
8364
+ signal.removeEventListener("abort", onAbort);
8365
+ resolve(freed);
8366
+ };
8367
+ const onFree = done(true);
8368
+ const onAbort = done(false);
8369
+ waiters.add(onFree);
8370
+ signal.addEventListener("abort", onAbort, { once: true });
8371
+ });
8372
+ }
7888
8373
  };
7889
8374
  };
7890
- // ../core/src/run/scope.ts
8375
+ // ../core/src/run/spending.ts
7891
8376
  /**
7892
- * @file A run's id and folder, and the scope templates and `when:` conditions
7893
- * read during it.
8377
+ * A result with only what this activation spent (ADR-046): a session's report
8378
+ * holds its total so far, while its tokens are this process's own. `reported`
8379
+ * holds each session's total.
7894
8380
  */
7895
- /** The run's id (given, or a new one) and its `.ork/runs/<run>` folder. */
7896
- const runPlace = (options) => {
7897
- const runId = options.runId ?? randomUUID().slice(0, 8);
8381
+ const withOwnSpend = (id, result, reported) => {
8382
+ const { sessionId, usage } = result;
8383
+ if (sessionId === void 0 || usage === void 0) return result;
8384
+ const own = {
8385
+ ...usage,
8386
+ costUsd: ownSpend(reported, `${id}/${sessionId}`, usage.costUsd)
8387
+ };
7898
8388
  return {
7899
- runId,
7900
- runDir: options.runDir ?? path.join(options.cwd, ".ork", "runs", runId)
8389
+ ...result,
8390
+ usage: own,
8391
+ output: "usage" in result.output ? {
8392
+ ...result.output,
8393
+ usage: own
8394
+ } : result.output
8395
+ };
8396
+ };
8397
+ /** A run's spending so far; a resumed run starts from what it had spent. */
8398
+ const createSpend = (budgetUsd, earlier, limits) => {
8399
+ const reported = new Map(earlier?.reported);
8400
+ let spent = earlier?.total ?? 0;
8401
+ /** What running sessions last reported, not yet in `spent`, by `node/session`. */
8402
+ const inFlight = /* @__PURE__ */ new Map();
8403
+ const committed = () => spent + [...inFlight.values()].reduce((sum, cost) => sum + cost, 0);
8404
+ /** Caps running sessions were given (ADR-077): out of what is offered. */
8405
+ const reserves = createReserves();
8406
+ const budgetLeft = () => budgetUsd === void 0 ? void 0 : budgetUsd - committed() - reserves.total();
8407
+ return {
8408
+ total: () => spent,
8409
+ /** A node sets aside its session's cap; nothing or 0 lets it go. */
8410
+ reserve: reserves.reserve,
8411
+ /** Waits until another node's reserve frees; `false` when none can. */
8412
+ freed: reserves.freed,
8413
+ /** What a session has spent in this activation, from its total so far. */
8414
+ ownOf: (id, sessionId, total) => unreported(reported, `${id}/${sessionId}`, total),
8415
+ /** A running session said what it has spent so far. */
8416
+ report: (id, sessionId, total) => {
8417
+ const key = `${id}/${sessionId}`;
8418
+ inFlight.set(key, unreported(reported, key, total));
8419
+ },
8420
+ /** The node ended: `count` has what its sessions last reported. */
8421
+ settled: (id) => {
8422
+ for (const key of inFlight.keys()) if (key.startsWith(`${id}/`)) inFlight.delete(key);
8423
+ reserves.drop(id);
8424
+ },
8425
+ /** What a session may spend: the least of the budget and any quota. */
8426
+ left: () => {
8427
+ const known = [budgetLeft(), limits?.().leftUsd].filter((left) => left !== void 0);
8428
+ return known.length === 0 ? void 0 : Math.min(...known);
8429
+ },
8430
+ budgetLeft,
8431
+ /** What the limits across runs allow now. */
8432
+ quota: () => limits?.() ?? {},
8433
+ /**
8434
+ * What stops anything new from starting: a quota first, as a budget can be
8435
+ * raised.
8436
+ */
8437
+ spentOut: () => {
8438
+ const blocked = limits?.().blocked;
8439
+ if (blocked !== void 0) return {
8440
+ status: "quota_exhausted",
8441
+ reason: blocked
8442
+ };
8443
+ return budgetUsd !== void 0 && committed() >= budgetUsd ? {
8444
+ status: "budget_exhausted",
8445
+ reason: `the run's budget of $${String(budgetUsd)} is spent`
8446
+ } : null;
8447
+ },
8448
+ /**
8449
+ * Counts a result, and gives it back with only its own spend. A replayed
8450
+ * result is as stored, and already in what the run started from.
8451
+ */
8452
+ count: (id, result, replayed) => {
8453
+ if (replayed) return result;
8454
+ const own = withOwnSpend(id, result, reported);
8455
+ spent += own.usage?.costUsd ?? 0;
8456
+ return own;
8457
+ }
7901
8458
  };
7902
8459
  };
8460
+ /** A run's spending, from what the scheduler is given. */
8461
+ const spendOf = (budgetUsd, options) => createSpend(budgetUsd, options.replay?.earlier, options.limits);
8462
+ // ../core/src/run/gather.ts
8463
+ const asList = (value) => Array.isArray(value) ? value : [];
8464
+ /** `before` then `now`, leaving out an entry already there. */
8465
+ const joined = (before, now) => {
8466
+ const seen = /* @__PURE__ */ new Set();
8467
+ const out = [];
8468
+ for (const entry of [...asList(before), ...asList(now)]) {
8469
+ const key = JSON.stringify(entry);
8470
+ if (seen.has(key)) continue;
8471
+ seen.add(key);
8472
+ out.push(entry);
8473
+ }
8474
+ return out;
8475
+ };
7903
8476
  /**
7904
- * The folders every node of a run reads from: the workflow's, the project's,
7905
- * Ork's.
8477
+ * `result` with each gathered field holding every round's entries so far.
8478
+ * `previous` is the node's output from its last round, already gathered.
7906
8479
  */
7907
- const foldersOf = (options) => ({
7908
- workflowDir: options.workflowDir ?? options.cwd,
7909
- projectRoot: options.projectRoot ?? options.cwd,
7910
- ...options.orkDir === void 0 ? {} : { orkDir: options.orkDir }
7911
- });
7912
- /** What templates and `when:` read during a run. */
7913
- const runScope = (inputs, outputs, runId, item) => {
7914
- const scope = {
7915
- inputs,
7916
- nodes: Object.fromEntries(Object.entries(outputs).map(([id, output]) => [id, { output }]))
8480
+ const joinRounds = (fields, previous, result) => {
8481
+ if (fields.length === 0 || previous === void 0) return result;
8482
+ const output = { ...result.output };
8483
+ for (const field of fields) output[field] = joined(previous[field], result.output[field]);
8484
+ return {
8485
+ ...result,
8486
+ output
7917
8487
  };
7918
- if (runId !== void 0) scope.run = { id: runId };
7919
- if (item !== void 0) {
7920
- scope.item = item.value;
7921
- scope.index = item.index;
7922
- }
7923
- return scope;
7924
8488
  };
7925
- // ../core/src/run/context.ts
8489
+ /** `result` with `node`'s `output.gather` fields joined to its earlier rounds. */
8490
+ const gatherRounds = (node, previous, result) => joinRounds(gatheredFieldsOf(node), previous, result);
7926
8491
  /**
7927
- * @file One activation's execute context: what the scheduler hands a node's
7928
- * executor. Its inputs and the outputs so far, where it works, the budget
7929
- * left, the human, its hold and its own stop signal (ADR-041), its ports on
7930
- * the bus, the session it resumes, and, for a leader, its ownership of its
7931
- * children.
8492
+ * What `activation`'s rounds reported so far, to gather onto. A spawned
8493
+ * instance gathers nothing earlier: its id (`impl-2`) is reused by a different
8494
+ * worker in the leader's next round. A delegated child that hasn't run since a
8495
+ * resume starts from what it reported before it, since its leader's recorded
8496
+ * rounds are handed back whole and never run it again.
7932
8497
  */
7933
- const nodeContext = (deps, activation, cwd, round) => {
7934
- const { options, runId } = deps;
7935
- const { graph } = options;
7936
- const { id, template } = activation;
7937
- const context = {
7938
- runId,
8498
+ const previousOf = (activation, current, replay) => {
8499
+ const { brief, template, id } = activation;
8500
+ if (brief !== void 0 && template !== void 0) return void 0;
8501
+ if (current !== void 0 || brief === void 0) return current;
8502
+ return replay?.lastOutput?.(id);
8503
+ };
8504
+ /** Gathers each activation's result onto what its node reported before. */
8505
+ const createGatherer = (outputs, replay, nodeOf) => (activation, result) => gatherRounds(nodeOf(activation), previousOf(activation, outputs[activation.id], replay), result);
8506
+ // ../core/src/run/results.ts
8507
+ /** Control nodes pass their input through; a join's input is its output. */
8508
+ const passThrough = (input) => ({
8509
+ output: input ?? {},
8510
+ ok: true
8511
+ });
8512
+ const recordRun = (entry, result) => {
8513
+ entry.runs += 1;
8514
+ entry.ok = result.ok;
8515
+ entry.output = result.output;
8516
+ entry.costUsd += result.usage?.costUsd ?? 0;
8517
+ if (result.error === void 0) delete entry.error;
8518
+ else entry.error = result.error;
8519
+ if (result.ok) delete entry.end;
8520
+ else entry.end = endOf(result);
8521
+ };
8522
+ const startedEvent = (activation, nodeType, round, agent) => {
8523
+ const event = {
8524
+ type: "node.started",
8525
+ id: activation.id,
8526
+ nodeType,
8527
+ round
8528
+ };
8529
+ if (activation.brief !== void 0) event.owner = activation.brief.from;
8530
+ if (agent !== void 0) event.agent = agent;
8531
+ const { item } = activation;
8532
+ if (item !== void 0) event.item = {
8533
+ of: item.of,
8534
+ index: item.index
8535
+ };
8536
+ return event;
8537
+ };
8538
+ const finishedEvent = (id, round, result) => {
8539
+ const event = {
8540
+ type: "node.finished",
7939
8541
  id,
7940
- workflow: graph.workflow,
7941
- inputs: options.inputs,
7942
- outputs: deps.outputs,
7943
- input: activation.input,
7944
8542
  round,
7945
- cwd,
7946
- runDir: deps.runDir,
7947
- ...foldersOf(options),
7948
- budgetLeftUsd: deps.spend.left(),
7949
- budgetLeftNow: deps.spend.budgetLeft,
7950
- reserveBudget: (usd) => {
7951
- deps.spend.reserve(id, usd, deps.nodeOf(activation)?.type !== "claude.leader");
7952
- },
7953
- budgetFreed: (signal) => deps.spend.freed(id, signal),
7954
- quota: deps.spend.quota,
7955
- ownSpend: (sessionId, total) => deps.spend.ownOf(id, sessionId, total),
7956
- human: deps.human,
7957
- hold: deps.holds.forNode(id, template),
7958
- ...deps.ports.forNode(id, template, cwd),
7959
- ...options.calls === void 0 ? {} : { calls: options.calls },
7960
- emit: (event) => {
7961
- deps.ports.owners.seen(id, event, cwd);
7962
- if (event.type === "session.finished" && event.fork !== true) {
7963
- const { sessionId, usage } = event.outcome;
7964
- deps.spend.report(id, sessionId, usage.costUsd);
7965
- }
7966
- options.onEvent({
7967
- type: "node.event",
7968
- id,
7969
- event
7970
- });
7971
- },
7972
- signal: deps.stops.signalFor(id, template)
8543
+ ok: result.ok,
8544
+ output: result.output
7973
8545
  };
7974
- const owner = ownerPort(deps, id, template);
7975
- if (owner !== void 0) context.owner = owner;
7976
- if (template !== void 0) context.template = template;
7977
- if (activation.brief !== void 0) context.brief = activation.brief;
7978
- if (activation.item !== void 0) context.item = activation.item;
7979
- const resume = options.replay?.cutOff?.(activation) ?? resumeOf(deps.nodeOf(activation), deps.sessionIds.get(id) ?? options.replay?.previousSession?.(activation));
7980
- if (resume !== void 0) context.resume = resume;
7981
- if (deps.nodeOf(activation)?.type === "claude.leader") context.ownership = createOwnership({
7982
- graph,
7983
- leader: id,
7984
- runChild: deps.runChild,
7985
- isRunning: (other) => deps.running.has(other),
7986
- checkpoint: () => options.workspace?.checkpoint(id) ?? Promise.resolve(null),
7987
- releaseClaims: () => {
7988
- const held = context.claims?.held() ?? [];
7989
- for (const resource of held) context.claims?.release(resource);
7990
- return held;
7991
- },
7992
- lend: deps.slots.release,
7993
- reclaim: deps.slots.take
7994
- });
7995
- return context;
8546
+ if (result.usage !== void 0) event.usage = result.usage;
8547
+ if (result.error !== void 0) event.error = result.error;
8548
+ event.end = endOf(result);
8549
+ return event;
7996
8550
  };
7997
- /** Where `id`'s questions go first, if its leader takes them (ADR-063). */
7998
- const ownerPort = (deps, id, template) => deps.ports.owners.portFor(id, template, {
7999
- budgetLeftUsd: deps.spend.left,
8000
- charge: (owner, sessionId, usage) => {
8001
- const own = deps.spend.count(owner, {
8002
- ok: true,
8003
- output: {},
8004
- sessionId,
8005
- usage
8006
- }, false);
8007
- deps.summary(owner).costUsd += own.usage?.costUsd ?? 0;
8551
+ const failure = (error) => ({
8552
+ output: {
8553
+ status: "fail",
8554
+ summary: error
8008
8555
  },
8009
- emit: (owner, event) => {
8010
- deps.options.onEvent({
8011
- type: "node.event",
8012
- id: owner,
8013
- event
8014
- });
8015
- }
8556
+ ok: false,
8557
+ error
8558
+ });
8559
+ /** The node's entry in the run summary, created on first use. */
8560
+ const summaryOf = (summaries, id) => {
8561
+ const existing = summaries.get(id);
8562
+ if (existing !== void 0) return existing;
8563
+ const created = {
8564
+ id,
8565
+ runs: 0,
8566
+ ok: null,
8567
+ output: null,
8568
+ costUsd: 0
8569
+ };
8570
+ summaries.set(id, created);
8571
+ return created;
8572
+ };
8573
+ /** A run's summary; a resumed run's leaders keep their forks' spend (ADR-063). */
8574
+ const createSummaries = (earlier) => {
8575
+ const summaries = /* @__PURE__ */ new Map();
8576
+ const summary = (id) => summaryOf(summaries, id);
8577
+ for (const [id, costUsd] of earlier?.forks ?? []) summary(id).costUsd += costUsd;
8578
+ return {
8579
+ summaries,
8580
+ summary
8581
+ };
8582
+ };
8583
+ /** The tighter of the workflow's budget and what a caller left it, if any. */
8584
+ const runBudget = (graph, settings) => {
8585
+ const limits = [graph.workflow.limits?.budgetUsd, settings?.budgetCap].filter((limit) => limit !== void 0);
8586
+ return limits.length === 0 ? {} : { budgetUsd: Math.min(...limits) };
8587
+ };
8588
+ /** The `run.started` event: the workflow, its entries, nodes and inputs. */
8589
+ const runStartedEvent = (graph, runId, options) => ({
8590
+ type: "run.started",
8591
+ runId,
8592
+ workflow: graph.workflow.name,
8593
+ entries: graph.entries,
8594
+ nodes: declaredNodes(graph),
8595
+ edges: graph.edges.map(({ from, to, when, maxRounds }) => ({
8596
+ from,
8597
+ to,
8598
+ ...when === void 0 ? {} : { when },
8599
+ ...maxRounds === void 0 ? {} : { maxRounds }
8600
+ })),
8601
+ channels: graph.channels,
8602
+ inputs: options.inputs,
8603
+ ...options.settings === void 0 ? {} : { settings: options.settings },
8604
+ ...runBudget(graph, options.settings),
8605
+ ...options.orkVersion === void 0 ? {} : { ork: options.orkVersion }
8016
8606
  });
8607
+ /** The unread mail of nodes that never ran; a node that ran saw its notice. */
8608
+ const unreadOf = (nodes, unread) => {
8609
+ const ran = new Set(nodes.filter((node) => node.runs > 0).map((node) => node.id));
8610
+ const left = [...unread ?? []].filter(([node]) => !ran.has(node)).map(([node, messages]) => ({
8611
+ node,
8612
+ messages
8613
+ }));
8614
+ return left.length === 0 ? {} : { unread: left };
8615
+ };
8017
8616
  /**
8018
- * The session a node that runs again continues, when it resumes (ADR-033): by
8019
- * `session: resume`, the default for a leader; a worker starts fresh unless it
8020
- * says `resume`.
8617
+ * The run's result: failed when any reason was given, unless it stopped. A
8618
+ * skipped node or a handled failure leaves it `succeeded`, and is counted.
8021
8619
  */
8022
- const resumeOf = (node, sessionId) => {
8023
- if (node === void 0 || !isClaudeNode(node)) return void 0;
8024
- if (node.type === "claude.subagent") return void 0;
8025
- return (node.session ?? (node.type === "claude.leader" ? "resume" : "fresh")) === "resume" ? sessionId : void 0;
8026
- };
8620
+ const runResult = (run) => ({
8621
+ runId: run.runId,
8622
+ status: run.stopped ?? (run.reasons.length > 0 ? "failed" : "succeeded"),
8623
+ costUsd: run.costUsd,
8624
+ nodes: run.nodes,
8625
+ reasons: run.reasons,
8626
+ worktrees: run.worktrees,
8627
+ ...run.outputs === void 0 ? {} : { outputs: run.outputs },
8628
+ skipped: run.nodes.filter((node) => node.runs === 0).length,
8629
+ failuresHandled: run.failuresHandled,
8630
+ ...unreadOf(run.nodes, run.unread)
8631
+ });
8632
+ /** A workflow's `outputs`, rendered as the run ends. */
8633
+ const renderOutputs = (outputs, scope) => outputs === void 0 ? void 0 : Object.fromEntries(Object.entries(outputs).map(([name, text]) => {
8634
+ const template = parseTemplate(text);
8635
+ return [name, template.ok ? renderTemplate(template.parts, scope) : text];
8636
+ }));
8637
+ /** Closes the run's worktrees; a failure is one more reason, not a crash. */
8638
+ const closeWorkspace = (workspace, reasons) => (workspace?.close() ?? Promise.resolve([])).catch((error) => {
8639
+ reasons.push(`cleaning up worktrees: ${String(error)}`);
8640
+ return [];
8641
+ });
8027
8642
  // ../core/src/store/nest-items.ts
8028
8643
  /** Whether a tree node is an item of a fanned node (`build.2`). */
8029
8644
  const isItem = (node) => node.template !== void 0 && /\.\d+$/u.test(node.id);
@@ -8307,6 +8922,13 @@ const runTree = (events) => {
8307
8922
  }
8308
8923
  return roots;
8309
8924
  };
8925
+ /** The nodes of a run waiting for a machine-wide slot now. */
8926
+ const slotWaitsOf = (events) => {
8927
+ const waiting = /* @__PURE__ */ new Set();
8928
+ for (const event of events) if (event.type === "slot.waiting") waiting.add(event.id);
8929
+ else if (event.type === "slot.taken" || event.type === "node.finished") waiting.delete(event.id);
8930
+ return [...waiting];
8931
+ };
8310
8932
  /** Folds a run's `ork pause` and `ork continue` commands (ADR-041). */
8311
8933
  const pausedOf = (events) => {
8312
8934
  let run = false;
@@ -8499,7 +9121,10 @@ const createStops = (run) => {
8499
9121
  own.delete(id);
8500
9122
  return stopped.delete(id) ? {
8501
9123
  ...result,
8502
- ...failure("stopped by the user")
9124
+ ...failedWith(endFor("interrupted"), {
9125
+ status: "fail",
9126
+ summary: "stopped by the user"
9127
+ })
8503
9128
  } : result;
8504
9129
  }
8505
9130
  };
@@ -8569,6 +9194,8 @@ const createClock = (options) => {
8569
9194
  else resting.delete(id);
8570
9195
  changed();
8571
9196
  },
9197
+ /** Stands the node's clock (and its owners' and the run's) still for `wait`. */
9198
+ waitFor: asking,
8572
9199
  /** The same channel, noting who waits on the user for how long. */
8573
9200
  human: (channel) => ({
8574
9201
  permit: (request) => asking(request.nodeId, () => channel.permit(request)),
@@ -8588,18 +9215,17 @@ const createClock = (options) => {
8588
9215
  };
8589
9216
  };
8590
9217
  const timedOut = (timeout, partial) => {
8591
- const error = `timed out after ${timeout}`;
8592
- return {
9218
+ const end = endFor("timed_out", { timeout });
9219
+ return endedWith({
8593
9220
  ...partial,
8594
9221
  ok: false,
8595
- error,
8596
9222
  output: {
8597
9223
  ...partial?.output,
8598
9224
  status: "fail",
8599
- summary: error,
9225
+ summary: end.message,
8600
9226
  timedOut: true
8601
9227
  }
8602
- };
9228
+ }, end);
8603
9229
  };
8604
9230
  /** Runs `start` under `timeout`, if it has one. */
8605
9231
  const withTimeout = async (bound) => {
@@ -8650,9 +9276,28 @@ const watchRunTimeout = (watched) => {
8650
9276
  cancelled.abort();
8651
9277
  };
8652
9278
  };
9279
+ // ../core/src/run/watch.ts
9280
+ /**
9281
+ * @file A `watch:` on a tool or MCP node (RFC-011): the node's look, repeated
9282
+ * until its condition holds, with no model turn. The last look is its output.
9283
+ * A failed look, such as a network error, is tried again up to `misses` in a
9284
+ * row; an answer the node didn't declare fails it at once, as waiting can't
9285
+ * mend that. The time between looks isn't work, so a pause keeps it and the
9286
+ * run's clock stands still. It records what changed, not every look.
9287
+ */
9288
+ /**
9289
+ * The ends of a failed look that the next look may mend besides a transient
9290
+ * kind: what a tool's non-zero exit or an MCP call's error ends as.
9291
+ */
9292
+ const LOOKS_AGAIN = /* @__PURE__ */ new Set([
9293
+ "command_failed",
9294
+ "auth",
9295
+ "tool_missing",
9296
+ "interrupted"
9297
+ ]);
8653
9298
  const SAID_LIMIT = 1024;
8654
9299
  /** What a look says, for telling whether it changed and showing it. */
8655
- const saidBy = (result, declared) => {
9300
+ const saidBy$1 = (result, declared) => {
8656
9301
  const { json, exitCode, stdout, text } = result.output;
8657
9302
  const value = declared !== void 0 && typeof json === "object" && json !== null ? Object.fromEntries(Object.keys(declared).map((name) => [name, Reflect.get(json, name)])) : json ?? {
8658
9303
  exitCode,
@@ -8685,7 +9330,7 @@ const leftBehind = (signal, graceMs) => {
8685
9330
  const runWatch = async (options) => {
8686
9331
  const { id, watch, declared, look, hold, signal, graceMs, onEvent } = options;
8687
9332
  const until = parseExpr(watch.until);
8688
- if (!until.ok) return failure(`watch.until: ${until.message}`);
9333
+ if (!until.ok) return failedWith(endFor("invalid_node", { reason: `watch.until: ${until.message}` }));
8689
9334
  const every = options.everyMs ?? timeoutMs(watch.every) ?? 0;
8690
9335
  const misses = watch.misses ?? 3;
8691
9336
  const limit = new AbortController();
@@ -8706,17 +9351,16 @@ const runWatch = async (options) => {
8706
9351
  };
8707
9352
  const expiredResult = () => {
8708
9353
  const error = `watched for ${watch.timeout}, and ${markValue(watch.until)} never held`;
8709
- return {
9354
+ return endedWith({
8710
9355
  ...last,
8711
9356
  ok: false,
8712
- error,
8713
9357
  output: {
8714
9358
  ...last?.output,
8715
9359
  status: "fail",
8716
9360
  summary: error,
8717
9361
  timedOut: true
8718
9362
  }
8719
- };
9363
+ }, endFor("timed_out", { timeout: watch.timeout }, error));
8720
9364
  };
8721
9365
  /** Stands still while paused, and records the hold at once. */
8722
9366
  const rest = async () => {
@@ -8761,11 +9405,14 @@ const runWatch = async (options) => {
8761
9405
  for (;;) {
8762
9406
  await hold?.wait("start", active);
8763
9407
  const result = active.aborted ? null : await lookOnce();
8764
- if (signal.aborted) return ended("stopped", failure("stopped while watching"));
9408
+ if (signal.aborted) return ended("stopped", failedWith(endFor("interrupted"), {
9409
+ status: "fail",
9410
+ summary: "stopped while watching"
9411
+ }));
8765
9412
  if (limit.signal.aborted || result === null) return ended("timed_out", expiredResult());
8766
9413
  looks += 1;
8767
9414
  last = result;
8768
- const said = saidBy(result, declared);
9415
+ const said = saidBy$1(result, declared);
8769
9416
  if (said !== lastSaid) {
8770
9417
  lastSaid = said;
8771
9418
  onEvent({
@@ -8781,7 +9428,7 @@ const runWatch = async (options) => {
8781
9428
  missed = 0;
8782
9429
  if (holds(result.output)) return ended("met", result);
8783
9430
  } else {
8784
- if (!(result.transient === true || result.output["timedOut"] === true)) return ended("failed", result);
9431
+ if (!(result.transient === true || LOOKS_AGAIN.has(result.end?.kind) || result.output["timedOut"] === true)) return ended("failed", result);
8785
9432
  missed += 1;
8786
9433
  if (missed >= misses) return ended("failed", {
8787
9434
  ...result,
@@ -8798,19 +9445,36 @@ const runWatch = async (options) => {
8798
9445
  const repeatsCall = (event) => event.type === "tool.called" || event.type === "tool.result";
8799
9446
  // ../core/src/run/execute.ts
8800
9447
  /** Runs an activation; control nodes pass their input through. */
8801
- const createExecute = (deps) => async (activation, round) => {
9448
+ const saidBy = (error) => error instanceof Error ? error.message : String(error);
9449
+ /**
9450
+ * A thrown error's end: a failed git or missing program by its text, else
9451
+ * `fallback`.
9452
+ */
9453
+ const thrown = (error, fallback) => {
9454
+ const text = saidBy(error);
9455
+ const read = endOf({
9456
+ ok: false,
9457
+ output: {},
9458
+ error: text
9459
+ });
9460
+ return failedWith(read.kind === "unknown" ? endFor(fallback, { reason: text }, text) : read);
9461
+ };
9462
+ /** Runs an activation, its end's detail scrubbed of the run's paths. */
9463
+ const createExecute = (deps) => async (activation, round) => scrubbedEnd(await executeOne(deps, activation, round), [deps.options.cwd, deps.options.projectRoot ?? ""]);
9464
+ const executeOne = async (deps, activation, round) => {
8802
9465
  const { options, contextDeps, clock, graceMs, nodeOf } = deps;
8803
9466
  const node = nodeOf(activation);
8804
- if (node === void 0) return failure(`unknown node ${activation.id}`);
9467
+ if (node === void 0) return failedWith(endFor("unknown", {}, `unknown node ${activation.id}`));
8805
9468
  if (node.type.startsWith("control.")) return passThrough(activation.input);
8806
9469
  const executor = options.executors[node.type];
8807
- if (executor === void 0) return failure(`no executor for ${node.type}`);
9470
+ if (executor === void 0) return failedWith(endFor("unknown", {}, `no executor for ${node.type}`));
8808
9471
  try {
8809
9472
  const forEach = activation.item === void 0 ? forEachOf(node) : void 0;
8810
9473
  if (forEach !== void 0) return await deps.fanOut(activation, forEach, round);
8811
9474
  const { workspace } = options;
8812
- const place = await workspace?.prepare(activation.id, activation.template) ?? { cwd: options.cwd };
8813
- const context = nodeContext(contextDeps, activation, place.cwd, round);
9475
+ const place = await (workspace?.prepare(activation.id, activation.template) ?? Promise.resolve({ cwd: options.cwd })).catch((error) => thrown(error, "invalid_node"));
9476
+ if ("ok" in place) return place;
9477
+ const context = nodeContext(contextDeps, activation, place.cwd, round, clock);
8814
9478
  const once = (signal, emit) => withTimeout({
8815
9479
  timeout: timeoutOf(node, options.graph.workflow),
8816
9480
  clock: clock.forNode(activation.id),
@@ -8842,9 +9506,10 @@ const createExecute = (deps) => async (activation, round) => {
8842
9506
  },
8843
9507
  everyMs: options.watchEveryMs
8844
9508
  });
8845
- return await workspace?.finish(activation.id, result) ?? result;
9509
+ const ended = result.ok || !context.signal.aborted ? result : endedWith(result, stoppedEnd(context.signal));
9510
+ return await workspace?.finish(activation.id, ended) ?? ended;
8846
9511
  } catch (error) {
8847
- return failure(error instanceof Error ? error.message : String(error));
9512
+ return thrown(error, "unknown");
8848
9513
  }
8849
9514
  };
8850
9515
  // ../core/src/run/bus-calls.ts
@@ -9358,7 +10023,7 @@ const failed = (id, why) => {
9358
10023
  return reason === "" ? `${id} failed` : `${id} failed: ${reason}`;
9359
10024
  };
9360
10025
  /** A program that ran says how it exited; anything else has its own reason. */
9361
- const toolLine = (id, result) => {
10026
+ const toolLine$1 = (id, result) => {
9362
10027
  const { output, ok, error } = result;
9363
10028
  const code = output["exitCode"];
9364
10029
  if (!(typeof code === "number" && code >= 0 && output["timedOut"] !== true && (ok || code !== 0))) return ok ? `${id} passed` : failed(id, error ?? "");
@@ -9368,7 +10033,7 @@ const toolLine = (id, result) => {
9368
10033
  /** Ork's own line about a result, when a rule gives no body. */
9369
10034
  const defaultRuleBody = (id, node, result) => {
9370
10035
  const { output, ok } = result;
9371
- if (node !== void 0 && isToolNode(node)) return toolLine(id, result);
10036
+ if (node !== void 0 && isToolNode(node)) return toolLine$1(id, result);
9372
10037
  const summary = output["summary"];
9373
10038
  const said = typeof summary === "string" ? summary : "";
9374
10039
  if (!ok) return failed(id, result.error ?? said);
@@ -9636,7 +10301,7 @@ const startRun = (options) => {
9636
10301
  expire: (reason) => {
9637
10302
  stop("timed_out", reason);
9638
10303
  options.calls?.stopAll();
9639
- abort.abort();
10304
+ abort.abort(runTimedOut(limits.timeout));
9640
10305
  settle();
9641
10306
  },
9642
10307
  leave: () => {
@@ -10929,7 +11594,7 @@ const mailboxInstructions = (mailbox, owner) => {
10929
11594
  const verifyInstructions = (node) => {
10930
11595
  if (!("verify" in node) || node.verify === void 0) return [];
10931
11596
  const { command } = node.verify;
10932
- return [`- A report that is not \`fail\` or \`changes_requested\` is accepted only if ${command === void 0 || command.includes("{{") ? "a check the workflow defines" : `\`${command}\``} passes when you report. If it fails, you are told why and asked to fix it, so run it yourself first if you can.`];
11597
+ return [`- A report that is not \`fail\` or \`changes_requested\` is accepted only if ${command === void 0 || command.includes("{{") ? "a check the workflow defines" : `\`${command}\``} passes when you report. If it fails, you are told why and asked to fix it, ${(command !== void 0 && node.tools?.deny?.includes(heavyRule(command))) === true ? "so run targeted checks (such as the tests you changed) first: Ork runs the full check, and this session may not." : "so run it yourself first if you can."}`];
10933
11598
  };
10934
11599
  /** Ork's own instructions for a node, kept short: they cost tokens every turn. */
10935
11600
  const harnessInstructions = (workflow, id, node, options = {}) => {
@@ -11279,6 +11944,112 @@ const explainWorkflowText = (text, file, nodeId, where) => {
11279
11944
  sources: sourcesOf(result.explanation, place, where.home ?? homedir())
11280
11945
  } : result;
11281
11946
  };
11947
+ // ../core/src/usage/quota.ts
11948
+ const WINDOWS = [
11949
+ "day",
11950
+ "week",
11951
+ "month"
11952
+ ];
11953
+ const FIELDS = {
11954
+ day: "dayUsd",
11955
+ week: "weekUsd",
11956
+ month: "monthUsd"
11957
+ };
11958
+ /** Where a window began and when the next begins, in local time. */
11959
+ const windowOf = (window, now) => {
11960
+ const [year, month, day] = [
11961
+ now.getFullYear(),
11962
+ now.getMonth(),
11963
+ now.getDate()
11964
+ ];
11965
+ if (window === "month") return {
11966
+ since: new Date(year, month, 1),
11967
+ until: new Date(year, month + 1)
11968
+ };
11969
+ if (window === "day") return {
11970
+ since: new Date(year, month, day),
11971
+ until: new Date(year, month, day + 1)
11972
+ };
11973
+ const monday = day - (now.getDay() + 6) % 7;
11974
+ return {
11975
+ since: new Date(year, month, monday),
11976
+ until: new Date(year, month, monday + 7)
11977
+ };
11978
+ };
11979
+ /** The caps that apply: the user's on everything, and the project's on itself. */
11980
+ const capsOf = (user, project, root) => WINDOWS.flatMap((window) => {
11981
+ const field = FIELDS[window];
11982
+ const everywhere = user?.[field];
11983
+ const here = project?.[field];
11984
+ const caps = [];
11985
+ if (everywhere !== void 0) caps.push({
11986
+ window,
11987
+ capUsd: everywhere
11988
+ });
11989
+ if (here !== void 0 && root !== void 0) caps.push({
11990
+ window,
11991
+ capUsd: here,
11992
+ project: root
11993
+ });
11994
+ return caps;
11995
+ });
11996
+ /** Each cap with what has been spent against it. */
11997
+ const standingOf = (store, caps, now) => caps.map((cap) => {
11998
+ const { since, until } = windowOf(cap.window, now);
11999
+ const spentUsd = store.spentSince(since.toISOString(), cap.project);
12000
+ return {
12001
+ ...cap,
12002
+ spentUsd,
12003
+ leftUsd: cap.capUsd - spentUsd,
12004
+ resetsAt: until.toISOString()
12005
+ };
12006
+ });
12007
+ /** Dollars: cents, or more digits for an amount under a dollar. */
12008
+ const usd = (amount) => `$${amount.toFixed(amount > 0 && amount < 1 ? 4 : 2)}`;
12009
+ const at = (iso) => new Date(iso).toLocaleString(void 0, {
12010
+ dateStyle: "medium",
12011
+ timeStyle: "short"
12012
+ });
12013
+ const capReason = (spent) => `${spent.project === void 0 ? "your" : "this project's"} ${spent.window} quota of ${usd(spent.capUsd)} is spent (${usd(spent.spentUsd)}); it lifts on ${at(spent.resetsAt)}, or raise it in ${spent.project === void 0 ? "~/.ork/settings.yaml" : ".ork/project.yaml"}`;
12014
+ /** What a run or a call refused at its start says first, for its caller to read. */
12015
+ const NOT_STARTED = "Not started: ";
12016
+ /** Why a plan limit stops a run. */
12017
+ const planReason = (limit) => `your Claude plan's ${limit.window.replaceAll("_", " ")} limit was reached${limit.resetsAt === void 0 ? "" : `; it resets on ${at(limit.resetsAt)}, or use ork usage --clear-plan-limits if it has lifted`}`;
12018
+ /**
12019
+ * The windows that hold the whole account. One for a single model, such as
12020
+ * `seven_day_opus`, stops only the session it refused: other models may be
12021
+ * free.
12022
+ */
12023
+ const ACCOUNT_WINDOWS = /* @__PURE__ */ new Set([
12024
+ "five_hour",
12025
+ "seven_day",
12026
+ "overage",
12027
+ "plan"
12028
+ ]);
12029
+ /** The plan limits in force, the one that lifts last first. */
12030
+ const refusing = (limits, now) => limits.filter((limit) => limit.status === "rejected" && ACCOUNT_WINDOWS.has(limit.window) && limit.resetsAt !== void 0 && Date.parse(limit.resetsAt) > now.getTime()).toSorted((a, b) => String(b.resetsAt).localeCompare(String(a.resetsAt)));
12031
+ /** How long a report with no reset time stays worth showing. */
12032
+ const SHOWN_MS = 864e5;
12033
+ /** What the caps and the plan allow a run now. */
12034
+ const limitsNow = (store, caps, now) => {
12035
+ const standing = standingOf(store, caps, now);
12036
+ const [plan] = refusing(store.planLimits(), now);
12037
+ const spent = standing.find((cap) => cap.leftUsd <= 0);
12038
+ const blocked = plan === void 0 ? spent === void 0 ? void 0 : capReason(spent) : planReason(plan);
12039
+ return {
12040
+ ...standing.length === 0 ? {} : { leftUsd: Math.min(...standing.map((cap) => cap.leftUsd)) },
12041
+ ...blocked === void 0 ? {} : { blocked }
12042
+ };
12043
+ };
12044
+ /** What `ork usage` and the panel show. */
12045
+ const limitsReport = (store, { caps, problems }, now) => ({
12046
+ problems,
12047
+ caps: standingOf(store, caps, now),
12048
+ plan: store.planLimits().filter((limit) => {
12049
+ if (limit.status === "allowed") return false;
12050
+ return (limit.resetsAt === void 0 ? Date.parse(limit.at) + SHOWN_MS : Date.parse(limit.resetsAt)) > now.getTime();
12051
+ })
12052
+ });
11282
12053
  // ../core/src/remote/follow.ts
11283
12054
  /**
11284
12055
  * @file Following a run's events from another process (`ork logs -f`, the local
@@ -11362,31 +12133,58 @@ const noWait = (id) => ({
11362
12133
  run: id
11363
12134
  }
11364
12135
  });
11365
- /** A node's report as a call's result; a failing one says why. */
11366
- const reportResult = (id, node, output, ok, usage) => {
11367
- const summary = output["summary"];
11368
- return {
11369
- ok,
11370
- output: {
11371
- ...output,
11372
- run: id
11373
- },
11374
- usage,
11375
- ...ok ? {} : { error: typeof summary === "string" ? summary : `${node} reported failure.` }
11376
- };
12136
+ /**
12137
+ * A call that could not start its run: refused by a limit across runs
12138
+ * (RFC-007), or something the calling node asks for that isn't there.
12139
+ */
12140
+ const startFailed = (error) => failedWith(error.includes("Not started: ") ? endFor("plan_limit", {}, error) : endFor("invalid_node", { reason: error }), {
12141
+ status: "fail",
12142
+ summary: error
12143
+ });
12144
+ /** How a run that ended `status` ended, when its nodes don't say. */
12145
+ const STATUS_KINDS = {
12146
+ succeeded: "done",
12147
+ failed: "unknown",
12148
+ budget_exhausted: "budget_limit",
12149
+ quota_exhausted: "plan_limit",
12150
+ timed_out: "timed_out",
12151
+ interrupted: "interrupted"
12152
+ };
12153
+ /**
12154
+ * How a called run ended, carried up to the node that called it: a limit the
12155
+ * run stopped at, or else how its first failed node ended.
12156
+ */
12157
+ const runEnd = (result, detail) => {
12158
+ const kind = STATUS_KINDS[result.status];
12159
+ const node = result.nodes.find((entry) => entry.ok === false);
12160
+ return kind === "unknown" && node !== void 0 ? endOf({
12161
+ ok: false,
12162
+ output: node.output ?? {},
12163
+ ...node.error === void 0 ? {} : { error: node.error },
12164
+ ...node.end === void 0 ? {} : { end: node.end }
12165
+ }) : endFor(kind, {}, detail);
11377
12166
  };
12167
+ /** A node's report as a call's result; a failing one says why. */
12168
+ const reportResult = (id, output, ok, usage, end = endFor(ok ? "done" : "reported_failure")) => endedWith({
12169
+ ok,
12170
+ output: {
12171
+ ...output,
12172
+ run: id
12173
+ },
12174
+ usage
12175
+ }, end);
11378
12176
  /**
11379
12177
  * What the node last reported, for a caller coming back to the run: a resumed
11380
12178
  * run hands its kept results back with no new events, so none would come.
11381
12179
  */
11382
12180
  const storedReport = (watch, id, node) => {
11383
12181
  const found = watch.store.events(id).flatMap(({ event }) => event.type === "node.finished" && event.id === node && event.ok ? [event.output] : []).at(-1);
11384
- return found === void 0 ? void 0 : reportResult(id, node, found, true, usageOf(watch.newCost(id)));
12182
+ return found === void 0 ? void 0 : reportResult(id, found, true, usageOf(watch.newCost(id)));
11385
12183
  };
11386
12184
  const resultOf = (id, result, cost) => {
11387
12185
  const ok = result.status === "succeeded";
11388
12186
  const summary = ok ? `Run ${id} succeeded.` : `Run ${id} ended ${result.status}: ${result.reasons.join("; ")}`;
11389
- return {
12187
+ return endedWith({
11390
12188
  ok,
11391
12189
  output: {
11392
12190
  status: ok ? "pass" : "fail",
@@ -11395,9 +12193,8 @@ const resultOf = (id, result, cost) => {
11395
12193
  runStatus: result.status,
11396
12194
  outputs: result.outputs ?? {}
11397
12195
  },
11398
- usage: usageOf(cost),
11399
- ...ok ? {} : { error: summary }
11400
- };
12196
+ usage: usageOf(cost)
12197
+ }, ok ? endFor("done") : runEnd(result, summary));
11401
12198
  };
11402
12199
  /** The first thing `pick` finds in the run's events after `after`, if any. */
11403
12200
  const followRun = async ({ store, home }, id, after, signal, pick) => {
@@ -11420,9 +12217,12 @@ const waitForRun = async (watch, id, after, signal) => {
11420
12217
  const cost = watch.newCost(id);
11421
12218
  if (result !== void 0) return resultOf(id, result, cost);
11422
12219
  const [run] = watch.store.lookup(id);
11423
- const status = run === void 0 ? "gone" : liveStatus(run);
12220
+ const said = `Run ${id} ended without a result (${run === void 0 ? "gone" : liveStatus(run)}).`;
11424
12221
  return {
11425
- ...failure(`Run ${id} ended without a result (${status}).`),
12222
+ ...failedWith(endFor("unknown", {}, said), {
12223
+ status: "fail",
12224
+ summary: said
12225
+ }),
11426
12226
  usage: usageOf(cost)
11427
12227
  };
11428
12228
  };
@@ -11431,20 +12231,34 @@ const waitForNode = async (watch, id, node, after, signal) => {
11431
12231
  const seen = await followRun(watch, id, after, signal, (event) => {
11432
12232
  if (event.type === "node.finished" && event.id === node) return {
11433
12233
  ok: event.ok,
11434
- output: event.output
12234
+ output: event.output,
12235
+ end: endOf(event)
11435
12236
  };
11436
12237
  return event.type === "run.finished" ? { ended: event.result } : void 0;
11437
12238
  });
11438
12239
  const usage = usageOf(watch.newCost(id));
11439
- if (seen === void 0) return {
11440
- ...failure(`Run ${id} ended before ${node} reported.`),
11441
- usage
11442
- };
11443
- if ("ended" in seen) return {
11444
- ...failure(`${node} of run ${id} did not report: the run ended ${seen.ended.status}.`),
11445
- usage
11446
- };
11447
- return reportResult(id, node, seen.output, seen.ok, usage);
12240
+ if (seen === void 0) {
12241
+ const said = `Run ${id} ended before ${node} reported.`;
12242
+ return {
12243
+ ...failedWith(endFor("unknown", {}, said), {
12244
+ status: "fail",
12245
+ summary: said
12246
+ }),
12247
+ usage
12248
+ };
12249
+ }
12250
+ if ("ended" in seen) {
12251
+ const said = `${node} of run ${id} did not report: the run ended ${seen.ended.status}.`;
12252
+ const end = seen.ended.status === "succeeded" ? endFor("no_report", {}, said) : runEnd(seen.ended, said);
12253
+ return {
12254
+ ...failedWith(end, {
12255
+ status: "fail",
12256
+ summary: said
12257
+ }),
12258
+ usage
12259
+ };
12260
+ }
12261
+ return reportResult(id, seen.output, seen.ok, usage, seen.end);
11448
12262
  };
11449
12263
  // ../core/src/remote/detach.ts
11450
12264
  /**
@@ -11976,18 +12790,18 @@ const usageRowsOf = (run, events) => {
11976
12790
  }
11977
12791
  return rows;
11978
12792
  };
11979
- const SOURCE$1 = `SELECT seq, at, data FROM events
12793
+ const SOURCE$2 = `SELECT seq, at, data FROM events
11980
12794
  WHERE run_id = ? AND (
11981
12795
  type IN ('run.started', 'node.started', 'node.finished')
11982
12796
  OR (type = 'node.event'
11983
12797
  AND json_extract(data, '$.event.type') IN ('session.started', 'session.finished'))
11984
12798
  ) ORDER BY seq`;
11985
- const INSERT = `INSERT OR IGNORE INTO usage (
12799
+ const INSERT$1 = `INSERT OR IGNORE INTO usage (
11986
12800
  run_id, seq, at, project, workflow, node, template, model, driver, kind,
11987
12801
  round, failed, cost_usd, input_tokens, output_tokens, cache_read_tokens,
11988
12802
  cache_write_tokens, turns, duration_ms
11989
12803
  ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`;
11990
- const rowsFromEvents = (db, run) => usageRowsOf(run, db.prepare(SOURCE$1).all(run.id).map((r) => ({
12804
+ const rowsFromEvents = (db, run) => usageRowsOf(run, db.prepare(SOURCE$2).all(run.id).map((r) => ({
11991
12805
  seq: Number(r["seq"]),
11992
12806
  at: text(r, "at"),
11993
12807
  event: eventOf(text(r, "data"))
@@ -11997,7 +12811,7 @@ const writeRow = (insert, row) => {
11997
12811
  };
11998
12812
  /** Fills the ledger from the runs recorded before it existed. */
11999
12813
  const backfillUsage = (db) => {
12000
- const insert = db.prepare(INSERT);
12814
+ const insert = db.prepare(INSERT$1);
12001
12815
  for (const run of db.prepare("SELECT id, project, workflow FROM runs").all()) {
12002
12816
  const rows = rowsFromEvents(db, {
12003
12817
  id: text(run, "id"),
@@ -12059,7 +12873,7 @@ const alive = (row) => {
12059
12873
  };
12060
12874
  /** The ledger's queries; `recordUsage` is the store's own, called on append. */
12061
12875
  const usageQueries = (db) => {
12062
- const insert = db.prepare(INSERT);
12876
+ const insert = db.prepare(INSERT$1);
12063
12877
  const spentAll = db.prepare("SELECT COALESCE(SUM(cost_usd), 0) AS spent FROM usage WHERE at >= ?");
12064
12878
  const spentHere = db.prepare("SELECT COALESCE(SUM(cost_usd), 0) AS spent FROM usage WHERE at >= ? AND project = ?");
12065
12879
  const runById = db.prepare("SELECT id, project, workflow FROM runs WHERE id = ?");
@@ -12384,21 +13198,26 @@ const resumeNode = async (talk, run, request, node, signal) => {
12384
13198
  const refused = await talk.resumeSession(run, node, body, signal);
12385
13199
  if (refused !== null) return failure(refused);
12386
13200
  const finished = store.events(run.id, from).flatMap(({ event }) => event.type === "node.event" && event.event.type === "session.finished" && event.id === node ? [event.event.outcome] : []).at(-1);
12387
- if (finished?.report === null || finished === void 0) return failure(`${node} ended without a report when resumed.`);
13201
+ if (finished?.report === null || finished === void 0) {
13202
+ const said = `${node} ended without a report when resumed.`;
13203
+ return failedWith(endFor("no_report", {}, said), {
13204
+ status: "fail",
13205
+ summary: said
13206
+ });
13207
+ }
12388
13208
  const ok = finished.report.status !== "fail";
12389
13209
  const own = usageRowsOf(run, store.events(run.id)).filter((row) => row.seq > from && row.node === node).reduce((sum, row) => sum + row.costUsd, 0);
12390
- return {
13210
+ return endedWith({
12391
13211
  ok,
12392
13212
  output: {
12393
13213
  ...finished.report,
12394
13214
  run: run.id
12395
13215
  },
12396
- ...ok ? {} : { error: finished.report.summary },
12397
13216
  usage: {
12398
13217
  ...finished.usage,
12399
13218
  costUsd: own + talk.newCost(run.id)
12400
13219
  }
12401
- };
13220
+ }, endFor(ok ? "done" : "reported_failure"));
12402
13221
  };
12403
13222
  /** A later activation: as a message, or by resuming a session that has ended. */
12404
13223
  const continueConversation = async (talk, id, request, signal) => {
@@ -12417,7 +13236,13 @@ const continueConversation = async (talk, id, request, signal) => {
12417
13236
  if (isLive()) await followRun(talk, id, lastSeq(store, id), signal, finishedRun);
12418
13237
  const [run] = store.lookup(id);
12419
13238
  if (run === void 0) return failure(`Run ${id} is gone.`);
12420
- if (signal.aborted) return failure(`Stopped while run ${id} was finishing.`);
13239
+ if (signal.aborted) {
13240
+ const said = `Stopped while run ${id} was finishing.`;
13241
+ return failedWith(endFor("interrupted", {}, said), {
13242
+ status: "fail",
13243
+ summary: said
13244
+ });
13245
+ }
12421
13246
  return resumeNode(talk, run, request, node, signal);
12422
13247
  };
12423
13248
  // ../core/src/remote/calls.ts
@@ -12513,7 +13338,7 @@ const createCalls = (options) => {
12513
13338
  };
12514
13339
  const converse = async (request, signal) => {
12515
13340
  const unadopted = await adoptConversation(request, signal);
12516
- if (unadopted !== null) return failure(unadopted);
13341
+ if (unadopted !== null) return startFailed(unadopted);
12517
13342
  const known = conversations.get(request.callId);
12518
13343
  if (known !== void 0) {
12519
13344
  const done = stopOnAbort(known, signal);
@@ -12525,7 +13350,7 @@ const createCalls = (options) => {
12525
13350
  }
12526
13351
  const begun = await begin(request);
12527
13352
  adopt.delete(request.callId);
12528
- if (!begun.ok) return failure(begun.error);
13353
+ if (!begun.ok) return startFailed(begun.error);
12529
13354
  const { id, after, fresh } = begun;
12530
13355
  conversations.set(request.callId, id);
12531
13356
  const done = stopOnAbort(id, signal);
@@ -12534,7 +13359,7 @@ const createCalls = (options) => {
12534
13359
  const refused = await linkChannels(talk, id, request, signal);
12535
13360
  if (refused !== null) {
12536
13361
  stop(id);
12537
- return failure(refused);
13362
+ return startFailed(refused);
12538
13363
  }
12539
13364
  }
12540
13365
  if (fresh) tellInput(talk, id, request);
@@ -12550,7 +13375,7 @@ const createCalls = (options) => {
12550
13375
  };
12551
13376
  const callOnce = async (request, signal) => {
12552
13377
  const begun = await begin(request);
12553
- if (!begun.ok) return failure(begun.error);
13378
+ if (!begun.ok) return startFailed(begun.error);
12554
13379
  const { id, after } = begun;
12555
13380
  const done = stopOnAbort(id, signal);
12556
13381
  try {
@@ -13510,6 +14335,82 @@ const projectQueries = (db) => {
13510
14335
  }
13511
14336
  };
13512
14337
  };
14338
+ // ../core/src/store/tool-cpu.ts
14339
+ const SOURCE$1 = `SELECT seq, at, data FROM events
14340
+ WHERE run_id = ? AND (
14341
+ type = 'run.started'
14342
+ OR (type = 'node.event' AND json_extract(data, '$.event.type') = 'tool.result'
14343
+ AND json_extract(data, '$.event.cpu') IS NOT NULL)
14344
+ ) ORDER BY seq`;
14345
+ const INSERT = `INSERT OR IGNORE INTO tool_cpu (
14346
+ run_id, seq, at, project, workflow, node, template, user_ms, system_ms
14347
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`;
14348
+ /** A run's tool commands' rows, from its events. */
14349
+ const rowsOf = (db, run) => {
14350
+ const declared = /* @__PURE__ */ new Set();
14351
+ const rows = [];
14352
+ for (const r of db.prepare(SOURCE$1).all(run.id)) {
14353
+ const event = eventOf(text(r, "data"));
14354
+ if (event.type === "run.started") {
14355
+ for (const node of event.nodes) declared.add(node.id);
14356
+ continue;
14357
+ }
14358
+ if (event.type !== "node.event" || event.event.type !== "tool.result") continue;
14359
+ const { cpu } = event.event;
14360
+ if (cpu === void 0) continue;
14361
+ const template = declared.has(event.id) ? void 0 : templateOf(event.id, declared);
14362
+ rows.push({
14363
+ runId: run.id,
14364
+ seq: Number(r["seq"]),
14365
+ at: text(r, "at"),
14366
+ project: run.project,
14367
+ workflow: run.workflow,
14368
+ node: event.id,
14369
+ ...template === void 0 ? {} : { template },
14370
+ userMs: cpu.userMs,
14371
+ systemMs: cpu.systemMs
14372
+ });
14373
+ }
14374
+ return rows;
14375
+ };
14376
+ const write = (db, rows) => {
14377
+ const insert = db.prepare(INSERT);
14378
+ for (const row of rows) insert.run(row.runId, row.seq, row.at, row.project, row.workflow, row.node, row.template ?? null, row.userMs, row.systemMs);
14379
+ };
14380
+ const runsOf = (db, sql, ...values) => db.prepare(sql).all(...values).map((run) => ({
14381
+ id: text(run, "id"),
14382
+ project: text(run, "project"),
14383
+ workflow: text(run, "workflow")
14384
+ }));
14385
+ /** Fills the table from the runs recorded before it existed. */
14386
+ const backfillToolCpu = (db) => {
14387
+ for (const run of runsOf(db, "SELECT id, project, workflow FROM runs")) write(db, rowsOf(db, run));
14388
+ };
14389
+ const toolCpuQueries = (db) => ({
14390
+ /** Writes a run's rows; the store's own, on each timed `tool.result`. */
14391
+ recordToolCpu: (runId) => {
14392
+ const [run] = runsOf(db, "SELECT id, project, workflow FROM runs WHERE id = ?", runId);
14393
+ if (run !== void 0) write(db, rowsOf(db, run));
14394
+ },
14395
+ toolCpu: (filter = {}) => {
14396
+ const { sql, values } = whereOf(filter);
14397
+ return db.prepare(`SELECT * FROM tool_cpu ${sql} ORDER BY at, run_id, seq`).all(...values).map((r) => {
14398
+ const row = {
14399
+ runId: text(r, "run_id"),
14400
+ seq: Number(r["seq"]),
14401
+ at: text(r, "at"),
14402
+ project: text(r, "project"),
14403
+ workflow: text(r, "workflow"),
14404
+ node: text(r, "node"),
14405
+ userMs: Number(r["user_ms"]),
14406
+ systemMs: Number(r["system_ms"])
14407
+ };
14408
+ const template = text(r, "template");
14409
+ if (template !== "") row.template = template;
14410
+ return row;
14411
+ });
14412
+ }
14413
+ });
13513
14414
  // ../core/src/store/triggers.ts
13514
14415
  const recordOf$1 = (row) => {
13515
14416
  const acceptedHash = text(row, "accepted_hash");
@@ -13587,8 +14488,8 @@ const triggerQueries = (db) => {
13587
14488
  };
13588
14489
  // ../core/src/store/queries.ts
13589
14490
  /**
13590
- * Every query set on `db`; `insertFeedback` and `recordUsage` are the store's
13591
- * own.
14491
+ * Every query set on `db`; `insertFeedback`, `recordUsage` and `recordToolCpu`
14492
+ * are the store's own.
13592
14493
  */
13593
14494
  const storeQueries = (db) => ({
13594
14495
  ...decisionQueries(db),
@@ -13596,6 +14497,7 @@ const storeQueries = (db) => ({
13596
14497
  ...feedbackQueries(db),
13597
14498
  ...projectQueries(db),
13598
14499
  ...usageQueries(db),
14500
+ ...toolCpuQueries(db),
13599
14501
  ...planLimitQueries(db),
13600
14502
  ...triggerQueries(db),
13601
14503
  ...draftQueries(db)
@@ -13799,7 +14701,23 @@ const MIGRATIONS = [
13799
14701
  ALTER TABLE runs ADD COLUMN failures_handled INTEGER;`,
13800
14702
  `ALTER TABLE decisions ADD COLUMN note TEXT;`,
13801
14703
  `ALTER TABLE decisions ADD COLUMN owner TEXT;`,
13802
- `ALTER TABLE decisions ADD COLUMN document TEXT;`
14704
+ `ALTER TABLE decisions ADD COLUMN document TEXT;`,
14705
+ (db) => {
14706
+ db.exec(`CREATE TABLE tool_cpu (
14707
+ run_id TEXT NOT NULL,
14708
+ seq INTEGER NOT NULL,
14709
+ at TEXT NOT NULL,
14710
+ project TEXT NOT NULL,
14711
+ workflow TEXT NOT NULL,
14712
+ node TEXT NOT NULL,
14713
+ template TEXT,
14714
+ user_ms INTEGER NOT NULL,
14715
+ system_ms INTEGER NOT NULL,
14716
+ PRIMARY KEY (run_id, seq)
14717
+ ) WITHOUT ROWID;
14718
+ CREATE INDEX tool_cpu_by_time ON tool_cpu (at);`);
14719
+ backfillToolCpu(db);
14720
+ }
13803
14721
  ];
13804
14722
  const versionOf = (db) => Number(db.prepare("PRAGMA user_version").get()?.["user_version"] ?? 0);
13805
14723
  /**
@@ -13925,8 +14843,9 @@ const openStore = (file = storePath(), options = {}) => {
13925
14843
  const allRuns = db.prepare("SELECT * FROM runs ORDER BY started_at DESC, rowid DESC LIMIT ?");
13926
14844
  const projectRuns = db.prepare("SELECT * FROM runs WHERE project = ? ORDER BY started_at DESC, rowid DESC LIMIT ?");
13927
14845
  const eventsAfter = db.prepare("SELECT seq, at, data FROM events WHERE run_id = ? AND seq > ? ORDER BY seq");
14846
+ const controlEvents = db.prepare("SELECT seq, at, data FROM events WHERE run_id = ? AND type IN ('run.command', 'run.started', 'run.finished') ORDER BY seq");
13928
14847
  const runNodes = db.prepare("SELECT node FROM events WHERE run_id = ? AND node IS NOT NULL GROUP BY node ORDER BY MIN(seq)");
13929
- const { insertFeedback, recordUsage, recordPlanLimit, ...queries } = storeQueries(db);
14848
+ const { insertFeedback, recordUsage, recordToolCpu, recordPlanLimit, ...queries } = storeQueries(db);
13930
14849
  return {
13931
14850
  ...queries,
13932
14851
  begin: (run) => {
@@ -13953,6 +14872,7 @@ const openStore = (file = storePath(), options = {}) => {
13953
14872
  insertEvent.run(runId, seq, at, event.type, nodeOf(event), JSON.stringify(event, maskReplacer));
13954
14873
  if (event.type === "node.finished" && event.usage !== void 0) addCost.run(event.usage.costUsd, runId);
13955
14874
  if (event.type === "node.event" && event.event.type === "session.finished") recordUsage(runId);
14875
+ if (event.type === "node.event" && event.event.type === "tool.result" && event.event.cpu !== void 0) recordToolCpu(runId);
13956
14876
  if (event.type === "node.event" && event.event.type === "plan.limit") recordPlanLimit(event.event, at);
13957
14877
  if (event.type === "feedback.recorded") insertFeedback(runId, seq, at, event.node, event.feedback);
13958
14878
  if (event.type === "run.finished") {
@@ -13981,12 +14901,95 @@ const openStore = (file = storePath(), options = {}) => {
13981
14901
  at: text(r, "at"),
13982
14902
  event: eventOf(text(r, "data"))
13983
14903
  })),
14904
+ controlEvents: (runId) => controlEvents.all(runId).map((r) => ({
14905
+ runId,
14906
+ seq: Number(r["seq"]),
14907
+ at: text(r, "at"),
14908
+ event: eventOf(text(r, "data"))
14909
+ })),
13984
14910
  nodes: (runId) => runNodes.all(runId).map((r) => text(r, "node")),
13985
14911
  close: () => {
13986
14912
  db.close();
13987
14913
  }
13988
14914
  };
13989
14915
  };
14916
+ // ../core/src/store/activity.ts
14917
+ /**
14918
+ * @file What each node of a run is doing, and when it started and finished,
14919
+ * folded from the stored events so nothing new has to be recorded. The tree
14920
+ * (view.ts) folds the events alone; this needs when they were written, and
14921
+ * keeps a screen's one-line answer to "what is it doing" in one place.
14922
+ */
14923
+ const LONGEST_LINE = 120;
14924
+ /**
14925
+ * The fields of a tool's input that say what it's working on, best first. Only
14926
+ * these are shown, so what else a tool was given stays in the logs.
14927
+ */
14928
+ const TARGETS = [
14929
+ "file_path",
14930
+ "notebook_path",
14931
+ "path",
14932
+ "url",
14933
+ "pattern",
14934
+ "query",
14935
+ "command"
14936
+ ];
14937
+ const oneLine = (text) => {
14938
+ const line = text.replaceAll(/\s+/gu, " ").trim();
14939
+ return line.length > LONGEST_LINE ? `${line.slice(0, 119)}…` : line;
14940
+ };
14941
+ const toolLine = ({ name, input }) => {
14942
+ const target = TARGETS.map((field) => input[field]).find((value) => typeof value === "string" && value.trim() !== "");
14943
+ if (typeof target !== "string") return name;
14944
+ return oneLine(target.startsWith(`${name} `) ? target : `${name} ${target}`);
14945
+ };
14946
+ /** What a finished node said, or why it failed. */
14947
+ const finishedNow = (ok, error, summary) => {
14948
+ if (!ok) return error === void 0 ? void 0 : oneLine(error);
14949
+ return typeof summary === "string" && summary.trim() !== "" ? oneLine(summary) : void 0;
14950
+ };
14951
+ /** Each node's activity, by id; a node that never started has none. */
14952
+ const runActivity = (stored) => {
14953
+ const nodes = /* @__PURE__ */ new Map();
14954
+ const of = (id) => {
14955
+ const found = nodes.get(id);
14956
+ if (found !== void 0) return found;
14957
+ const fresh = {};
14958
+ nodes.set(id, fresh);
14959
+ return fresh;
14960
+ };
14961
+ for (const { at, event } of stored) if (event.type === "node.started") {
14962
+ const node = of(event.id);
14963
+ node.startedAt ??= at;
14964
+ delete node.finishedAt;
14965
+ delete node.now;
14966
+ } else if (event.type === "node.event") {
14967
+ if (event.event.type === "tool.called") of(event.id).now = toolLine(event.event);
14968
+ } else if (event.type === "slot.waiting") of(event.id).now = oneLine(slotWaitLine(event.holders));
14969
+ else if (event.type === "slot.taken") delete of(event.id).now;
14970
+ else if (event.type === "decision.opened") of(event.node).now = oneLine(`Asked: ${event.question}`);
14971
+ else if (event.type === "decision.answered") of(event.node).now = oneLine(`Answered: ${event.answer}`);
14972
+ else if (event.type === "decision.withdrawn") delete of(event.node).now;
14973
+ else if (event.type === "node.finished") {
14974
+ const node = of(event.id);
14975
+ node.finishedAt = at;
14976
+ const now = finishedNow(event.ok, event.error, event.output["summary"]);
14977
+ if (now === void 0) delete node.now;
14978
+ else node.now = now;
14979
+ }
14980
+ return nodes;
14981
+ };
14982
+ /** The ownership tree, with what each node is doing and when it ran. */
14983
+ const activeTree = (stored) => {
14984
+ const activity = runActivity(stored);
14985
+ const dress = (node) => {
14986
+ Object.assign(node, activity.get(node.id));
14987
+ for (const child of node.children) dress(child);
14988
+ };
14989
+ const roots = runTree(stored.map(({ event }) => event));
14990
+ for (const root of roots) dress(root);
14991
+ return roots;
14992
+ };
13990
14993
  // ../core/src/triggers/check.ts
13991
14994
  /**
13992
14995
  * @file Checking `triggers.yaml` against the project's workflows (RFC-011):
@@ -14653,112 +15656,6 @@ const planUpgrade = (options) => {
14653
15656
  const RUNS_LOOKED_AT = 500;
14654
15657
  /** The ids of runs still running anywhere on this machine, newest first. */
14655
15658
  const runningRunIds = (store) => store.runs({ limit: RUNS_LOOKED_AT }).filter((run) => liveStatus(run) === "running").map((run) => run.id);
14656
- // ../core/src/usage/quota.ts
14657
- const WINDOWS = [
14658
- "day",
14659
- "week",
14660
- "month"
14661
- ];
14662
- const FIELDS = {
14663
- day: "dayUsd",
14664
- week: "weekUsd",
14665
- month: "monthUsd"
14666
- };
14667
- /** Where a window began and when the next begins, in local time. */
14668
- const windowOf = (window, now) => {
14669
- const [year, month, day] = [
14670
- now.getFullYear(),
14671
- now.getMonth(),
14672
- now.getDate()
14673
- ];
14674
- if (window === "month") return {
14675
- since: new Date(year, month, 1),
14676
- until: new Date(year, month + 1)
14677
- };
14678
- if (window === "day") return {
14679
- since: new Date(year, month, day),
14680
- until: new Date(year, month, day + 1)
14681
- };
14682
- const monday = day - (now.getDay() + 6) % 7;
14683
- return {
14684
- since: new Date(year, month, monday),
14685
- until: new Date(year, month, monday + 7)
14686
- };
14687
- };
14688
- /** The caps that apply: the user's on everything, and the project's on itself. */
14689
- const capsOf = (user, project, root) => WINDOWS.flatMap((window) => {
14690
- const field = FIELDS[window];
14691
- const everywhere = user?.[field];
14692
- const here = project?.[field];
14693
- const caps = [];
14694
- if (everywhere !== void 0) caps.push({
14695
- window,
14696
- capUsd: everywhere
14697
- });
14698
- if (here !== void 0 && root !== void 0) caps.push({
14699
- window,
14700
- capUsd: here,
14701
- project: root
14702
- });
14703
- return caps;
14704
- });
14705
- /** Each cap with what has been spent against it. */
14706
- const standingOf = (store, caps, now) => caps.map((cap) => {
14707
- const { since, until } = windowOf(cap.window, now);
14708
- const spentUsd = store.spentSince(since.toISOString(), cap.project);
14709
- return {
14710
- ...cap,
14711
- spentUsd,
14712
- leftUsd: cap.capUsd - spentUsd,
14713
- resetsAt: until.toISOString()
14714
- };
14715
- });
14716
- /** Dollars: cents, or more digits for an amount under a dollar. */
14717
- const usd = (amount) => `$${amount.toFixed(amount > 0 && amount < 1 ? 4 : 2)}`;
14718
- const at = (iso) => new Date(iso).toLocaleString(void 0, {
14719
- dateStyle: "medium",
14720
- timeStyle: "short"
14721
- });
14722
- const capReason = (spent) => `${spent.project === void 0 ? "your" : "this project's"} ${spent.window} quota of ${usd(spent.capUsd)} is spent (${usd(spent.spentUsd)}); it lifts on ${at(spent.resetsAt)}, or raise it in ${spent.project === void 0 ? "~/.ork/settings.yaml" : ".ork/project.yaml"}`;
14723
- /** What a run or a call refused at its start says first, for its caller to read. */
14724
- const NOT_STARTED = "Not started: ";
14725
- /** Why a plan limit stops a run. */
14726
- const planReason = (limit) => `your Claude plan's ${limit.window.replaceAll("_", " ")} limit was reached${limit.resetsAt === void 0 ? "" : `; it resets on ${at(limit.resetsAt)}, or use ork usage --clear-plan-limits if it has lifted`}`;
14727
- /**
14728
- * The windows that hold the whole account. One for a single model, such as
14729
- * `seven_day_opus`, stops only the session it refused: other models may be
14730
- * free.
14731
- */
14732
- const ACCOUNT_WINDOWS = /* @__PURE__ */ new Set([
14733
- "five_hour",
14734
- "seven_day",
14735
- "overage",
14736
- "plan"
14737
- ]);
14738
- /** The plan limits in force, the one that lifts last first. */
14739
- const refusing = (limits, now) => limits.filter((limit) => limit.status === "rejected" && ACCOUNT_WINDOWS.has(limit.window) && limit.resetsAt !== void 0 && Date.parse(limit.resetsAt) > now.getTime()).toSorted((a, b) => String(b.resetsAt).localeCompare(String(a.resetsAt)));
14740
- /** How long a report with no reset time stays worth showing. */
14741
- const SHOWN_MS = 864e5;
14742
- /** What the caps and the plan allow a run now. */
14743
- const limitsNow = (store, caps, now) => {
14744
- const standing = standingOf(store, caps, now);
14745
- const [plan] = refusing(store.planLimits(), now);
14746
- const spent = standing.find((cap) => cap.leftUsd <= 0);
14747
- const blocked = plan === void 0 ? spent === void 0 ? void 0 : capReason(spent) : planReason(plan);
14748
- return {
14749
- ...standing.length === 0 ? {} : { leftUsd: Math.min(...standing.map((cap) => cap.leftUsd)) },
14750
- ...blocked === void 0 ? {} : { blocked }
14751
- };
14752
- };
14753
- /** What `ork usage` and the panel show. */
14754
- const limitsReport = (store, { caps, problems }, now) => ({
14755
- problems,
14756
- caps: standingOf(store, caps, now),
14757
- plan: store.planLimits().filter((limit) => {
14758
- if (limit.status === "allowed") return false;
14759
- return (limit.resetsAt === void 0 ? Date.parse(limit.at) + SHOWN_MS : Date.parse(limit.resetsAt)) > now.getTime();
14760
- })
14761
- });
14762
15659
  // ../core/src/usage/caps.ts
14763
15660
  /**
14764
15661
  * @file Which limits across runs apply where (RFC-007): the user's settings and
@@ -15113,6 +16010,11 @@ const commitWork = async (worktree, never, message) => {
15113
16010
  };
15114
16011
  }
15115
16012
  };
16013
+ /** A node's result once its work could not be committed: a failed `git commit`. */
16014
+ const uncommitted = (result, error) => endedWith({
16015
+ ...result,
16016
+ ok: false
16017
+ }, endFor("command_failed", { program: "git commit" }, error));
15116
16018
  // ../core/src/worktree/close.ts
15117
16019
  const reasonOf = (options, id) => {
15118
16020
  const why = options.unmerged.get(id);
@@ -15294,12 +16196,7 @@ const createWorkspace = (options) => {
15294
16196
  if (work.committed) event(id, "committed", worktree);
15295
16197
  if (work.error === void 0) return next;
15296
16198
  unmerged.set(holder, `uncommitted: ${work.error}`);
15297
- const error = `could not commit its work: ${work.error}`;
15298
- return {
15299
- ...next,
15300
- ok: false,
15301
- error
15302
- };
16199
+ return uncommitted(next, work.error);
15303
16200
  };
15304
16201
  const finish = async (id, result) => {
15305
16202
  const from = continued.get(id);
@@ -15355,4 +16252,4 @@ const createWorkspace = (options) => {
15355
16252
  close
15356
16253
  };
15357
16254
  };
15358
- export { adoptedCalls as $, NodeSchema as $n, targetOf as $t, inspectProject as A, inside as An, scriptContactsOf as At, createCalls as B, newWorkflowText as Bn, mcpNotText as Bt, workflowTextHash as C, newProjectId as Cn, runTree as Ct, openStore as D, runDirOf as Dn, isItem as Dt, isNodeEvent as E, projectsHome as En, stillTheSameProcess as Et, clearGitLocalEnv as F, plainPathInside as Fn, declaredOutputOf as Ft, peerOf as G, parseWorkflow as Gn, DISCUSSIONS as Gt, endLink as H, beforeOf as Hn, FEEDBACK_KINDS as Ht, parseInputPairs as I, writePlainFile as In, PROJECT_FILE as It, flattenTree as J, EdgeInputSchema as Jn, sendPost as Jt, tellNode as K, serializeWorkflow as Kn, planPost as Kt, resolveInputs as L, skillsIn as Ln, gitRulesOf as Lt, git as M, LESSON_CAP_TOKENS as Mn, calledWorkflows as Mt, gitOk as N, estimateTokens as Nn, envReadsOf as Nt, storePath as O, shownPath as On, nestItems as Ot, lines as P, guardedBase as Pn, nodeSays as Pt, tellLiveRun as Q, settingsOf as Qn, normalizeTitle as Qt, driverRefusals as R, WorkflowEditSchema as Rn, loadProject as Rt, readProjectTriggers as S, inRepoPlace as Sn, runStatus as St, watchingPid as T, placeFor as Tn, processAlive as Tt, endLinksOf as U, programsRunBy as Un, FEEDBACK_SEVERITIES as Ut, addressOf as V, readDocument as Vn, serversIn as Vt, linkNodes as W, renderJsonSchema as Wn, FEEDBACK_TARGETS as Wt, requestPause as X, CheckReportSchema as Xn, createFeedback as Xt, requestContinue as Y, WorkflowSchema as Yn, publishPost as Yt, requestStop as Z, expandEdge as Zn, fingerprint as Zt, lateTriggers as _, createAgent as _n, EXPERIMENTS as _r, MESSAGE_LIMIT as _t, limitsNow as a, socketPath as an, timeoutMs as ar, followEvents as at, triggerStanding as b, loadAgents as bn, liveStatus as bt, usd as c, MASK as cn, integrationFor as cr, planNodeSession as ct, runningRunIds as d, planAgentMigration as dn, readyDriver as dr, parseReport as dt, evaluate as en, isClaudeNode as er, detachResume as et, runningScript as f, applySteps as fn, AGENT_TOOL_LAYOUTS as fr, reportSchemaFor as ft, parseVersion as g, toolsInUse as gn, firstLine as gr, startRun as gt, isNewer as h, toolLinks as hn, DRIVER_IDS as hr, walkSummary as ht, NOT_STARTED as i, serveRun as in, retiredDriverHint as ir, detachRun as it, removeWorktree as j, projectRootOf as jn, declaredProblem as jt, BRANCH_PREFIX as k, workflowDirOf as kn, runScope as kt, planUpgrade as l, maskReplacer as ln, PREFERRED_DRIVER as lr, namingNoDriver as lt, compareVersions as m, linkTools as mn, AGENT_DRIVERS as mr, walkChecks as mt, resolveBase as n, renderTemplate as nn, DriverSchema as nr, listKey as nt, limitsReport as o, socketRoom as on, INTEGRATIONS as or, explainWorkflowText as ot, detectInstaller as p, planMoveInto as pn, ORK_LAYOUT as pr, checkWorkflowText as pt, unlinkNodes as q, markValue as qn, runGh as qt, capsHere as r, readCondition as rn, NodeIdSchema as rr, DETACHED_ENV as rt, planReason as s, wakeRun as sn, TOOL_PREFIX as sr, explainSession as st, createWorkspace as t, parseTemplate as tn, isToolNode as tr, resumable as tt, releaseNotes as u, maskSecrets as un, driverChecks as ur, sessionDriver as ut, triggersReport as v, deleteAgent as vn, experimentsOn as vr, userMessageText as vt, takeWatchLock as w, placeFiles as wn, templateOf as wt, declaredTriggers as x, hasInRepoFiles as xn, pausedOf as xt, createWatcher as y, catalogAgent as yn, knownExperiments as yr, heldTimeout as yt, driverQueues as z, applyEdits as zn, MCP_FILE as zt };
16255
+ export { DETACHED_ENV as $, skillsIn as $n, endOf as $t, lines as A, linkTools as An, AGENT_DRIVERS as Ar, nestItems as At, linkNodes as B, placeFiles as Bn, lowerChild as Bt, openStore as C, wakeRun as Cn, TOOL_PREFIX as Cr, runStatus as Ct, removeWorktree as D, planAgentMigration as Dn, readyDriver as Dr, processAlive as Dt, inspectProject as E, maskSecrets as En, driverChecks as Er, templateOf as Et, driverQueues as F, catalogAgent as Fn, knownExperiments as Fr, failedWith as Ft, requestContinue as G, workflowDirOf as Gn, PROJECT_FILE as Gt, tellNode as H, projectsHome as Hn, envReadsOf as Ht, createCalls as I, loadAgents as In, stoppedEnd as It, tellLiveRun as J, LESSON_CAP_TOKENS as Jn, machineRulesOf as Jt, requestPause as K, inside as Kn, gitRulesOf as Kt, addressOf as L, hasInRepoFiles as Ln, calledWorkflows as Lt, parseInputPairs as M, toolsInUse as Mn, firstLine as Mr, runScope as Mt, resolveInputs as N, createAgent as Nn, EXPERIMENTS as Nr, declaredProblem as Nt, git as O, applySteps as On, AGENT_TOOL_LAYOUTS as Or, stillTheSameProcess as Ot, driverRefusals as P, deleteAgent as Pn, experimentsOn as Pr, endedWith as Pt, listKey as Q, writePlainFile as Qn, createSignals as Qt, endLink as R, inRepoPlace as Rn, slotClock as Rt, isNodeEvent as S, socketRoom as Sn, INTEGRATIONS as Sr, pausedOf as St, BRANCH_PREFIX as T, maskReplacer as Tn, PREFERRED_DRIVER as Tr, slotWaitsOf as Tt, unlinkNodes as U, runDirOf as Un, nodeSays as Ut, peerOf as V, placeFor as Vn, startAt as Vt, flattenTree as W, shownPath as Wn, declaredOutputOf as Wt, detachResume as X, guardedBase as Xn, mcpNotText as Xt, adoptedCalls as Y, estimateTokens as Yn, MCP_FILE as Yt, resumable as Z, plainPathInside as Zn, serversIn as Zt, readProjectTriggers as _, parseTemplate as _n, isToolNode as _r, startRun as _t, releaseNotes as a, FEEDBACK_SEVERITIES as an, programsRunBy as ar, planReason as at, watchingPid as b, serveRun as bn, retiredDriverHint as br, heldTimeout as bt, detectInstaller as c, planPost as cn, serializeWorkflow as cr, explainSession as ct, parseVersion as d, publishPost as dn, WorkflowSchema as dr, sessionDriver as dt, outcomeEnd as en, WorkflowEditSchema as er, detachRun as et, lateTriggers as f, createFeedback as fn, CheckReportSchema as fr, parseReport as ft, declaredTriggers as g, evaluate as gn, isClaudeNode as gr, walkSummary as gt, triggerStanding as h, targetOf as hn, NodeSchema as hr, walkChecks as ht, planUpgrade as i, FEEDBACK_KINDS as in, beforeOf as ir, limitsReport as it, clearGitLocalEnv as j, toolLinks as jn, DRIVER_IDS as jr, scriptContactsOf as jt, gitOk as k, planMoveInto as kn, ORK_LAYOUT as kr, isItem as kt, compareVersions as l, runGh as ln, markValue as lr, planNodeSession as lt, createWatcher as m, normalizeTitle as mn, settingsOf as mr, checkWorkflowText as mt, resolveBase as n, END_KINDS as nn, newWorkflowText as nr, NOT_STARTED as nt, runningRunIds as o, FEEDBACK_TARGETS as on, renderJsonSchema as or, usd as ot, triggersReport as p, fingerprint as pn, expandEdge as pr, reportSchemaFor as pt, requestStop as q, projectRootOf as qn, loadProject as qt, capsHere as r, endFor as rn, readDocument as rr, limitsNow as rt, runningScript as s, DISCUSSIONS as sn, parseWorkflow as sr, explainWorkflowText as st, createWorkspace as t, END_DETAIL_LIMIT as tn, applyEdits as tr, followEvents as tt, isNewer as u, sendPost as un, EdgeInputSchema as ur, namingNoDriver as ut, workflowTextHash as v, renderTemplate as vn, DriverSchema as vr, MESSAGE_LIMIT as vt, storePath as w, MASK as wn, integrationFor as wr, runTree as wt, activeTree as x, socketPath as xn, timeoutMs as xr, liveStatus as xt, takeWatchLock as y, readCondition as yn, NodeIdSchema as yr, userMessageText as yt, endLinksOf as z, newProjectId as zn, slotWaitLine as zt };