@krapht/ork 1.0.0-alpha.8 → 1.0.0-alpha.9

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 (33) hide show
  1. package/dist/{__build_info__-B83zdwIv.mjs → __build_info__-y1TPvZvp.mjs} +2 -2
  2. package/dist/bin.mjs +597 -50
  3. package/dist/{clean-NtapkN2x.mjs → clean-BRUXyk87.mjs} +5 -2
  4. package/dist/{completion-DAQMrf1u.mjs → completion-cpZHtv50.mjs} +5 -5
  5. package/dist/{context-C5wQNjp9.mjs → context-Dbay8l7P.mjs} +8 -7
  6. package/dist/{decide-BaEOo7sO.mjs → decide-BphP3ONM.mjs} +1 -1
  7. package/dist/{find-DDAkEoda.mjs → find-BXZjliwr.mjs} +1 -1
  8. package/dist/format-DeT7vL-8.mjs +191 -0
  9. package/dist/{in-a-run-DENitTUe.mjs → in-a-run-C4E9HfED.mjs} +1 -1
  10. package/dist/{limits-DezRXN0Z.mjs → limits-CBeoYrUH.mjs} +1 -1
  11. package/dist/{list-CZTh_wdE.mjs → list-BmxgNyd3.mjs} +11 -4
  12. package/dist/{mcp-B2DW26HO.mjs → mcp-DTreb7Nq.mjs} +6 -6
  13. package/dist/{output-D1wMNIly.mjs → output-BVkI6ZVv.mjs} +1 -1
  14. package/dist/{pause-HuQhHyET.mjs → pause-h66SC4rV.mjs} +4 -4
  15. package/dist/{relay-DCGGYSdz.mjs → relay-BxRhxL6n.mjs} +2 -2
  16. package/dist/{remove-0L02cre2.mjs → remove-CYGd9Csj.mjs} +14 -7
  17. package/dist/{send-C8pQfhED.mjs → send-BskOwN4p.mjs} +3 -3
  18. package/dist/{src-DosUvy4B.mjs → src-BwFxTank.mjs} +291 -100
  19. package/dist/{src-COHxAj7X.mjs → src-W1i5Bl6N.mjs} +23 -9
  20. package/dist/{src-BNhCiDQw.mjs → src-bOImrq43.mjs} +30 -11
  21. package/dist/{src-DCCbIABO.mjs → src-xd9DbQw8.mjs} +7 -7
  22. package/dist/ui/assets/{app-DBYZFrdE.js → app-BMg0OOv4.js} +19 -19
  23. package/dist/ui/assets/app-C8uhHVYp.css +1 -0
  24. package/dist/ui/assets/{index-DXoKpYjA.js → index-DY1q4SDl.js} +2 -2
  25. package/dist/ui/index.html +1 -1
  26. package/dist/{ui-Cdb05xJw.mjs → ui-B46Z3e9E.mjs} +4 -4
  27. package/dist/{usage-jYI2QHt7.mjs → usage-DpHXbQEu.mjs} +5 -5
  28. package/dist/{watch-D4lFXnHT.mjs → watch-CJO1ee6Y.mjs} +6 -6
  29. package/dist/{workflows-BF9fO-ER.mjs → workflows-BJdSHnkr.mjs} +4 -3
  30. package/dist/{worktrees-0iUiA6eD.mjs → worktrees-Ccf2lUhE.mjs} +72 -19
  31. package/package.json +1 -1
  32. package/dist/format-sGEQmQid.mjs +0 -643
  33. package/dist/ui/assets/app-D14lfYns.css +0 -1
@@ -608,7 +608,10 @@ const agentSettings = {
608
608
  practices: PracticesSchema.optional(),
609
609
  tools: ToolRulesSchema.optional(),
610
610
  session: z.enum(["fresh", "resume"]).optional().describe("When the node runs again (a loop, another delegation): resume its session, or start a fresh one with the new input. Default: resume for a leader, fresh for a worker."),
611
- output: z.strictObject({ schema: z.record(z.string(), z.unknown()).optional().describe("Extra structured fields the node must report, e.g. { verdict: [approve, escalate] }.") }).optional()
611
+ output: z.strictObject({
612
+ schema: z.record(z.string(), z.unknown()).optional().describe("Extra structured fields the node must report, e.g. { verdict: [approve, escalate] }."),
613
+ gather: z.array(z.string().min(1)).min(1).optional().describe("List fields kept across rounds: each round's entries are added to the earlier rounds' instead of replacing them, e.g. [decisions].")
614
+ }).optional()
612
615
  };
613
616
  /** The settings keys an agent can bring, in canonical order. */
