@krapht/ork 0.4.0 → 1.0.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/README.md +16 -12
  2. package/dist/{__build_info__-CtSVVuZE.mjs → __build_info__-D9lSlMFy.mjs} +36 -112
  3. package/dist/bin.mjs +688 -306
  4. package/dist/{clean-Dcr807be.mjs → clean-CA_Oi2Wl.mjs} +1 -1
  5. package/dist/completion-DBGfL-gN.mjs +405 -0
  6. package/dist/{context-C_bksb0C.mjs → context-6x3kXREO.mjs} +11 -88
  7. package/dist/decide-CoIJh34e.mjs +113 -0
  8. package/dist/{find-DptmbhGd.mjs → find-rBBk7Jui.mjs} +1 -1
  9. package/dist/{format-J-a21Yvn.mjs → format-zMmhqyLE.mjs} +71 -37
  10. package/dist/help-groups-BwYoAWsh.mjs +354 -0
  11. package/dist/{in-a-run-D1Qc5Mit.mjs → in-a-run-DdAakpRR.mjs} +1 -1
  12. package/dist/{limits-BDZCmGEu.mjs → limits-BPM9wUlV.mjs} +1 -1
  13. package/dist/{list-ZvY2OcLq.mjs → list-Bv5Lxu7z.mjs} +1 -1
  14. package/dist/{mcp-CHVKKg3B.mjs → mcp-DM30MFBw.mjs} +7 -7
  15. package/dist/{output-CXSJO00X.mjs → output-TV_j7Ni0.mjs} +67 -17
  16. package/dist/{pause-BPSmg4yz.mjs → pause-C7lJkSma.mjs} +4 -4
  17. package/dist/{relay-Do-aNbSD.mjs → relay-BipJYszF.mjs} +2 -2
  18. package/dist/{remove-AwaMMaHf.mjs → remove-BDs9isB6.mjs} +3 -3
  19. package/dist/{send-DhGBkowy.mjs → send-nbkjf3sc.mjs} +3 -3
  20. package/dist/{src-CpDKIulI.mjs → src-B0TT7DBi.mjs} +28 -15
  21. package/dist/{src-BHnxpJyH.mjs → src-B9v0lDc6.mjs} +58 -10
  22. package/dist/{src-UWkk1sl9.mjs → src-BxUJPGa5.mjs} +252 -18
  23. package/dist/{src-CGqMati3.mjs → src-hrj2h0IZ.mjs} +640 -220
  24. package/dist/ui/THIRD_PARTY_LICENSES.txt +2 -2
  25. package/dist/ui/assets/app-BNT0SuIz.css +1 -0
  26. package/dist/ui/assets/app-CmkynZyW.js +67 -0
  27. package/dist/ui/assets/{index-BiO54L90.css → index-BPIbqRRb.css} +1 -1
  28. package/dist/ui/assets/{index-MQNNgsyT.js → index-CPx74lFf.js} +2 -2
  29. package/dist/ui/index.html +2 -3
  30. package/dist/{ui-DhutUMgQ.mjs → ui-B98FSboa.mjs} +5 -5
  31. package/dist/{usage-CT7Z-yjr.mjs → usage-EW7DI6Z7.mjs} +6 -6
  32. package/dist/{watch-0Qr2UfCQ.mjs → watch-BAkSGaOp.mjs} +7 -7
  33. package/dist/workflows-vsEohqa7.mjs +88 -0
  34. package/dist/{worktrees-DHl80k96.mjs → worktrees-Ahj9rdWK.mjs} +5 -5
  35. package/package.json +3 -3
  36. package/dist/ui/assets/app-BgVDC_5l.js +0 -67
  37. package/dist/ui/assets/app-DVf68fj5.css +0 -1
