@krapht/ork 0.3.0 → 1.0.0-alpha.0

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 (39) hide show
  1. package/README.md +16 -12
  2. package/dist/__build_info__-DdTVd2p5.mjs +615 -0
  3. package/dist/bin.mjs +1572 -909
  4. package/dist/clean-NnboOuz6.mjs +88 -0
  5. package/dist/completion-snnYGjgK.mjs +405 -0
  6. package/dist/{context-BFGC_C02.mjs → context-DO3sBy9v.mjs} +30 -191
  7. package/dist/decide-B81wCZQg.mjs +113 -0
  8. package/dist/{find-BcvhT6UN.mjs → find-BKW-uGey.mjs} +1 -1
  9. package/dist/{format-DqpObyMl.mjs → format-CmTlTOjT.mjs} +72 -198
  10. package/dist/help-groups-BwYoAWsh.mjs +354 -0
  11. package/dist/{in-a-run-BbLhZOFW.mjs → in-a-run-BPXX09Bs.mjs} +1 -1
  12. package/dist/{limits-BKYAmqCR.mjs → limits-JCve2QpI.mjs} +1 -1
  13. package/dist/list-C7UEgmtd.mjs +110 -0
  14. package/dist/{mcp-BWsdQyri.mjs → mcp-CKPyNaKD.mjs} +7 -7
  15. package/dist/{output-Q2EdeVUt.mjs → output-B1V6ZJSx.mjs} +130 -36
  16. package/dist/{pause-DKACDfV1.mjs → pause-B_SL_yDz.mjs} +4 -4
  17. package/dist/{relay-CbK-Wme9.mjs → relay-o-Luee1c.mjs} +2 -2
  18. package/dist/remove-DAZ7ZCzt.mjs +194 -0
  19. package/dist/{send-C6yr04Fe.mjs → send-3B64fBNB.mjs} +3 -3
  20. package/dist/{src-D5ruZFgD.mjs → src-BJTegEzY.mjs} +1212 -57
  21. package/dist/{src-DtN1k-B3.mjs → src-C38BPzit.mjs} +251 -18
  22. package/dist/{src-Cz1yOUTg.mjs → src-DHvWU4tX.mjs} +2742 -1659
  23. package/dist/{src-CZOVF2yU.mjs → src-Dyz_Gy1q.mjs} +35 -8
  24. package/dist/ui/THIRD_PARTY_LICENSES.txt +2 -2
  25. package/dist/ui/assets/app-C8nJ6NpT.js +67 -0
  26. package/dist/ui/assets/app-CH7YC3in.css +1 -0
  27. package/dist/ui/assets/{index-BiO54L90.css → index-BPIbqRRb.css} +1 -1
  28. package/dist/ui/assets/{index-BoG6O-Lp.js → index-D83F2OKZ.js} +2 -2
  29. package/dist/ui/index.html +2 -3
  30. package/dist/ui-CY4xxJUu.mjs +472 -0
  31. package/dist/{usage-BIjf9Xdt.mjs → usage-CJnytoe4.mjs} +6 -5
  32. package/dist/{watch-B8o4Szd_.mjs → watch-BJB82ZJ7.mjs} +7 -7
  33. package/dist/workflows-CiM7S3l0.mjs +88 -0
  34. package/dist/worktrees-86ySXxR6.mjs +212 -0
  35. package/package.json +6 -6
  36. package/dist/__build_info__-CXXMLvjt.mjs +0 -104
  37. package/dist/ui/assets/app-BlDdP396.js +0 -61
  38. package/dist/ui/assets/app-C722xNZ3.css +0 -1
  39. package/dist/ui-CZS1XKla.mjs +0 -160
@@ -1,4 +1,4 @@
1
- import { A as declaredProblem, An as integrationFor, At as evaluate, Cn as beforeOf, Dn as timeoutMs, E as planNodeSession, En as programsRunBy, Mt as renderTemplate, On as INTEGRATIONS, Tn as isToolNode, _n as markValue, _t as calledWorkflows, bt as declaredOutputOf, ht as runScope, jt as parseTemplate, kn as TOOL_PREFIX, mt as heldTimeout, s as planReason, vt as envReadsOf } from "./src-Cz1yOUTg.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-DHvWU4tX.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,19 @@ 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
+ const driver = sessionDriver({
1594
+ override: options.driver,
1595
+ named: plan.driver,
1596
+ ready: options.readyDriver
1597
+ });
1598
+ if (driver === void 0) return notStarted("no Claude driver is ready; see `ork doctor`");
1599
+ const runtime = await options.runtimeFor(driver);
1600
+ const ports = { human: context.owner === void 0 ? context.human : ownerFirst(context, context.owner, {
1601
+ runtimeFor: options.runtimeFor,
1602
+ driver: options.driver,
1603
+ readyDriver: options.readyDriver,
1604
+ inPlace: options.inPlace
1605
+ }) };
1391
1606
  if (context.hold !== void 0) ports.hold = context.hold;