614
617
  const AGENT_SETTING_KEYS = [
@@ -858,7 +861,7 @@ const serializeAgent = (name, agent) => {
858
861
  // ../schema/src/edges.ts
859
862
  const ARROW = "->";
860
863
  const splitList = (side) => side.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
861
- const asList$1 = (value) => Array.isArray(value) ? value : [value];
864
+ const asList$2 = (value) => Array.isArray(value) ? value : [value];
862
865
  /**
863
866
  * Expands `"a, b -> c, d"` (or the object form) into the cartesian product of
864
867
  * edges.
@@ -866,7 +869,7 @@ const asList$1 = (value) => Array.isArray(value) ? value : [value];
866
869
  const expandEdge = (input, source) => {
867
870
  if (typeof input !== "string") return {
868
871
  ok: true,
869
- edges: asList$1(input.from).flatMap((from) => asList$1(input.to).map((to) => {
872
+ edges: asList$2(input.from).flatMap((from) => asList$2(input.to).map((to) => {
870
873
  const edge = {
871
874
  from,
872
875
  to,
@@ -907,7 +910,7 @@ const formatEdge = (input) => {
907
910
  const [left = "", right = ""] = input.split(ARROW);
908
911
  return `${splitList(left).join(", ")} ${ARROW} ${splitList(right).join(", ")}`;
909
912
  }
910
- if (input.when === void 0 && input.maxRounds === void 0) return `${asList$1(input.from).join(", ")} ${ARROW} ${asList$1(input.to).join(", ")}`;
913
+ if (input.when === void 0 && input.maxRounds === void 0) return `${asList$2(input.from).join(", ")} ${ARROW} ${asList$2(input.to).join(", ")}`;
911
914
  return input;
912
915
  };
913
916
  /** Settings every Claude node takes unless it (or its agent) sets its own. */
@@ -1055,6 +1058,31 @@ const markValue = (value) => {
1055
1058
  const pad = line.startsWith("`") || line.endsWith("`") ? " " : "";
1056
1059
  return `${fence}${pad}${line}${pad}${fence}`;
1057
1060
  };
1061
+ // ../schema/src/issue-messages.ts
1062
+ const NODE_TYPES = NodeSchema.options.map((o) => o.shape.type.value);
1063
+ const isMap$1 = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
1064
+ /** The text for an issue at or under a `forEach`, else `null`. */
1065
+ const forEachMessage = (issue, path, value) => {
1066
+ const key = path.at(-1);
1067
+ if (issue.code === "too_small" && path.at(-2) === "forEach") {
1068
+ if (key === "items") return "`forEach.items` is empty: list at least one item, or use `from`";
1069
+ if (key === "maxItems") return "`forEach.maxItems` must be at least 1: the most items to run before Ork asks";
1070
+ }
1071
+ if (issue.code !== "invalid_union" || key !== "forEach") return null;
1072
+ if (!isMap$1(value)) return "`forEach` takes a map: `items: [...]` for a list written here, or `from:` with `maxItems:` for an earlier node's";
1073
+ if ("items" in value && "from" in value) return "`forEach` takes `items` or `from`, not both";
1074
+ if ("from" in value) return "`forEach` with `from` needs `maxItems`: the most items to run before Ork asks";
1075
+ if (!("items" in value)) return "`forEach` needs `items` or `from`: a list written here, or an earlier node's";
1076
+ return null;
1077
+ };
1078
+ /** The text for a `list` rule that is not a map of fields, else `null`. */
1079
+ const listRuleMessage = (issue, value) => issue.code === "invalid_union" && isMap$1(value) && "list" in value ? "`list` takes a map of each item's fields, e.g. `list: { title: string }` (`list: {}` for items of any shape)" : null;
1080
+ /** The text for a node whose `type` is missing or unknown, else `null`. */
1081
+ const nodeTypeMessage = (issue, value) => {
1082
+ if (issue.code !== "invalid_union" || issue.discriminator !== "type") return null;
1083
+ const types = NODE_TYPES.map((type) => markValue(type)).join(", ");
1084
+ return value === void 0 ? `node needs a \`type\`: one of ${types}` : `unknown node type ${markValue(typeof value === "string" ? value : JSON.stringify(value) ?? "")}: expected one of ${types}`;
1085
+ };
1058
1086
  // ../schema/src/newer-version.ts
1059
1087
  /**
1060
1088
  * A file with a higher `schemaVersion` than this Ork reads was made by a newer
@@ -1075,7 +1103,6 @@ const newerVersion = (issues, raw) => {
1075
1103
  * diagnostics, and the workflow's byte-stable canonical serializer.
1076
1104
  */
1077
1105
  const SCHEMA_URL = "https://raw.githubusercontent.com/mimshins/ork/main/packages/schema/workflow.schema.json";
1078
- const NODE_TYPES = NodeSchema.options.map((o) => o.shape.type.value);
1079
1106
  const createLocate = (doc, counter) => (path) => {
1080
1107
  for (let depth = path.length; depth >= 0; depth -= 1) {
1081
1108
  const node = doc.getIn(path.slice(0, depth), true);
@@ -1090,6 +1117,12 @@ const createLocate = (doc, counter) => (path) => {
1090
1117
  return null;
1091
1118
  };
1092
1119
  const toPath = (path) => path.map((p) => typeof p === "number" ? p : String(p));
1120
+ /** The value at a path of the raw document, or undefined. */
1121
+ const valueAt = (raw, path) => {
1122
+ let value = raw;
1123
+ for (const segment of path) value = value !== null && typeof value === "object" ? Reflect.get(value, segment) : void 0;
1124
+ return value;
1125
+ };
1093
1126
  const describeIssue = (issue, raw, path) => {
1094
1127
  if (issue.code === "unrecognized_keys") {
1095
1128
  const [first] = issue.keys;
@@ -1098,14 +1131,12 @@ const describeIssue = (issue, raw, path) => {
1098
1131
  path: first === void 0 ? path : [...path, first]
1099
1132
  };
1100
1133
  }
1101
- if (issue.code === "invalid_union" && issue.discriminator === "type") {
1102
- let value = raw;
1103
- for (const segment of path) value = value !== null && typeof value === "object" ? Reflect.get(value, segment) : void 0;
1104
- return {
1105
- message: value === void 0 ? `node needs a \`type\`: one of ${NODE_TYPES.map((type) => markValue(type)).join(", ")}` : `unknown node type ${markValue(typeof value === "string" ? value : JSON.stringify(value) ?? "")}: expected one of ${NODE_TYPES.map((type) => markValue(type)).join(", ")}`,
1106
- path
1107
- };
1108
- }
1134
+ const value = valueAt(raw, path);
1135
+ const said = forEachMessage(issue, path, value) ?? listRuleMessage(issue, value) ?? nodeTypeMessage(issue, value);
1136
+ if (said !== null) return {
1137
+ message: said,
1138
+ path
1139
+ };
1109
1140
  return {
1110
1141
  message: issue.message.replaceAll(/"([^"`\n]+)"/gu, "`$1`"),
1111
1142
  path
@@ -4194,7 +4225,7 @@ const resolvePath = (segments, scope) => {
4194
4225
  };
4195
4226
  /** Truthiness as expressions see it: an empty list is false. */
4196
4227
  const truthy = (value) => Array.isArray(value) ? value.length > 0 : Boolean(value);
4197
- const asList = (value) => Array.isArray(value) ? value : [];
4228
+ const asList$1 = (value) => Array.isArray(value) ? value : [];
4198
4229
  const order = (op, l, r) => {
4199
4230
  if (op === "<") return l < r;
4200
4231
  if (op === "<=") return l <= r;
@@ -4222,7 +4253,7 @@ const evaluate = (expr, scope) => {
4222
4253
  case "call": {
4223
4254
  const args = expr.args.map((arg) => evaluate(arg, scope));
4224
4255
  const [subject, value] = args;
4225
- const list = asList(subject);
4256
+ const list = asList$1(subject);
4226
4257
  const matches = (item) => args.length > 1 ? item === value : truthy(item);
4227
4258
  switch (expr.name) {
4228
4259
  case "any": return list.some((item) => matches(item));
@@ -4328,11 +4359,21 @@ const readableText = (value, before) => {
4328
4359
  * Fills a parsed template. `readable` is for text a person reads (a gate's
4329
4360
  * `question` and `document`); scripts and prompts keep JSON (ADR-047).
4330
4361
  */
4331
- const renderTemplate = (parts, scope, { readable = false } = {}) => parts.reduce((text, part) => {
4332
- if (typeof part === "string") return text + part;
4333
- const value = evaluate(part.expr, scope);
4334
- return text + (readable ? readableText(value, text) : stringify$1(value));
4335
- }, "");
4362
+ const renderTemplate = (parts, scope, { readable = false } = {}) => {
4363
+ let text = "";
4364
+ let afterList = false;
4365
+ for (const part of parts) {
4366
+ const value = typeof part === "string" ? part : evaluate(part.expr, scope);
4367
+ const piece = readable ? readableText(value, text) : stringify$1(value);
4368
+ if (piece.trim() === "") {
4369
+ text += piece;
4370
+ continue;
4371
+ }
4372
+ text = afterList && !isContainer(value) ? `${text.trimEnd()}\n\n${piece.trimStart()}` : text + piece;
4373
+ afterList = readable && isContainer(value) && depthOf(value) <= 2;
4374
+ }
4375
+ return text;
4376
+ };
4336
4377
  // ../core/src/feedback/fingerprint.ts
4337
4378
  /**
4338
4379
  * @file Feedback fingerprints: records about the same thing (same target, kind,
@@ -5055,6 +5096,8 @@ const REPORT_STATUSES = [
5055
5096
  ];
5056
5097
  /** What a node declares of its output (RFC-010), if it does. */
5057
5098
  const declaredOutputOf = (node) => "output" in node ? node.output?.schema : void 0;
5099
+ /** The report fields a node keeps across its rounds (ADR-073), if any. */
5100
+ const gatheredFieldsOf = (node) => node !== void 0 && isClaudeNode(node) ? node.output?.gather ?? [] : [];
5058
5101
  /** What a node with `forEach` reports instead of its type's fields (RFC-013). */
5059
5102
  const GATHERED_FIELDS = [
5060
5103
  "items",
@@ -5686,6 +5729,11 @@ const ownersOf = (workflow, id) => {
5686
5729
  return owners;
5687
5730
  };
5688
5731
  // ../core/src/graph/for-each.ts
5732
+ /**
5733
+ * @file The checks on `forEach` (RFC-013): where its list comes from, and where
5734
+ * `{{item}}` and `{{index}}` may be read and what `{{item.<field>}}` may
5735
+ * name.
5736
+ */
5689
5737
  const isItemRoot = (root) => ITEM_ROOTS.includes(root);
5690
5738
  /**
5691
5739
  * Nodes whose output exists before `id` runs: its flow ancestors and its
@@ -5701,7 +5749,7 @@ const upstreamOf = (workflow, edges, id) => {
5701
5749
  };
5702
5750
  /** The field a path under `nodes.<id>.output` reads, as `says.ts` names it. */
5703
5751
  const fieldOfPath = (segments) => segments.slice(3).join(".");
5704
- const isList = (said) => said.items !== void 0 || said.kind === "statuses";
5752
+ const isList$1 = (said) => said.items !== void 0 || said.kind === "statuses";
5705
5753
  /** What a node's `from` list's items have, or `undefined` if nothing says. */
5706
5754
  const sourceFields = (segments, says) => {
5707
5755
  const [root, id] = segments;
@@ -5745,6 +5793,11 @@ const checkFiles = (id, key, files) => files.flatMap((file, index) => {
5745
5793
  index
5746
5794
  ]))) : [];
5747
5795
  });
5796
+ /**
5797
+ * Whether one activation's parsed JSON is at `output.json` (a program, an MCP
5798
+ * call).
5799
+ */
5800
+ const readsJson = (node) => (isToolNode(node) || node.type === "mcp.call") && forEachOf$1(node) === void 0;
5748
5801
  /** The source of one `from`: it exists, runs first, and is a declared list. */
5749
5802
  const checkSource = (workflow, edges, says, id, segments, path) => {
5750
5803
  const named = describePath(segments);
@@ -5759,8 +5812,15 @@ const checkSource = (workflow, edges, says, id, segments, path) => {
5759
5812
  if (source === id || !ancestorsOf(id, first).has(String(source))) return [error("forEach.order", `\`${named}\`: \`${String(source)}\` does not run before \`${id}\`, so its list can't exist when \`${id}\` starts`, path)];
5760
5813
  const field = fieldOfPath(segments);
5761
5814
  const said = says[String(source)]?.fields.find(({ field: name }) => name === field);
5762
- if (field === "" || said !== void 0 && !isList(said)) return [error("forEach.not-list", `\`${named}\` is not a list: \`${String(source)}\` declares ${field === "" ? "its whole output" : `\`${field}\``} as something else; declare it as \`{ list: { … } }\``, path)];
5763
- return said === void 0 ? [warning("forEach.undeclared", `\`${named}\`: nothing declares this list, so a run can't check it or its items; add \`{ list: { … } }\` to \`${String(source)}\`'s \`output.schema\``, path)] : [];
5815
+ if (field === "" || said !== void 0 && !isList$1(said)) return [error("forEach.not-list", `\`${named}\` is not a list: \`${String(source)}\` declares ${field === "" ? "its whole output" : `\`${field}\``} as something else; declare it as \`{ list: { … } }\``, path)];
5816
+ if (said !== void 0) return [];
5817
+ const add = `add \`{ list: { … } }\` to \`${String(source)}\`'s \`output.schema\``;
5818
+ const node = workflow.nodes[String(source)];
5819
+ if (node !== void 0 && readsJson(node) && !field.startsWith("json.")) {
5820
+ const declared = says[String(source)]?.fields.some(({ field: name }) => name === `json.${field}`);
5821
+ return [warning("forEach.undeclared", `\`${named}\`: a tool's JSON is read as \`nodes.${String(source)}.output.json.${field}\`${declared === true ? "" : `; ${add}`}`, path)];
5822
+ }
5823
+ return [warning("forEach.undeclared", `\`${named}\`: nothing declares this list, so a run can't check it or its items; ${add}`, path)];
5764
5824
  };
5765
5825
  /** Every `forEach`'s source, and `{{item}}` in the lists no template reads. */
5766
5826
  const checkForEach = (workflow, edges) => {
@@ -5839,6 +5899,26 @@ const checkGroup = (workflow, edges, component) => {
5839
5899
  };
5840
5900
  /** What's wrong with the shape of each group of nodes that loop together. */
5841
5901
  const checkLoops = (workflow, edges) => stronglyConnected(Object.keys(workflow.nodes), edges).flatMap((component) => checkGroup(workflow, edges, component));
5902
+ // ../core/src/graph/gather.ts
5903
+ /** Whether a declared field is a list: `{ type: array }` or `{ list: … }`. */
5904
+ const isList = (rule) => listShape(rule) !== void 0 || typeof rule === "object" && rule !== null && "type" in rule && rule.type === "array";
5905
+ const gatherProblems = (id, node) => {
5906
+ const fields = gatheredFieldsOf(node);
5907
+ if (fields.length === 0) return [];
5908
+ const path = [
5909
+ "nodes",
5910
+ id,
5911
+ "output",
5912
+ "gather"
5913
+ ];
5914
+ if (forEachOf$1(node) !== void 0) return [error("output.gather", `\`${id}\` runs once per item and reports its items' results, so it has no fields of its own to keep across rounds`, path)];
5915
+ const declared = declaredOutputOf(node) ?? {};
5916
+ return fields.flatMap((field, index) => {
5917
+ const rule = declared[field];
5918
+ if (rule === void 0) return [error("output.gather", `\`${field}\` isn't in \`${id}\`'s \`output.schema\`; declare it as a list to keep it across rounds`, [...path, index])];
5919
+ return isList(rule) ? [] : [error("output.gather", `\`${field}\` isn't declared as a list (\`{ type: array }\` or \`{ list: … }\`), so its rounds can't be added together`, [...path, index])];
5920
+ });
5921
+ };
5842
5922
  // ../core/src/graph/script.ts
5843
5923
  /**
5844
5924
  * @file Checks on the programs a workflow declares (RFC-006): a tool node's and
@@ -5910,7 +5990,7 @@ const checkNodes = (workflow, edges) => {
5910
5990
  const incoming = edges.filter((e) => e.to === id);
5911
5991
  const outgoing = edges.filter((e) => e.from === id);
5912
5992
  if (node.type === "claude.subagent" && node.timeout !== void 0) diagnostics.push(warning("timeout.subagent", `sub-agent \`${id}\` runs inside its parent's session, so its timeout has no effect; set one on the parent`, [...path, "timeout"]));
5913
- diagnostics.push(...statusProblems(id, node));
5993
+ diagnostics.push(...statusProblems(id, node), ...gatherProblems(id, node));
5914
5994
  if (node.type === "control.join") {
5915
5995
  const sources = new Set(incoming.map((e) => e.from)).size;
5916
5996
  if (sources < 2) diagnostics.push(warning("join.inputs", `join \`${id}\` has ${sources} incoming node(s); a join waits for two or more`, path));
@@ -6823,14 +6903,23 @@ const createLoops = (options) => {
6823
6903
  };
6824
6904
  /**
6825
6905
  * What a list over its cap runs: asked of a live owner, then the human. All
6826
- * of it, the first `maxItems`, or `null` to stop.
6906
+ * of it, the first `maxItems`, or `null` to stop. A stop (`signal`) ends the
6907
+ * wait and withdraws the question; the list comes back whole, and nothing of
6908
+ * it starts because the stop also bars its items.
6827
6909
  */
6828
- const overList = async (node, list, maxItems, round) => {
6910
+ const overList = async (node, list, maxItems, round, signal) => {
6829
6911
  const question = eachQuestion(node, list.length, maxItems, round);
6830
- const choice = await options.bus?.askOwner(node, question) ?? await options.human.ask({
6912
+ const asking = async () => await options.bus?.askOwner(node, question) ?? (signal.aborted ? null : options.human.ask({
6831
6913
  nodeId: node,
6832
- ...question
6833
- });
6914
+ ...question,
6915
+ signal
6916
+ }));
6917
+ const stopped = Promise.withResolvers();
6918
+ signal.addEventListener("abort", () => {
6919
+ stopped.resolve(null);
6920
+ }, { once: true });
6921
+ const choice = await Promise.race([asking(), stopped.promise]);
6922
+ if (signal.aborted) return list;
6834
6923
  if (choice === EACH_CHOICES.first) return list.slice(0, maxItems);
6835
6924
  return choice === EACH_CHOICES.all ? list : null;
6836
6925
  };
@@ -6911,6 +7000,40 @@ const declaredProblem = (declared, value) => {
6911
7000
  * still masks to the record is the file's, with its real values.
6912
7001
  */
6913
7002
  const unmasked = (forEach, recorded) => "items" in forEach && JSON.stringify(forEach.items, maskReplacer) === JSON.stringify(recorded) ? [...forEach.items] : recorded;
7003
+ // ../core/src/run/contacts.ts
7004
+ const SESSION_TYPES$4 = /* @__PURE__ */ new Set(["claude.leader", "claude.worker"]);
7005
+ const isSession = (node) => SESSION_TYPES$4.has(node?.type ?? "");
7006
+ const isTool = (node) => node?.type.startsWith("tool.") ?? false;
7007
+ const channelPeersOf = (id, channels) => channels.flatMap(([a, b]) => {
7008
+ if (a === id) return [b];
7009
+ return b === id ? [a] : [];
7010
+ });
7011
+ /** Every declared node, with its owner, as a run records them. */
7012
+ const declaredNodes = (graph) => [...graph.nodes.values()].map(({ id, node }) => node.parent === void 0 ? {
7013
+ id,
7014
+ type: node.type
7015
+ } : {
7016
+ id,
7017
+ type: node.type,
7018
+ parent: node.parent
7019
+ });
7020
+ /** Whom a session may write to, by how it knows them. */
7021
+ const contactsOf = (id, nodes, channels) => {
7022
+ const byId = new Map(nodes.map((node) => [node.id, node]));
7023
+ return {
7024
+ owner: byId.get(id)?.parent,
7025
+ children: nodes.filter((node) => node.parent === id && isSession(node)).map((node) => node.id),
7026
+ channels: channelPeersOf(id, channels).filter((peer) => !isTool(byId.get(peer)))
7027
+ };
7028
+ };
7029
+ /** Whom a program in the tool node `id` may write to. */
7030
+ const scriptContactsOf = (id, nodes, channels) => {
7031
+ const byId = new Map(nodes.map((node) => [node.id, node]));
7032
+ const owner = byId.get(id)?.parent;
7033
+ return [.../* @__PURE__ */ new Set([...owner !== void 0 && isSession(byId.get(owner)) ? [owner] : [], ...channelPeersOf(id, channels).filter((peer) => isSession(byId.get(peer)))])];
7034
+ };
7035
+ /** Whether a program in a tool node may write to the session `id`. */
7036
+ const hearsFromScripts = (id, nodes, channels) => nodes.some((node) => isTool(node) && scriptContactsOf(node.id, nodes, channels).includes(id));
6914
7037
  // ../core/src/usage/spend.ts
6915
7038
  /** What a report would add to the session's earlier ones, counting nothing. */
6916
7039
  const unreported = (reported, key, total) => {
@@ -6966,46 +7089,7 @@ const earlierSpend = (events) => {
6966
7089
  forks
6967
7090
  };
6968
7091
  };
6969
- // ../core/src/run/contacts.ts
6970
- const SESSION_TYPES$4 = /* @__PURE__ */ new Set(["claude.leader", "claude.worker"]);
6971
- const isSession = (node) => SESSION_TYPES$4.has(node?.type ?? "");
6972
- const isTool = (node) => node?.type.startsWith("tool.") ?? false;
6973
- const channelPeersOf = (id, channels) => channels.flatMap(([a, b]) => {
6974
- if (a === id) return [b];
6975
- return b === id ? [a] : [];
6976
- });
6977
- /** Every declared node, with its owner, as a run records them. */
6978
- const declaredNodes = (graph) => [...graph.nodes.values()].map(({ id, node }) => node.parent === void 0 ? {
6979
- id,
6980
- type: node.type
6981
- } : {
6982
- id,
6983
- type: node.type,
6984
- parent: node.parent
6985
- });
6986
- /** Whom a session may write to, by how it knows them. */
6987
- const contactsOf = (id, nodes, channels) => {
6988
- const byId = new Map(nodes.map((node) => [node.id, node]));
6989
- return {
6990
- owner: byId.get(id)?.parent,
6991
- children: nodes.filter((node) => node.parent === id && isSession(node)).map((node) => node.id),
6992
- channels: channelPeersOf(id, channels).filter((peer) => !isTool(byId.get(peer)))
6993
- };
6994
- };
6995
- /** Whom a program in the tool node `id` may write to. */
6996
- const scriptContactsOf = (id, nodes, channels) => {
6997
- const byId = new Map(nodes.map((node) => [node.id, node]));
6998
- const owner = byId.get(id)?.parent;
6999
- return [.../* @__PURE__ */ new Set([...owner !== void 0 && isSession(byId.get(owner)) ? [owner] : [], ...channelPeersOf(id, channels).filter((peer) => isSession(byId.get(peer)))])];
7000
- };
7001
- /** Whether a program in a tool node may write to the session `id`. */
7002
- const hearsFromScripts = (id, nodes, channels) => nodes.some((node) => isTool(node) && scriptContactsOf(node.id, nodes, channels).includes(id));
7003
- // ../core/src/run/results.ts
7004
- /** Control nodes pass their input through; a join's input is its output. */
7005
- const passThrough = (input) => ({
7006
- output: input ?? {},
7007
- ok: true
7008
- });
7092
+ // ../core/src/run/spending.ts
7009
7093
  /**
7010
7094
  * A result with only what this activation spent (ADR-046): a session's report
7011
7095
  * holds its total so far, while its tokens are this process's own. `reported`
@@ -7031,11 +7115,23 @@ const withOwnSpend = (id, result, reported) => {
7031
7115
  const createSpend = (budgetUsd, earlier, limits) => {
7032
7116
  const reported = new Map(earlier?.reported);
7033
7117
  let spent = earlier?.total ?? 0;
7034
- const budgetLeft = () => budgetUsd === void 0 ? void 0 : budgetUsd - spent;
7118
+ /** What running sessions last reported, not yet in `spent`, by `node/session`. */
7119
+ const inFlight = /* @__PURE__ */ new Map();
7120
+ const committed = () => spent + [...inFlight.values()].reduce((sum, cost) => sum + cost, 0);
7121
+ const budgetLeft = () => budgetUsd === void 0 ? void 0 : budgetUsd - committed();
7035
7122
  return {
7036
7123
  total: () => spent,
7037
7124
  /** What a session has spent in this activation, from its total so far. */
7038
7125
  ownOf: (id, sessionId, total) => unreported(reported, `${id}/${sessionId}`, total),
7126
+ /** A running session said what it has spent so far. */
7127
+ report: (id, sessionId, total) => {
7128
+ const key = `${id}/${sessionId}`;
7129
+ inFlight.set(key, unreported(reported, key, total));
7130
+ },
7131
+ /** The node ended: `count` has what its sessions last reported. */
7132
+ settled: (id) => {
7133
+ for (const key of inFlight.keys()) if (key.startsWith(`${id}/`)) inFlight.delete(key);
7134
+ },
7039
7135
  /** What a session may spend: the least of the budget and any quota. */
7040
7136
  left: () => {
7041
7137
  const known = [budgetLeft(), limits?.().leftUsd].filter((left) => left !== void 0);
@@ -7054,7 +7150,7 @@ const createSpend = (budgetUsd, earlier, limits) => {
7054
7150
  status: "quota_exhausted",
7055
7151
  reason: blocked
7056
7152
  };
7057
- return budgetUsd !== void 0 && spent >= budgetUsd ? {
7153
+ return budgetUsd !== void 0 && committed() >= budgetUsd ? {
7058
7154
  status: "budget_exhausted",
7059
7155
  reason: `the run's budget of $${String(budgetUsd)} is spent`
7060
7156
  } : null;
@@ -7073,6 +7169,56 @@ const createSpend = (budgetUsd, earlier, limits) => {
7073
7169
  };
7074
7170
  /** A run's spending, from what the scheduler is given. */
7075
7171
  const spendOf = (budgetUsd, options) => createSpend(budgetUsd, options.replay?.earlier, options.limits);
7172
+ // ../core/src/run/gather.ts
7173
+ const asList = (value) => Array.isArray(value) ? value : [];
7174
+ /** `before` then `now`, leaving out an entry already there. */
7175
+ const joined = (before, now) => {
7176
+ const seen = /* @__PURE__ */ new Set();
7177
+ const out = [];
7178
+ for (const entry of [...asList(before), ...asList(now)]) {
7179
+ const key = JSON.stringify(entry);
7180
+ if (seen.has(key)) continue;
7181
+ seen.add(key);
7182
+ out.push(entry);
7183
+ }
7184
+ return out;
7185
+ };
7186
+ /**
7187
+ * `result` with each gathered field holding every round's entries so far.
7188
+ * `previous` is the node's output from its last round, already gathered.
7189
+ */
7190
+ const joinRounds = (fields, previous, result) => {
7191
+ if (fields.length === 0 || previous === void 0) return result;
7192
+ const output = { ...result.output };
7193
+ for (const field of fields) output[field] = joined(previous[field], result.output[field]);
7194
+ return {
7195
+ ...result,
7196
+ output
7197
+ };
7198
+ };
7199
+ /** `result` with `node`'s `output.gather` fields joined to its earlier rounds. */
7200
+ const gatherRounds = (node, previous, result) => joinRounds(gatheredFieldsOf(node), previous, result);
7201
+ /**
7202
+ * What `activation`'s rounds reported so far, to gather onto. A spawned
7203
+ * instance gathers nothing earlier: its id (`impl-2`) is reused by a different
7204
+ * worker in the leader's next round. A delegated child that hasn't run since a
7205
+ * resume starts from what it reported before it, since its leader's recorded
7206
+ * rounds are handed back whole and never run it again.
7207
+ */
7208
+ const previousOf = (activation, current, replay) => {
7209
+ const { brief, template, id } = activation;
7210
+ if (brief !== void 0 && template !== void 0) return void 0;
7211
+ if (current !== void 0 || brief === void 0) return current;
7212
+ return replay?.lastOutput?.(id);
7213
+ };
7214
+ /** Gathers each activation's result onto what its node reported before. */
7215
+ const createGatherer = (outputs, replay, nodeOf) => (activation, result) => gatherRounds(nodeOf(activation), previousOf(activation, outputs[activation.id], replay), result);
7216
+ // ../core/src/run/results.ts
7217
+ /** Control nodes pass their input through; a join's input is its output. */
7218
+ const passThrough = (input) => ({
7219
+ output: input ?? {},
7220
+ ok: true
7221
+ });
7076
7222
  const recordRun = (entry, result) => {
7077
7223
  entry.runs += 1;
7078
7224
  entry.ok = result.ok;
@@ -7270,10 +7416,10 @@ const sourceOf = (forEach, scope) => {
7270
7416
  const isRecord$1 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
7271
7417
  /**
7272
7418
  * What `results` keeps of an item: its declared fields (a program's are under
7273
- * `json`), its summary, and Ork's own branch and worktree. The full output
7274
- * stays in the item's own `node.finished`.
7419
+ * `json`), its summary, Ork's own branch and worktree, and how a program
7420
+ * failed. The full output stays in the item's own `node.finished`.
7275
7421
  */
7276
- const entryOf = (declared, output) => {
7422
+ const entryOf = (declared, { ok, output, error }) => {
7277
7423
  const json = isRecord$1(output["json"]) ? output["json"] : {};
7278
7424
  const fields = Object.keys(declared ?? {}).map((field) => [field, Object.hasOwn(json, field) ? json[field] : output[field]]);
7279
7425
  const own = [
@@ -7282,7 +7428,12 @@ const entryOf = (declared, output) => {
7282
7428
  "worktree",
7283
7429
  "merge"
7284
7430
  ].map((field) => [field, output[field]]);
7285
- return Object.fromEntries([...fields, ...own].filter(([, value]) => value !== void 0));
7431
+ const failed = !ok && typeof output["exitCode"] === "number" ? [["exitCode", output["exitCode"]], ["error", error]] : [];
7432
+ return Object.fromEntries([
7433
+ ...fields,
7434
+ ...own,
7435
+ ...failed
7436
+ ].filter(([, value]) => value !== void 0));
7286
7437
  };
7287
7438
  /** Whether an item passed but its work could not reach its owner. */
7288
7439
  const mergeFailed = ({ output }) => isRecord$1(output["merge"]) && output["merge"]["ok"] === false;
@@ -7332,10 +7483,12 @@ const createFanOut = (deps) => {
7332
7483
  node: id,
7333
7484
  round,
7334
7485
  count: list.length,
7335
- list
7486
+ list,
7487
+ ...recorded === null && options.replay !== void 0 ? { anew: true } : {}
7336
7488
  });
7337
7489
  };
7338
7490
  recordList();
7491
+ const signal = stops.signalFor(id);
7339
7492
  if ("maxItems" in forEach && list.length > forEach.maxItems) {
7340
7493
  options.onEvent({
7341
7494
  type: "each.exceeded",
@@ -7344,14 +7497,14 @@ const createFanOut = (deps) => {
7344
7497
  count: list.length,
7345
7498
  maxItems: forEach.maxItems
7346
7499
  });
7347
- const chosen = await deps.loops.overList(id, list, forEach.maxItems, round);
7500
+ const chosen = await deps.loops.overList(id, list, forEach.maxItems, round, signal);
7348
7501
  if (chosen === null) {
7349
7502
  const reason = `the list of ${id} has ${String(list.length)} items, over maxItems ${String(forEach.maxItems)}`;
7350
- return {
7503
+ return stops.ended(id, {
7351
7504
  ...failure(reason),
7352
7505
  limited: reason,
7353
7506
  stopsAs: "failed"
7354
- };
7507
+ });
7355
7508
  }
7356
7509
  const narrowed = chosen.length !== list.length;
7357
7510
  list = chosen;
@@ -7361,7 +7514,6 @@ const createFanOut = (deps) => {
7361
7514
  const session = node !== void 0 && needsSlot(node);
7362
7515
  const spends = node !== void 0 && spendsBudget(node);
7363
7516
  const declared = node === void 0 ? void 0 : declaredOutputOf(node);
7364
- const signal = stops.signalFor(id);
7365
7517
  const results = list.map(() => ({}));
7366
7518
  const statuses = list.map(() => "skipped");
7367
7519
  const failures = [];
@@ -7383,7 +7535,7 @@ const createFanOut = (deps) => {
7383
7535
  },
7384
7536
  round
7385
7537
  });
7386
- results[index] = entryOf(declared, result.output);
7538
+ results[index] = entryOf(declared, result);
7387
7539
  statuses[index] = statusOf(result);
7388
7540
  if (!result.ok) failures.push(`${itemId}: ${result.error ?? "failed"}`);
7389
7541
  else if (mergeFailed(result)) failures.push(`${itemId}: its work could not be merged`);
@@ -7619,6 +7771,10 @@ const nodeContext = (deps, activation, cwd, round) => {
7619
7771
  ...options.calls === void 0 ? {} : { calls: options.calls },
7620
7772
  emit: (event) => {
7621
7773
  deps.ports.owners.seen(id, event, cwd);
7774
+ if (event.type === "session.finished" && event.fork !== true) {
7775
+ const { sessionId, usage } = event.outcome;
7776
+ deps.spend.report(id, sessionId, usage.costUsd);
7777
+ }
7622
7778
  options.onEvent({
7623
7779
  type: "node.event",
7624
7780
  id,
@@ -9375,6 +9531,7 @@ const startRun = (options) => {
9375
9531
  loops
9376
9532
  })
9377
9533
  });
9534
+ const gather = createGatherer(outputs, options.replay, nodeOf);
9378
9535
  /** Runs one activation and records it; routing is the caller's. */
9379
9536
  const perform = async (activation) => {
9380
9537
  const { id } = activation;
@@ -9387,8 +9544,9 @@ const startRun = (options) => {
9387
9544
  const agent = graph.agents.get(activation.template ?? id);
9388
9545
  onEvent(startedEvent(activation, type, round, agent));
9389
9546
  }
9390
- const result = spend.count(id, stops.ended(id, recorded ?? await execute(activation, round)), recorded !== null);
9547
+ const result = gather(activation, spend.count(id, stops.ended(id, recorded ?? await execute(activation, round)), recorded !== null));
9391
9548
  if (result.limited !== void 0) stop(result.stopsAs ?? "quota_exhausted", result.limited);
9549
+ spend.settled(id);
9392
9550
  holds.finished(id);
9393
9551
  ports.finished(id);
9394
9552
  clock.leave(id);
@@ -11208,10 +11366,11 @@ const mailOf = (events) => {
11208
11366
  * (ADR-026). Each node's recorded results, in order, up to its first that
11209
11367
  * failed or never finished, are handed back by the replay, so the scheduler
11210
11368
  * rebuilds outputs, rounds, joins and loop counts without running them; the
11211
- * rest run again, and so does everything a new result feeds. Also: the run's
11212
- * inputs and settings, its worktrees, the answers to its loops, and a fanned
11213
- * node's lists and its items' results by round (RFC-013). `replay.ts` hands
11214
- * them back.
11369
+ * rest run again, and so does everything a new result feeds. A run resumed
11370
+ * more than once reads as one history: a later result replaces the earlier
11371
+ * one at its place. Also: the run's inputs and settings, its worktrees, the
11372
+ * answers to its loops, and a fanned node's lists and its items' results by
11373
+ * round (RFC-013). `replay.ts` hands them back.
11215
11374
  */
11216
11375
  const listKey = (node, round) => `${node}/${String(round)}`;
11217
11376
  /** How a run ended that cut its working nodes off, rather than failing them. */
@@ -11221,6 +11380,8 @@ const CUTTING = /* @__PURE__ */ new Set([
11221
11380
  "quota_exhausted",
11222
11381
  "timed_out"
11223
11382
  ]);
11383
+ /** Whether `id` is an item (`<node>.<index>`) of `node`. */
11384
+ const isItemOf = (id, node) => id.startsWith(`${node}.`) && /^\d+$/u.test(id.slice(node.length + 1));
11224
11385
  /** Reads a run's events back into what `ork resume` needs. */
11225
11386
  const resumeState = (events) => {
11226
11387
  const history = /* @__PURE__ */ new Map();
@@ -11243,18 +11404,36 @@ const resumeState = (events) => {
11243
11404
  let ended;
11244
11405
  /** Nodes whose last activation did not finish well, and its session. */
11245
11406
  const unfinished = /* @__PURE__ */ new Map();
11407
+ /** A new run of `id`'s `round` replaces what is recorded there and after. */
11408
+ const supersede = (id, round) => {
11409
+ fanOuts.items.get(id)?.delete(round);
11410
+ const kept = history.get(id)?.slice(0, round - 1);
11411
+ if (kept === void 0) return;
11412
+ if (kept.length === 0) history.delete(id);
11413
+ else history.set(id, kept);
11414
+ };
11415
+ /** A list resolved anew: what its items did came from another list. */
11416
+ const forgetItems = (node, round) => {
11417
+ for (const [id, rounds] of fanOuts.items) if (isItemOf(id, node)) rounds.delete(round);
11418
+ for (const id of unfinished.keys()) if (isItemOf(id, node)) unfinished.delete(id);
11419
+ };
11246
11420
  for (const event of events) {
11247
11421
  if (event.type === "run.started") {
11248
11422
  inputs = event.inputs ?? inputs;
11249
11423
  settings = event.settings ?? settings;
11424
+ closed.clear();
11250
11425
  continue;
11251
11426
  }
11252
11427
  if (event.type === "node.started") {
11253
- if (event.owner === void 0) open.set(event.id, event.item === void 0 ? {} : { item: true });
11428
+ if (event.owner === void 0) {
11429
+ open.set(event.id, event.item === void 0 ? {} : { item: true });
11430
+ supersede(event.id, event.round);
11431
+ }
11254
11432
  continue;
11255
11433
  }
11256
11434
  if (event.type === "each.listed") {
11257
11435
  fanOuts.lists.set(listKey(event.node, event.round), event.list);
11436
+ if (event.anew === true) forgetItems(event.node, event.round);
11258
11437
  continue;
11259
11438
  }
11260
11439
  if (event.type === "node.event") {
@@ -11274,6 +11453,7 @@ const resumeState = (events) => {
11274
11453
  if (current.sessionId !== void 0) unfinished.set(event.id, current.sessionId);
11275
11454
  continue;
11276
11455
  }
11456
+ unfinished.delete(event.id);
11277
11457
  const result = {
11278
11458
  ok: true,
11279
11459
  output: event.output
@@ -13479,6 +13659,13 @@ const syncFrom = async (worktree, from) => {
13479
13659
  return snapshot(worktree);
13480
13660
  };
13481
13661
  // ../core/src/worktree/upstream.ts
13662
+ /** Where `id` worked: its own place, or for a fanned node each item's. */
13663
+ const placesOf = (id, placeOf) => {
13664
+ const own = placeOf.get(id);
13665
+ if (own !== void 0) return [own];
13666
+ const item = `${id}.`;
13667
+ return [...placeOf].filter(([other]) => other.startsWith(item)).map(([, place]) => place);
13668
+ };
13482
13669
  /**
13483
13670
  * The one place `id`'s upstream nodes worked in other than `root`, or
13484
13671
  * `undefined` when there is none or they disagree. `placeOf` is where each node
@@ -13497,8 +13684,7 @@ const upstreamPlace = (graph, id, placeOf, root) => {
13497
13684
  queue.push(...(node?.incoming ?? []).map((e) => e.from));
13498
13685
  continue;
13499
13686
  }
13500
- const place = placeOf.get(from);
13501
- if (place !== void 0 && place !== root) places.add(place);
13687
+ for (const place of placesOf(from, placeOf)) if (place !== root) places.add(place);
13502
13688
  }
13503
13689
  const [only, other] = places;
13504
13690
  return other === void 0 ? only : void 0;
@@ -13704,6 +13890,18 @@ const closeWorktrees = async (options) => {
13704
13890
  }
13705
13891
  return kept;
13706
13892
  };
13893
+ // ../core/src/worktree/conflict.ts
13894
+ const withConflict = (result, conflicts, into) => ({
13895
+ ...result,
13896
+ output: {
13897
+ ...result.output,
13898
+ merge: {
13899
+ ok: false,
13900
+ conflicts,
13901
+ into
13902
+ }
13903
+ }
13904
+ });
13707
13905
  // ../core/src/worktree/workspace.ts
13708
13906
  /**
13709
13907
  * @file Where each node of a run works (ADR-020), under the project's git rules
@@ -13716,6 +13914,7 @@ const closeWorktrees = async (options) => {
13716
13914
  * its owner: a merge, a patch, or not at all. A spawned worker works like the
13717
13915
  * declared child it is an instance of.
13718
13916
  */
13917
+ const ITEM_FAILED = "item failed; kept for inspection";
13719
13918
  const createWorkspace = (options) => {
13720
13919
  const { graph, runId, root, head, onEvent } = options;
13721
13920
  const rules = gitRulesOf(graph.workflow);
@@ -13863,6 +14062,8 @@ const createWorkspace = (options) => {
13863
14062
  }
13864
14063
  const next = await settle(id, id, worktree, result);
13865
14064
  outputs.set(id, next.output);
14065
+ if (!next.ok && templateOf.has(id) && !unmerged.has(id)) unmerged.set(id, ITEM_FAILED);
14066
+ if (next.ok && unmerged.get(id) === ITEM_FAILED) unmerged.delete(id);
13866
14067
  const owner = ownerOf(id);
13867
14068
  const ownerTree = owner === void 0 ? void 0 : worktrees.get(owner);
13868
14069
  if (!next.ok || owner === void 0 || ownerTree === void 0) return next;
@@ -13880,17 +14081,7 @@ const createWorkspace = (options) => {
13880
14081
  const files = outcome.conflicts.join(", ");
13881
14082
  unmerged.set(id, `merge conflict${files === "" ? "" : ` in ${files}`}`);
13882
14083
  event(id, "conflict", worktree, files === "" ? outcome.error : files);
13883
- return {
13884
- ...next,
13885
- output: {
13886
- ...next.output,
13887
- merge: {
13888
- ok: false,
13889
- conflicts: outcome.conflicts,
13890
- into: nameOf(ownerTree)
13891
- }
13892
- }
13893
- };
14084
+ return withConflict(next, outcome.conflicts, nameOf(ownerTree));
13894
14085
  };
13895
14086
  const close = () => closeWorktrees({
13896
14087
  root,