@@ -1,4 +1,4 @@
1
- import { Ct as runScope, Et as envReadsOf, Gn as TOOL_PREFIX, Gt as parseTemplate, Hn as programsRunBy, Kn as integrationFor, Kt as renderTemplate, M as declaredProblem, Nn as markValue, Ot as declaredOutputOf, St as heldTimeout, Tt as calledWorkflows, Un as timeoutMs, Vn as isToolNode, Wn as INTEGRATIONS, Wt as evaluate, s as planReason, st as planNodeSession, zn as beforeOf } from "./src-CGqMati3.mjs";
1
+ import { $n as timeoutMs, $t as parseTemplate, At as runScope, F as declaredProblem, Jn as beforeOf, Mt as envReadsOf, Ot as heldTimeout, Pt as declaredOutputOf, Qt as evaluate, Vn as markValue, Xn as isToolNode, Zn as programsRunBy, en as renderTemplate, er as INTEGRATIONS, ft as sessionDriver, jt as calledWorkflows, mt as reportSchemaFor, nr as integrationFor, s as planReason, tr as TOOL_PREFIX, ut as planNodeSession } from "./src-hrj2h0IZ.mjs";
2
2
  import path from "node:path";
3
3
  import { accessSync, constants, existsSync, lstatSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
4
4
  import { spawn } from "node:child_process";
@@ -123,6 +123,196 @@ const resultOf = (outcome, declared) => {
123
123
  if (!result.ok) result.error = report.summary;
124
124
  return result;
125
125
  };
126
+ /** The only built-in tools a fork has: it reads, and reports. */
127
+ const FORK_TOOLS = [
128
+ "Read",
129
+ "Grep",
130
+ "Glob"
131
+ ];
132
+ /**
133
+ * The option that passes a question on: `ask-user`, or the first free
134
+ * `ask-user-<n>`.
135
+ */
136
+ const passOnId = (options) => {
137
+ let id = "ask-user";
138
+ for (let n = 2; options.some((option) => option.id === id); n += 1) id = `ask-user-${String(n)}`;
139
+ return id;
140
+ };
141
+ const optionLine = (option) => [
142
+ `- \`${option.id}\``,
143
+ option.label === void 0 ? "" : `: ${option.label}`,
144
+ option.description === void 0 ? "" : ` (${option.description})`
145
+ ].join("");
146
+ /** What the fork is asked. */
147
+ const forkPrompt = (question, passOn) => [
148
+ `[ork] \`${question.nodeId}\`, which you own, asks a question (ask_user). You take its questions before the user does.`,
149
+ "",
150
+ `Question: ${question.question}`,
151
+ ...question.context === void 0 ? [] : [`Context: ${question.context}`],
152
+ "Options:",
153
+ ...question.options.map(optionLine),
154
+ ...question.recommended === void 0 ? [] : [`It recommends \`${question.recommended}\`.`],
155
+ "",
156
+ `If your task, and what was agreed with the user, already settle it, report that option as \`choice\`. If they don't, or it is the user's to decide, report \`${passOn}\` to pass it on to the user. Either way, say why in \`note\`, in a sentence or two: it goes to \`${question.nodeId}\` with your answer, or to the user with the question. This is a side conversation: change nothing, and your own session goes on as it was.`
157
+ ].join("\n");
158
+ /** The fork's report: one of the options or the pass-on, and a note. */
159
+ const forkReportSchema = (ids) => {
160
+ const node = {
161
+ type: "claude.worker",
162
+ prompt: "",
163
+ output: { schema: {
164
+ choice: [...ids],
165
+ note: "string"
166
+ } }
167
+ };
168
+ return reportSchemaFor(node);
169
+ };
170
+ /** A fork can't ask anyone: it answers or passes the question on. */
171
+ const NO_ONE = {
172
+ permit: () => Promise.resolve({
173
+ allow: false,
174
+ message: "A fork answering a question can't ask for permission."
175
+ }),
176
+ ask: () => Promise.reject(/* @__PURE__ */ new Error("a fork can't ask the user"))
177
+ };
178
+ /** The fork's session spec, from the leader's own plan; `null` if it can't. */
179
+ const forkSpec = (context, owner, question, passOn, deps) => {
180
+ const session = owner.session();
181
+ const left = owner.budgetLeftUsd();
182
+ if (session === void 0 || left !== void 0 && left <= 0) return null;
183
+ const plan = planNodeSession(context.workflow, owner.id, {
184
+ cwd: session.cwd,
185
+ inputs: context.inputs,
186
+ outputs: context.outputs,
187
+ inPlace: deps.inPlace,
188
+ workflowDir: context.workflowDir,
189
+ projectRoot: context.projectRoot,
190
+ ...context.orkDir === void 0 ? {} : { orkDir: context.orkDir },
191
+ ...left === void 0 ? {} : { budgetLeftUsd: left }
192
+ });
193
+ if (!plan.ok) return null;
194
+ const driver = session.driver ?? sessionDriver({
195
+ override: deps.driver,
196
+ named: plan.driver,
197
+ ready: deps.readyDriver
198
+ });
199
+ if (driver === void 0) return null;
200
+ const ids = [...question.options.map((option) => option.id), passOn];
201
+ const spec = {
202
+ ...plan.spec,
203
+ cwd: session.cwd,
204
+ prompt: forkPrompt(question, passOn),
205
+ resume: session.sessionId,
206
+ fork: true,
207
+ maxTurns: 3,
208
+ tools: {
209
+ allow: [...FORK_TOOLS],
210
+ deny: [...plan.spec.tools.deny, "mcp__ork__ask_user"],
211
+ only: [...FORK_TOOLS],
212
+ strictMcp: true
213
+ },
214
+ reportSchema: forkReportSchema(ids)
215
+ };
216
+ delete spec.promptCache;
217
+ delete spec.agents;
218
+ delete spec.plugins;
219
+ return {
220
+ driver,
221
+ spec
222
+ };
223
+ };
224
+ /** The choice a fork's outcome gives, if it is a valid one. */
225
+ const answerOf = (outcome, ids) => {
226
+ const { choice, note } = outcome?.report?.fields ?? {};
227
+ if (outcome?.subtype !== "success" || typeof choice !== "string") return null;
228
+ if (!ids.includes(choice)) return null;
229
+ return typeof note === "string" && note.trim() !== "" ? {
230
+ choice,
231
+ note: note.trim()
232
+ } : { choice };
233
+ };
234
+ /**
235
+ * Asks a fork of the owner's latest session; `null` when it can't answer: no
236
+ * session yet, nothing left to spend, the node stopped, or a fork that fails,
237
+ * runs out of time or gives no valid choice. A paused run or node starts no
238
+ * fork until it goes on; one already started isn't paused, as it is bounded by
239
+ * its turns and `timeoutMs`. Its events are the owner's, each marked as a fork,
240
+ * and its spend the run's.
241
+ */
242
+ const askFork = async (context, owner, question, deps) => {
243
+ const passOn = passOnId(question.options);
244
+ await context.hold?.wait("start", context.signal);
245
+ if (context.signal.aborted) return null;
246
+ const planned = forkSpec(context, owner, question, passOn, deps);
247
+ if (planned === null) return null;
248
+ const signal = AbortSignal.any([context.signal, AbortSignal.timeout(deps.timeoutMs ?? 3e5)]);
249
+ let outcome = null;
250
+ try {
251
+ const session = (await deps.runtimeFor(planned.driver)).start(planned.spec, { human: NO_ONE });
252
+ const stop = () => {
253
+ session.interrupt();
254
+ };
255
+ signal.addEventListener("abort", stop, { once: true });
256
+ if (signal.aborted) stop();
257
+ try {
258
+ for await (const event of session.events) {
259
+ if (event.type === "session.finished") {
260
+ ({outcome} = event);
261
+ owner.charge(outcome.sessionId, outcome.usage);
262
+ }
263
+ owner.emit({
264
+ ...event,
265
+ fork: true
266
+ });
267
+ }
268
+ } finally {
269
+ signal.removeEventListener("abort", stop);
270
+ }
271
+ } catch {
272
+ return null;
273
+ }
274
+ if (signal.aborted) return null;
275
+ return answerOf(outcome, [...question.options.map((option) => option.id), passOn]);
276
+ };
277
+ /** What the person sees of a question its asker's owner passed on. */
278
+ const passedOnContext = (context, owner, note) => [...context === void 0 ? [] : [context, ""], note === void 0 ? `${owner} passed this on.` : `${owner} passed this on: ${note}`].join("\n");
279
+ /**
280
+ * The child's channel when its owner takes its questions first: a question goes
281
+ * to a fork of the owner's session, then to the person if the fork passes it on
282
+ * or can't answer. Permission prompts go to the person as before.
283
+ */
284
+ const ownerFirst = (context, owner, deps) => {
285
+ const { human } = context;
286
+ return {
287
+ permit: (request) => human.permit(request),
288
+ ask: async (question) => {
289
+ const answered = await askFork(context, owner, question, deps);
290
+ if (context.signal.aborted) throw new Error(`${context.id} was stopped`);
291
+ const signal = question.signal === void 0 ? context.signal : AbortSignal.any([question.signal, context.signal]);
292
+ if (answered === null) return human.ask({
293
+ ...question,
294
+ signal
295
+ });
296
+ if (answered.choice === passOnId(question.options)) return human.ask({
297
+ ...question,
298
+ signal,
299
+ owner: owner.id,
300
+ context: passedOnContext(question.context, owner.id, answered.note)
301
+ });
302
+ const { note } = answered;
303
+ human.settled?.({
304
+ ...question,
305
+ answer: answered.choice,
306
+ owner: owner.id,
307
+ ...note === void 0 ? {} : { note }
308
+ });
309
+ question.onOwner?.(owner.id);
310
+ if (note !== void 0) question.onNote?.(note);
311
+ return answered.choice;
312
+ },
313
+ ...human.settled === void 0 ? {} : { settled: human.settled }
314
+ };
315
+ };
126
316
  // ../nodes/src/session-turns.ts
127
317
  /**
128
318
  * @file One session over several processes: the outcome after another turn, and
@@ -235,7 +425,8 @@ const notStarted = (reason, limited) => ({
235
425
  * result that cannot be read fails the node rather than turning into an empty
236
426
  * value, and one too large to keep in an event is never cut. What a program
237
427
  * wrote is not yet scrubbed, so these files live in a private folder that a
238
- * run clears when it starts and ends.
428
+ * run clears when it starts and ends. A long field is kept in the run's
429
+ * files, named for the node, its round, and its step and try.
239
430
  */
240
431
  /** The run's private folder for results not yet read. */
241
432
  const OUTPUT_FOLDER = ".output";
@@ -278,8 +469,14 @@ const prepareOutputFile = (runDir, id, round) => {
278
469
  removeOutputFile(file);
279
470
  return file;
280
471
  };
472
+ /**
473
+ * A node's activation, and the step and its try when it is one, as a file
474
+ * name's start: `review.1`, `review.1.before-1`, `review.1.verify-2`. Two
475
+ * steps, or two tries of a check, never share a file.
476
+ */
477
+ const keptStem = ({ id, round, step }) => `${id}.${String(round)}${step === void 0 ? "" : `.${step.name}-${String(step.try)}`}`;
281
478
  /** Where a long field of a node's output is kept. */
282
- const keptFile = (context, field) => path.join(context.runDir, `${context.id}.${String(context.round)}.${field}.txt`);
479
+ const keptFile = (context, field) => path.join(context.runDir, `${keptStem(context)}.${field}.txt`);
283
480
  /** A way to write a field of a node's output to the run's files, when it's long. */
284
481
  const keepFile = (context, field, text) => () => {
285
482
  mkdirSync(context.runDir, { recursive: true });
@@ -1168,11 +1365,15 @@ const TAIL_LIMIT = 8192;
1168
1365
  */
1169
1366
  const isAcceptance = (outcome) => outcome.report !== null && outcome.report.status !== "fail" && outcome.report.status !== "changes_requested";
1170
1367
  /** Runs a step as a one-off tool node, in the node's checkout, as the node. */
1171
- const runStep = async (tool, context, { timeout, ...fields }) => {
1368
+ const runStep = async (tool, given, { timeout, ...fields }, step) => {
1172
1369
  const node = {
1173
1370
  type: "tool.shell",
1174
1371
  ...fields
1175
1372
  };
1373
+ const context = {
1374
+ ...given,
1375
+ step
1376
+ };
1176
1377
  const ms = timeoutMs(timeout);
1177
1378
  if (ms === void 0) return tool({
1178
1379
  ...context,
@@ -1225,7 +1426,7 @@ const tailOf = (check, context) => {
1225
1426
  if (said === "") return "It printed nothing.";
1226
1427
  if (said.length <= TAIL_LIMIT) return `\`\`\`\n${said}\n\`\`\``;
1227
1428
  mkdirSync(context.runDir, { recursive: true });
1228
- const file = path.join(context.runDir, `${context.id}.${String(context.round)}.verify.txt`);
1429
+ const file = path.join(context.runDir, `${keptStem(context)}.txt`);
1229
1430
  writeFileSync(file, said);
1230
1431
  return `The end of what it printed, with all of it in ${file}:\n\n\`\`\`\n${said.slice(-8192)}\n\`\`\``;
1231
1432
  };
@@ -1362,7 +1563,10 @@ const claudeExecutor = (options) => async (context) => {
1362
1563
  if (context.claims !== void 0) planOptions.claims = context.claims;
1363
1564
  const before = beforeOf(context.node, context.workflow.defaults);
1364
1565
  if (before !== void 0) {
1365
- const ran = await runStep(options.steps ?? noSteps, context, before);
1566
+ const ran = await runStep(options.steps ?? noSteps, context, before, {
1567
+ name: "before",
1568
+ try: 1
1569
+ });
1366
1570
  if (!ran.ok) return beforeFailed(ran);
1367
1571
  await context.hold?.wait("start", context.signal);
1368
1572
  if (context.signal.aborted) return beforeFailed({
@@ -1386,8 +1590,20 @@ const claudeExecutor = (options) => async (context) => {
1386
1590
  error: plan.error
1387
1591
  };
1388
1592
  const spec = specFor(plan.spec, context);
1389
- const runtime = await options.runtimeFor(options.driver ?? plan.driver);
1390
- const ports = { human: context.human };
1593
+ if (options.machineClaude === true) spec.machineClaude = true;
1594
+ const driver = sessionDriver({
1595
+ override: options.driver,
1596
+ named: plan.driver,
1597
+ ready: options.readyDriver
1598
+ });
1599
+ if (driver === void 0) return notStarted("no Claude driver is ready; see `ork doctor`");
1600
+ const runtime = await options.runtimeFor(driver);
1601
+ const ports = { human: context.owner === void 0 ? context.human : ownerFirst(context, context.owner, {
1602
+ runtimeFor: options.runtimeFor,
1603
+ driver: options.driver,
1604
+ readyDriver: options.readyDriver,
1605
+ inPlace: options.inPlace
1606
+ }) };
1391
1607
  if (context.hold !== void 0) ports.hold = context.hold;
1392
1608
  if (context.ownership !== void 0) ports.ownership = context.ownership;
1393
1609
  if (context.mailbox !== void 0) ports.mailbox = context.mailbox;
@@ -1447,26 +1663,38 @@ const claudeExecutor = (options) => async (context) => {
1447
1663
  const { node } = context;
1448
1664
  const verify = "verify" in node ? node.verify : void 0;
1449
1665
  if (outcome === null || verify === void 0 || !isAcceptance(outcome)) return watch.result(resultOf(outcome, declaredOutputOf(node)));
1666
+ let tries = 0;
1667
+ const step = () => ({
1668
+ name: "verify",
1669
+ try: tries
1670
+ });
1450
1671
  const checked = await checkReport({
1451
1672
  verify,
1452
1673
  first: outcome,
1453
1674
  signal: context.signal,
1454
- check: () => runStep(options.steps ?? noSteps, context, verify),
1675
+ check: () => {
1676
+ tries += 1;
1677
+ return runStep(options.steps ?? noSteps, context, verify, step());
1678
+ },
1455
1679
  late: lateTurn,
1456
1680
  resume: async (current, check) => {
1457
1681
  const limits = limitsAfter(current);
1458
1682
  if (limits === null) return null;
1459
1683
  await context.hold?.wait("start", context.signal);
1460
- return context.signal.aborted ? null : resumeWith(current, checkFailedMessage(check, context), limits);
1684
+ return context.signal.aborted ? null : resumeWith(current, checkFailedMessage(check, {
1685
+ ...context,
1686
+ step: step()
1687
+ }), limits);
1461
1688
  }
1462
1689
  });
1463
1690
  return watch.result(checkedResult(resultOf(checked.outcome, declaredOutputOf(node)), checked));
1464
1691
  };
1465
1692
  // ../nodes/src/gate.ts
1466
1693
  /**
1467
- * @file The `human.gate` node type: asks the human the rendered question, and
1468
- * answers with the `default` when the `timeout` passes. The timeout counts
1469
- * only while the run and the gate are not paused (ADR-041).
1694
+ * @file The `human.gate` node type: asks the human the rendered question,
1695
+ * outputs their choice with the note they gave, and answers with the
1696
+ * `default` when the `timeout` passes. The timeout counts only while the run
1697
+ * and the gate are not paused (ADR-041).
1470
1698
  */
1471
1699
  /** A promise that never settles: the losing side of a race. */
1472
1700
  const NEVER = new Promise(() => {});
@@ -1478,8 +1706,8 @@ const choiceOf = (option) => {
1478
1706
  return choice;
1479
1707
  };
1480
1708
  /**
1481
- * Asks the human and outputs `choice` and `answeredBy`. With `timeout`, the
1482
- * `default` answers when no one has.
1709
+ * Asks the human and outputs `choice`, `answeredBy` and `note` (empty when they
1710
+ * said nothing). With `timeout`, the `default` answers when no one has.
1483
1711
  */
1484
1712
  const gateExecutor = async (context) => {
1485
1713
  const { node } = context;
@@ -1495,16 +1723,21 @@ const gateExecutor = async (context) => {
1495
1723
  context.signal.addEventListener("abort", () => {
1496
1724
  withdraw.abort();
1497
1725
  });
1726
+ let note = "";
1498
1727
  const asked = {
1499
1728
  nodeId: context.id,
1500
1729
  question,
1501
1730
  options,
1502
- signal: withdraw.signal
1731
+ signal: withdraw.signal,
1732
+ onNote: (given) => {
1733
+ note = given;
1734
+ }
1503
1735
  };
1504
1736
  if (node.recommended !== void 0) asked.recommended = node.recommended;
1505
1737
  const answer = context.human.ask(asked).then((choice) => ({
1506
1738
  choice,
1507
- answeredBy: "user"
1739
+ answeredBy: "user",
1740
+ note
1508
1741
  }));
1509
1742
  const ms = timeoutMs(node.timeout);
1510
1743
  const answered = new AbortController();
@@ -1513,7 +1746,8 @@ const gateExecutor = async (context) => {
1513
1746
  withdraw.abort();
1514
1747
  return {
1515
1748
  choice: node.default ?? "",
1516
- answeredBy: "timeout"
1749
+ answeredBy: "timeout",
1750
+ note: ""
1517
1751
  };
1518
1752
  })];
1519
1753
  try {