1392
1607
  if (context.ownership !== void 0) ports.ownership = context.ownership;
1393
1608
  if (context.mailbox !== void 0) ports.mailbox = context.mailbox;
@@ -1447,26 +1662,38 @@ const claudeExecutor = (options) => async (context) => {
1447
1662
  const { node } = context;
1448
1663
  const verify = "verify" in node ? node.verify : void 0;
1449
1664
  if (outcome === null || verify === void 0 || !isAcceptance(outcome)) return watch.result(resultOf(outcome, declaredOutputOf(node)));
1665
+ let tries = 0;
1666
+ const step = () => ({
1667
+ name: "verify",
1668
+ try: tries
1669
+ });
1450
1670
  const checked = await checkReport({
1451
1671
  verify,
1452
1672
  first: outcome,
1453
1673
  signal: context.signal,
1454
- check: () => runStep(options.steps ?? noSteps, context, verify),
1674
+ check: () => {
1675
+ tries += 1;
1676
+ return runStep(options.steps ?? noSteps, context, verify, step());
1677
+ },
1455
1678
  late: lateTurn,
1456
1679
  resume: async (current, check) => {
1457
1680
  const limits = limitsAfter(current);
1458
1681
  if (limits === null) return null;
1459
1682
  await context.hold?.wait("start", context.signal);
1460
- return context.signal.aborted ? null : resumeWith(current, checkFailedMessage(check, context), limits);
1683
+ return context.signal.aborted ? null : resumeWith(current, checkFailedMessage(check, {
1684
+ ...context,
1685
+ step: step()
1686
+ }), limits);
1461
1687
  }
1462
1688
  });
1463
1689
  return watch.result(checkedResult(resultOf(checked.outcome, declaredOutputOf(node)), checked));
1464
1690
  };
1465
1691
  // ../nodes/src/gate.ts
1466
1692
  /**
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).
1693
+ * @file The `human.gate` node type: asks the human the rendered question,
1694
+ * outputs their choice with the note they gave, and answers with the
1695
+ * `default` when the `timeout` passes. The timeout counts only while the run
1696
+ * and the gate are not paused (ADR-041).
1470
1697
  */
1471
1698
  /** A promise that never settles: the losing side of a race. */
1472
1699
  const NEVER = new Promise(() => {});
@@ -1478,8 +1705,8 @@ const choiceOf = (option) => {
1478
1705
  return choice;
1479
1706
  };
1480
1707
  /**
1481
- * Asks the human and outputs `choice` and `answeredBy`. With `timeout`, the
1482
- * `default` answers when no one has.
1708
+ * Asks the human and outputs `choice`, `answeredBy` and `note` (empty when they
1709
+ * said nothing). With `timeout`, the `default` answers when no one has.
1483
1710
  */
1484
1711
  const gateExecutor = async (context) => {
1485
1712
  const { node } = context;
@@ -1495,16 +1722,21 @@ const gateExecutor = async (context) => {
1495
1722
  context.signal.addEventListener("abort", () => {
1496
1723
  withdraw.abort();
1497
1724
  });
1725
+ let note = "";
1498
1726
  const asked = {
1499
1727
  nodeId: context.id,
1500
1728
  question,
1501
1729
  options,
1502
- signal: withdraw.signal
1730
+ signal: withdraw.signal,
1731
+ onNote: (given) => {
1732
+ note = given;
1733
+ }
1503
1734
  };
1504
1735
  if (node.recommended !== void 0) asked.recommended = node.recommended;
1505
1736
  const answer = context.human.ask(asked).then((choice) => ({
1506
1737
  choice,
1507
- answeredBy: "user"
1738
+ answeredBy: "user",
1739
+ note
1508
1740
  }));
1509
1741
  const ms = timeoutMs(node.timeout);
1510
1742
  const answered = new AbortController();
@@ -1513,7 +1745,8 @@ const gateExecutor = async (context) => {
1513
1745
  withdraw.abort();
1514
1746
  return {
1515
1747
  choice: node.default ?? "",
1516
- answeredBy: "timeout"
1748
+ answeredBy: "timeout",
1749
+ note: ""
1517
1750
  };
1518
1751
  })];
1519
1752
  try {