@krapht/ork 0.2.0 → 0.3.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 (64) hide show
  1. package/THIRD_PARTY_NOTICES.md +7 -0
  2. package/dist/__build_info__-CXXMLvjt.mjs +104 -0
  3. package/dist/bin.mjs +859 -3037
  4. package/dist/context-BFGC_C02.mjs +435 -0
  5. package/dist/find-BcvhT6UN.mjs +47 -0
  6. package/dist/format-DqpObyMl.mjs +553 -0
  7. package/dist/in-a-run-BbLhZOFW.mjs +22 -0
  8. package/dist/limits-BKYAmqCR.mjs +44 -0
  9. package/dist/mcp-BWsdQyri.mjs +91 -0
  10. package/dist/output-Q2EdeVUt.mjs +856 -0
  11. package/dist/pause-DKACDfV1.mjs +65 -0
  12. package/dist/relay-CbK-Wme9.mjs +122 -0
  13. package/dist/report-DK4LGqwF.mjs +129 -0
  14. package/dist/send-C6yr04Fe.mjs +163 -0
  15. package/dist/{src-DAsOYtoR.mjs → src-CZOVF2yU.mjs} +496 -193
  16. package/dist/src-Cz1yOUTg.mjs +11841 -0
  17. package/dist/src-D5ruZFgD.mjs +1973 -0
  18. package/dist/src-DtN1k-B3.mjs +1899 -0
  19. package/dist/ui/THIRD_PARTY_LICENSES.txt +631 -0
  20. package/dist/ui/assets/app-BlDdP396.js +61 -0
  21. package/dist/ui/assets/app-C722xNZ3.css +1 -0
  22. package/dist/ui/assets/geist-mono-cyrillic-400-normal-C51Di1Mf.woff2 +0 -0
  23. package/dist/ui/assets/geist-mono-cyrillic-400-normal-CPJFfJgk.woff +0 -0
  24. package/dist/ui/assets/geist-mono-cyrillic-500-normal-D2cXvDHF.woff2 +0 -0
  25. package/dist/ui/assets/geist-mono-cyrillic-500-normal-DfuvdVgn.woff +0 -0
  26. package/dist/ui/assets/geist-mono-cyrillic-ext-400-normal-CkxHOdCb.woff +0 -0
  27. package/dist/ui/assets/geist-mono-cyrillic-ext-400-normal-XB2BPmDK.woff2 +0 -0
  28. package/dist/ui/assets/geist-mono-cyrillic-ext-500-normal-BY1KTvO5.woff +0 -0
  29. package/dist/ui/assets/geist-mono-cyrillic-ext-500-normal-C7aYOB1D.woff2 +0 -0
  30. package/dist/ui/assets/geist-mono-latin-400-normal-B2yvC1Cq.woff +0 -0
  31. package/dist/ui/assets/geist-mono-latin-400-normal-DTRLJnHl.woff2 +0 -0
  32. package/dist/ui/assets/geist-mono-latin-500-normal-D-GG86Jb.woff +0 -0
  33. package/dist/ui/assets/geist-mono-latin-500-normal-YINYabwD.woff2 +0 -0
  34. package/dist/ui/assets/geist-mono-latin-ext-400-normal-CfzLURNc.woff +0 -0
  35. package/dist/ui/assets/geist-mono-latin-ext-400-normal-DI-rJ0UV.woff2 +0 -0
  36. package/dist/ui/assets/geist-mono-latin-ext-500-normal-Buglb9-a.woff2 +0 -0
  37. package/dist/ui/assets/geist-mono-latin-ext-500-normal-CrWEgvU_.woff +0 -0
  38. package/dist/ui/assets/geist-mono-symbols2-400-normal-BS9BGVxz.woff2 +0 -0
  39. package/dist/ui/assets/geist-mono-symbols2-400-normal-DjbiTEEA.woff +0 -0
  40. package/dist/ui/assets/geist-mono-symbols2-500-normal-CmnCPGa0.woff +0 -0
  41. package/dist/ui/assets/geist-mono-symbols2-500-normal-Q_ksOahU.woff2 +0 -0
  42. package/dist/ui/assets/geist-mono-vietnamese-400-normal-CKEaXIlx.woff2 +0 -0
  43. package/dist/ui/assets/geist-mono-vietnamese-400-normal-CZEPLOgu.woff +0 -0
  44. package/dist/ui/assets/geist-mono-vietnamese-500-normal-Bs29RrUZ.woff2 +0 -0
  45. package/dist/ui/assets/geist-mono-vietnamese-500-normal-Dx9-epDo.woff +0 -0
  46. package/dist/ui/assets/geist-sans-latin-400-normal-BOaIZNA2.woff +0 -0
  47. package/dist/ui/assets/geist-sans-latin-400-normal-gapTbOY8.woff2 +0 -0
  48. package/dist/ui/assets/geist-sans-latin-500-normal-CN2lyvyL.woff +0 -0
  49. package/dist/ui/assets/geist-sans-latin-500-normal-uokXdC-Q.woff2 +0 -0
  50. package/dist/ui/assets/geist-sans-latin-600-normal-CA1yjETN.woff +0 -0
  51. package/dist/ui/assets/geist-sans-latin-600-normal-DFOURf8L.woff2 +0 -0
  52. package/dist/ui/assets/index-BiO54L90.css +1 -0
  53. package/dist/ui/assets/index-BoG6O-Lp.js +10 -0
  54. package/dist/ui/assets/theme-3xH5jxKs.js +1 -0
  55. package/dist/ui/favicon.ico +0 -0
  56. package/dist/ui/favicon.svg +4 -0
  57. package/dist/ui/index.html +30 -0
  58. package/dist/ui/ork-wordmark-small-on-light.svg +4 -0
  59. package/dist/ui/ork-wordmark-small.svg +4 -0
  60. package/dist/ui-CZS1XKla.mjs +160 -0
  61. package/dist/usage-BIjf9Xdt.mjs +418 -0
  62. package/dist/watch-B8o4Szd_.mjs +394 -0
  63. package/package.json +4 -2
  64. package/dist/src-CJ_zF0Zj.mjs +0 -5433
package/dist/bin.mjs CHANGED
@@ -1,18 +1,23 @@
1
1
  #!/usr/bin/env node
2
- import { A as contextDiagnostics, B as loadAgents, C as createFeedback, D as maskSecrets, E as maskReplacer, F as toolLinks, G as placeFiles, H as hasInRepoFiles, I as toolsInUse, J as runDirOf, K as placeFor, L as createAgent, M as applySteps, N as planMoveInto, O as parseTemplate, P as linkTools, Q as projectRootOf, R as deleteAgent, S as FEEDBACK_TARGETS, T as targetOf, U as inRepoPlace, V as PROJECT_MODES, W as newProjectId, X as workflowDirOf, Y as shownPath, Z as inside, _ as compileWorkflow, a as removeWorktree, at as isToolNode, b as FEEDBACK_KINDS, c as lines, ct as integrationFor, d as planNodeSession, dt as CLAUDE_DRIVERS, et as parseWorkflow, ft as firstLine$1, g as resolvePractices, h as practiceInstructions, i as inspectProject, it as isClaudeNode, j as planAgentMigration, k as renderTemplate, l as detectInstaller, lt as AGENT_TOOL_LAYOUTS, m as runScope, n as resolveBase, nt as WorkflowSchema, o as git, ot as INTEGRATIONS, p as userMessageText, q as projectsHome, rt as settingsOf, s as gitOk, st as TOOL_PREFIX, t as createWorkspace, tt as serializeWorkflow, u as startRun, ut as ORK_LAYOUT, v as gitRulesOf, w as fingerprint, x as FEEDBACK_SEVERITIES, y as loadProject, z as catalogAgent } from "./src-CJ_zF0Zj.mjs";
2
+ import { $ as replayLoopAnswers, $t as projectsHome, Bt as linkTools, Cn as beforeOf, Ct as FEEDBACK_SEVERITIES, Dt as targetOf, E as planNodeSession, Et as fingerprint, Ft as socketPath, Gt as catalogAgent, H as requestStop, Ht as toolsInUse, I as peerOf, It as wakeRun, Jt as hasInRepoFiles, Kt as loadAgents, L as tellNode, Lt as planAgentMigration, M as addressOf, Mn as ORK_LAYOUT, N as endLink, Nn as CLAUDE_DRIVERS, O as parseInputPairs, On as INTEGRATIONS, Ot as maskReplacer, P as endLinksOf, Pn as firstLine, Pt as serveRun, Q as createReplay, Qt as placeFor, Rt as applySteps, St as FEEDBACK_KINDS, Tt as createFeedback, U as tellLiveRun, Ut as createAgent, Vt as toolLinks, Wt as deleteAgent, X as detachResume, Xt as newProjectId, Y as adoptedCalls, Yt as inRepoPlace, Z as resumable, Zt as placeFiles, b as inspectProject, d as triggersReport, dt as startRun, en as runDirOf, hn as serializeWorkflow, i as NOT_STARTED, in as projectRootOf, j as createCalls, jn as AGENT_TOOL_LAYOUTS, k as resolveInputs, kt as maskSecrets, l as detectInstaller, n as resolveBase, nt as followEvents, ot as runStatus, pt as userMessageText, rn as inside, rt as liveStatus, st as runTree, t as createWorkspace, tn as shownPath, tt as detachRun, u as lateTriggers, ut as walkSummary, wn as isClaudeNode, xn as settingsOf, xt as gitRulesOf, yn as WorkflowSchema, z as flattenTree, zt as planMoveInto } from "./src-Cz1yOUTg.mjs";
3
+ import { a as pendingDecisions, i as answerDecision, n as ORK_VERSION, r as nodeSession, t as ORK_NAME } from "./__build_info__-CXXMLvjt.mjs";
4
+ import { n as newRunId, t as findRun } from "./find-BcvhT6UN.mjs";
5
+ import { a as write, c as openStore, i as style, l as storePath, n as cost, o as writeErr, s as nodeOf, t as confirm } from "./output-Q2EdeVUt.mjs";
6
+ import { a as formatRunEvent, c as createdWorktrees, i as formatEvent, l as listOrkWorktrees, n as formatRuns, o as createTerminalHuman, r as formatTree, s as applyClean, t as ago, u as planClean } from "./format-DqpObyMl.mjs";
7
+ import { a as validate, c as warningLines, d as workflowArg, f as projectAt, i as loadWorkflow, l as WORKFLOW_DIR, o as validateFile, p as isNewer, r as formatReport, s as walkLoaded, t as context, u as WORKFLOW_SUFFIX } from "./context-BFGC_C02.mjs";
8
+ import { i as runLimits, r as limitsRefusal, t as chatNotice } from "./limits-BKYAmqCR.mjs";
9
+ import { _ as resultOf, a as builtinExecutors, d as maskedEnv, f as maskedNamesOfRun, g as clearOutputFiles, h as sessionEnv, i as onPath, l as clearStateFiles, m as runScrubber, n as missingProgramMessage, r as missingPrograms, t as envProblems } from "./src-DtN1k-B3.mjs";
10
+ import { i as decideRefusal, n as chatRefusal } from "./in-a-run-BbLhZOFW.mjs";
3
11
  import { defineCommand, runMain } from "citty";
4
12
  import { createInterface } from "node:readline/promises";
5
13
  import path from "node:path";
6
14
  import { stringify } from "yaml";
7
- import { accessSync, appendFileSync, constants, existsSync, mkdirSync, openSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
8
- import { createHash, randomUUID } from "node:crypto";
15
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, statSync, writeFileSync } from "node:fs";
16
+ import { createHash } from "node:crypto";
9
17
  import { homedir } from "node:os";
10
- import { createConnection, createServer } from "node:net";
11
- import { createInterface as createInterface$1 } from "node:readline";
12
- import { DatabaseSync } from "node:sqlite";
18
+ import { setTimeout } from "node:timers/promises";
13
19
  import { execFile, spawn } from "node:child_process";
14
20
  import { styleText } from "node:util";
15
- import { setTimeout as setTimeout$1 } from "node:timers/promises";
16
21
  // ../core/src/control/queue.ts
17
22
  const PERMISSION_OPTIONS = [{
18
23
  id: "allow",
@@ -134,123 +139,6 @@ const createDecisionQueue = (options) => {
134
139
  }
135
140
  };
136
141
  };
137
- // ../core/src/control/socket.ts
138
- /**
139
- * @file The per-run local socket (`~/.ork/sockets/<run>.sock`): the fast path
140
- * beside the store (ADR-024). Other `ork` processes `wake` a run when they
141
- * have written an answer or a command, and `follow` a run to get each event
142
- * as it is stored. One JSON message per line; nothing is lost when the socket
143
- * is gone, since the store has it all.
144
- */
145
- /** Where a run's socket lives; `home` is `~` (tests pass a folder). */
146
- const socketPath = (runId, home = homedir()) => path.join(home, ".ork", "sockets", `${runId}.sock`);
147
- /** For errors another handler already settles. */
148
- const ignore = () => {};
149
- const messageOf = (line) => {
150
- try {
151
- const value = JSON.parse(line);
152
- if (value === null || typeof value !== "object" || !("type" in value)) return;
153
- if (value.type === "wake") return { type: "wake" };
154
- if (value.type === "follow") {
155
- const after = "after" in value ? Number(value.after) : 0;
156
- return {
157
- type: "follow",
158
- after: Number.isFinite(after) ? after : 0
159
- };
160
- }
161
- } catch {}
162
- };
163
- /** Listens on the run's socket; resolves once it is listening. */
164
- const serveRun = async (options) => {
165
- mkdirSync(path.dirname(options.path), { recursive: true });
166
- rmSync(options.path, { force: true });
167
- const followers = /* @__PURE__ */ new Set();
168
- const server = createServer((socket) => {
169
- socket.on("error", () => {
170
- followers.delete(socket);
171
- });
172
- socket.on("close", () => {
173
- followers.delete(socket);
174
- });
175
- const lines = createInterface$1({ input: socket });
176
- lines.on("error", ignore);
177
- lines.on("line", (line) => {
178
- const message = messageOf(line);
179
- if (message?.type === "wake") options.onWake();
180
- if (message?.type === "follow") {
181
- for (const stored of options.backlog(message.after)) socket.write(`${JSON.stringify(stored)}\n`);
182
- followers.add(socket);
183
- }
184
- });
185
- });
186
- const { promise, resolve, reject } = Promise.withResolvers();
187
- server.once("error", reject);
188
- server.listen(options.path, () => {
189
- resolve();
190
- });
191
- await promise;
192
- return {
193
- publish: (stored) => {
194
- const line = `${JSON.stringify(stored)}\n`;
195
- for (const socket of followers) socket.write(line);
196
- },
197
- close: () => {
198
- for (const socket of followers) socket.end();
199
- const closed = Promise.withResolvers();
200
- server.close(() => {
201
- closed.resolve();
202
- });
203
- rmSync(options.path, { force: true });
204
- return closed.promise;
205
- }
206
- };
207
- };
208
- /** Tells a live run to look at the store now; false when it is not listening. */
209
- const wakeRun = (file) => {
210
- const { promise, resolve } = Promise.withResolvers();
211
- const socket = createConnection(file, () => {
212
- socket.end(`${JSON.stringify({ type: "wake" })}\n`, () => {
213
- resolve(true);
214
- });
215
- });
216
- socket.on("error", () => {
217
- resolve(false);
218
- });
219
- return promise;
220
- };
221
- /**
222
- * Follows a live run over its socket: `onEvent` gets each event stored after
223
- * `after`. Resolves `"ended"` when the run closes the socket, or
224
- * `"unavailable"` when no run is listening.
225
- */
226
- const followSocket = (file, after, onEvent, signal) => {
227
- const { promise, resolve } = Promise.withResolvers();
228
- let connected = false;
229
- const socket = createConnection(file, () => {
230
- connected = true;
231
- socket.write(`${JSON.stringify({
232
- type: "follow",
233
- after
234
- })}\n`);
235
- });
236
- const lines = createInterface$1({ input: socket });
237
- lines.on("error", ignore);
238
- lines.on("line", (line) => {
239
- try {
240
- onEvent(JSON.parse(line));
241
- } catch {}
242
- });
243
- socket.on("error", () => {
244
- resolve(connected ? "ended" : "unavailable");
245
- });
246
- socket.on("close", () => {
247
- resolve(connected ? "ended" : "unavailable");
248
- });
249
- signal?.addEventListener("abort", () => {
250
- socket.destroy();
251
- });
252
- return promise;
253
- };
254
142
  // ../core/src/control/control.ts
255
143
  const startControl = async (options) => {
256
144
  const { store, runId } = options;
@@ -265,13 +153,23 @@ const startControl = async (options) => {
265
153
  }
266
154
  }
267
155
  for (const command of store.takeCommands(runId)) if (command.kind === "message") options.onMessage(command.node, command.body);
268
- else if (!stopped) {
156
+ else if (command.kind === "relay") options.onRelay?.(command.node, command.from, command.body);
157
+ else if (command.kind === "script") options.onScript?.(command.node, command.from, command.body);
158
+ else if (command.kind === "notice") options.onNotice?.(command.node, command.body);
159
+ else if (command.kind === "stop" && command.node === void 0) {
160
+ if (stopped) continue;
269
161
  stopped = true;
270
162
  options.emit({
271
163
  type: "run.command",
272
164
  command: "stop"
273
165
  });
274
166
  options.onStop();
167
+ } else if (!stopped) {
168
+ if (options.onControl?.(command.kind, command.node) ?? false) options.emit({
169
+ type: "run.command",
170
+ command: command.kind,
171
+ ...command.node === void 0 ? {} : { node: command.node }
172
+ });
275
173
  }
276
174
  };
277
175
  const timer = setInterval(check, options.intervalMs ?? 1e3);
@@ -504,267 +402,102 @@ const createSignals = (options = {}) => {
504
402
  return signal === null ? [event] : [event, ...record(...signal)];
505
403
  };
506
404
  };
507
- // ../core/src/run/inputs.ts
508
- const coerce = (name, type, raw) => {
509
- if (type === "string") return { value: raw };
510
- if (type === "number") {
511
- const value = Number(raw);
512
- return raw.trim() === "" || Number.isNaN(value) ? { error: `input "${name}" must be a number, got "${raw}"` } : { value };
513
- }
514
- if (raw === "true" || raw === "false") return { value: raw === "true" };
515
- return { error: `input "${name}" must be true or false, got "${raw}"` };
516
- };
405
+ // ../core/src/remote/relay.ts
517
406
  /**
518
- * Checks `--input name=value` pairs against the workflow's inputs: unknown
519
- * names, missing required inputs, and types. Defaults fill the rest.
407
+ * @file A run's side of the relay (RFC-003): its nodes' links, read from the
408
+ * store, and sending to a node of another run. A message is a row in the
409
+ * store and a wake on that run's socket, so no process has to stay up for it,
410
+ * and the store has it if the socket is gone. Reaching a link's limit asks
411
+ * the user whether to go on, as a loop out of rounds does.
520
412
  */
521
- const resolveInputs = (workflow, raw) => {
522
- const declared = workflow.inputs ?? {};
523
- const errors = [];
524
- const inputs = {};
525
- for (const name of Object.keys(raw)) if (declared[name] === void 0) errors.push(`unknown input "${name}"`);
526
- for (const [name, def] of Object.entries(declared)) {
527
- const value = raw[name];
528
- if (value === void 0) {
529
- if (def.default !== void 0) inputs[name] = def.default;
530
- else if (def.required === true) errors.push(`missing required input "${name}" (--input ${name}=…)`);
531
- continue;
413
+ /** For a refused or abandoned question: a stopped run has nothing to do. */
414
+ const ignore = () => {};
415
+ const createRelay = (options) => {
416
+ const { store, runId, home, human } = options;
417
+ /** Links with the question about their limit open. */
418
+ const asking = /* @__PURE__ */ new Set();
419
+ const askToGoOn = async (link, node) => {
420
+ if (asking.has(link.id)) return;
421
+ asking.add(link.id);
422
+ try {
423
+ if (await human.ask({
424
+ nodeId: node,
425
+ question: `${addressOf(link.a)} and ${addressOf(link.b)} have used all ${String(link.maxMessages)} messages of their link. Let them go on?`,
426
+ options: [{
427
+ id: "extend",
428
+ description: `${String(link.maxMessages)} more`
429
+ }, {
430
+ id: "end",
431
+ description: "End the link"
432
+ }],
433
+ recommended: "end"
434
+ }) === "extend") {
435
+ store.extendLink(link.id, link.maxMessages);
436
+ await Promise.all([link.a, link.b].map((end) => tellNode(store, end, "[ork] The user let this link go on; you can send again.", home)));
437
+ } else await endLink(store, link, "the user ended it", home);
438
+ } finally {
439
+ asking.delete(link.id);
532
440
  }
533
- const result = coerce(name, def.type ?? "string", value);
534
- if ("error" in result) errors.push(result.error);
535
- else inputs[name] = result.value;
536
- }
537
- return errors.length > 0 ? {
538
- ok: false,
539
- errors
540
- } : {
541
- ok: true,
542
- inputs
543
441
  };
544
- };
545
- /** Parses `name=value` strings, as given to `--input`. */
546
- const parseInputPairs = (pairs) => {
547
- const raw = {};
548
- for (const pair of pairs) {
549
- const at = pair.indexOf("=");
550
- if (at <= 0) return {
551
- ok: false,
552
- error: `expected name=value, got "${pair}"`
553
- };
554
- raw[pair.slice(0, at)] = pair.slice(at + 1);
555
- }
556
- return {
557
- ok: true,
558
- raw
442
+ /** Why `peer`'s run cannot take a message now, or null. */
443
+ const closed = (peer) => {
444
+ const [run] = store.lookup(peer.run);
445
+ if (run === void 0) return `run ${peer.run} is gone`;
446
+ const status = liveStatus(run);
447
+ return status === "running" ? null : `its run has finished (${status}). A finished run's session is not resumed by another agent; the user can resume it with \`ork chat\``;
559
448
  };
560
- };
561
- // ../core/src/run/resume.ts
562
- /** How a run ended that cut its working nodes off, rather than failing them. */
563
- const CUTTING = /* @__PURE__ */ new Set(["interrupted", "budget_exhausted"]);
564
- /** Reads a run's events back into what `ork resume` needs. */
565
- const resumeState = (events) => {
566
- const history = /* @__PURE__ */ new Map();
567
- const closed = /* @__PURE__ */ new Set();
568
- const open = /* @__PURE__ */ new Map();
569
- const worktrees = /* @__PURE__ */ new Map();
570
- const branches = /* @__PURE__ */ new Set();
571
- const loopAnswers = [];
572
- /** Decision or owner-question id -> its question, when it is about a loop. */
573
- const loopQuestions = /* @__PURE__ */ new Map();
574
- let loopPending = false;
575
- let inputs = {};
576
- let settings = {};
577
- let succeeded = false;
578
- let ended;
579
- /** Nodes whose last activation did not finish well, and its session. */
580
- const unfinished = /* @__PURE__ */ new Map();
581
- const inboxes = /* @__PURE__ */ new Map();
582
- const waiting = /* @__PURE__ */ new Map();
583
- let last = 0;
584
- for (const event of events) {
585
- if (event.type === "run.started") {
586
- inputs = event.inputs ?? inputs;
587
- settings = event.settings ?? settings;
588
- continue;
589
- }
590
- if (event.type === "message.sent") {
591
- last = Math.max(last, event.message);
592
- const unread = inboxes.get(event.to) ?? [];
593
- unread.push({
594
- id: event.message,
595
- from: event.node,
596
- body: event.body
449
+ const isRunning = (peer) => {
450
+ const events = store.events(peer.run).map((stored) => stored.event);
451
+ return flattenTree(runTree(events)).some((n) => n.id === peer.node && n.state === "running");
452
+ };
453
+ return {
454
+ peers: (node) => store.links({
455
+ run: runId,
456
+ live: true
457
+ }).flatMap((link) => {
458
+ const peer = peerOf(link, runId, node);
459
+ return peer === void 0 ? [] : [addressOf(peer)];
460
+ }),
461
+ send: (node, to, body) => {
462
+ const link = store.links({
463
+ run: runId,
464
+ live: true
465
+ }).find((candidate) => {
466
+ const peer = peerOf(candidate, runId, node);
467
+ return peer !== void 0 && addressOf(peer) === to;
597
468
  });
598
- inboxes.set(event.to, unread);
599
- continue;
600
- }
601
- if (event.type === "message.read") {
602
- const unread = inboxes.get(event.node) ?? [];
603
- inboxes.set(event.node, unread.filter((m) => !event.ids.includes(m.id)));
604
- continue;
605
- }
606
- if (event.type === "user.message" && event.delivered === "queued") {
607
- waiting.set(event.node, [...waiting.get(event.node) ?? [], event.body]);
608
- continue;
609
- }
610
- if (event.type === "node.started") {
611
- waiting.delete(event.id);
612
- if (event.owner === void 0) open.set(event.id, {});
613
- continue;
614
- }
615
- if (event.type === "node.event") {
616
- const inner = event.event;
617
- const current = open.get(event.id);
618
- if (current === void 0) continue;
619
- if (inner.type === "session.started") current.sessionId = inner.sessionId;
620
- if (inner.type === "session.finished" && inner.outcome.sessionId) current.sessionId = inner.outcome.sessionId;
621
- continue;
622
- }
623
- if (event.type === "node.finished") {
624
- const current = open.get(event.id);
625
- open.delete(event.id);
626
- if (current === void 0 || closed.has(event.id)) continue;
627
- if (!event.ok) {
628
- closed.add(event.id);
629
- if (current.sessionId !== void 0) unfinished.set(event.id, current.sessionId);
630
- continue;
469
+ const peer = link && peerOf(link, runId, node);
470
+ if (link === void 0 || peer === void 0) return {
471
+ ok: false,
472
+ error: `you have no live link to ${to}`
473
+ };
474
+ const why = closed(peer);
475
+ if (why !== null) {
476
+ endLink(store, link, why, home);
477
+ return {
478
+ ok: false,
479
+ error: `${to} cannot take it: ${why}`
480
+ };
631
481
  }
632
- const result = {
482
+ if (link.messages >= link.maxMessages) {
483
+ askToGoOn(link, node).catch(ignore);
484
+ return {
485
+ ok: false,
486
+ error: `this link has used all ${String(link.maxMessages)} of its messages, and the user has been asked whether to go on`
487
+ };
488
+ }
489
+ store.countMessage(link.id);
490
+ store.command(peer.run, {
491
+ kind: "relay",
492
+ node: peer.node,
493
+ from: `${runId}/${node}`,
494
+ body
495
+ });
496
+ wakeRun(socketPath(peer.run, home));
497
+ return {
633
498
  ok: true,
634
- output: event.output
499
+ delivered: isRunning(peer) ? "now" : "queued"
635
500
  };
636
- if (event.usage !== void 0) result.usage = event.usage;
637
- if (current.sessionId !== void 0) result.sessionId = current.sessionId;
638
- history.set(event.id, [...history.get(event.id) ?? [], result]);
639
- continue;
640
- }
641
- if (event.type === "worktree") {
642
- const known = worktrees.get(event.id);
643
- if (event.action === "created" || known === void 0) worktrees.set(event.id, {
644
- id: event.id,
645
- path: event.path,
646
- ...event.branch === void 0 ? {} : { branch: event.branch },
647
- merged: false,
648
- removed: false
649
- });
650
- const entry = worktrees.get(event.id);
651
- if (entry === void 0) continue;
652
- if (event.branch !== void 0) branches.add(event.branch);
653
- if (event.snapshot !== void 0) entry.snapshot = event.snapshot;
654
- if (event.action === "merged") entry.merged = true;
655
- if (event.action === "committed" || event.action === "conflict") entry.merged = false;
656
- if (event.action === "removed") entry.removed = true;
657
- continue;
658
- }
659
- if (event.type === "loop.exhausted") {
660
- loopPending = true;
661
- continue;
662
- }
663
- if (event.type === "decision.opened") {
664
- if (loopPending) loopQuestions.set(event.decision, event.question);
665
- loopPending = false;
666
- continue;
667
- }
668
- if (event.type === "owner.asked") {
669
- if (loopPending) loopQuestions.set(event.question, event.text);
670
- loopPending = false;
671
- continue;
672
- }
673
- if (event.type === "owner.unanswered") {
674
- loopPending = true;
675
- continue;
676
- }
677
- if (event.type === "decision.answered" || event.type === "owner.answered") {
678
- const key = event.type === "owner.answered" ? event.question : event.decision;
679
- const question = loopQuestions.get(key);
680
- if (question !== void 0) loopAnswers.push({
681
- question,
682
- answer: event.answer
683
- });
684
- continue;
685
- }
686
- if (event.type === "run.finished") {
687
- succeeded = event.result.status === "succeeded";
688
- ended = event.result.status;
689
- continue;
690
- }
691
- }
692
- for (const [id, current] of open) {
693
- closed.add(id);
694
- if (current.sessionId !== void 0) unfinished.set(id, current.sessionId);
695
- }
696
- const cut = ended === void 0 || CUTTING.has(ended);
697
- return {
698
- history,
699
- inputs,
700
- settings,
701
- worktrees: [...worktrees.values()].filter((w) => !w.removed).map(({ removed: _removed, ...w }) => w),
702
- branches: [...branches],
703
- loopAnswers,
704
- succeeded,
705
- cutOff: cut ? unfinished : /* @__PURE__ */ new Map(),
706
- mail: {
707
- inboxes,
708
- waiting,
709
- last
710
- }
711
- };
712
- };
713
- /** Whether an activation's input carries a result this run made anew. */
714
- const carries = (fresh, input) => {
715
- if (input === void 0) return false;
716
- if (fresh.has(input)) return true;
717
- const results = input["results"];
718
- if (results === null || typeof results !== "object") return false;
719
- return Object.values(results).some((value) => value !== null && typeof value === "object" && fresh.has(value));
720
- };
721
- /**
722
- * Hands each node's recorded results back in order, until the node runs anew
723
- * once (it failed or never finished last time, or its input is new); from then
724
- * on it always runs. A node cut off mid-session continues that session, once.
725
- */
726
- const createReplay = (history, cutOff = /* @__PURE__ */ new Map(), mail) => {
727
- const sessions = new Map(cutOff);
728
- const used = /* @__PURE__ */ new Map();
729
- const anew = /* @__PURE__ */ new Set();
730
- const fresh = /* @__PURE__ */ new WeakSet();
731
- return {
732
- take: (activation) => {
733
- const { id } = activation;
734
- if (activation.brief !== void 0 || anew.has(id)) return null;
735
- const index = used.get(id) ?? 0;
736
- const recorded = history.get(id)?.[index];
737
- if (recorded === void 0 || carries(fresh, activation.input)) {
738
- anew.add(id);
739
- return null;
740
- }
741
- used.set(id, index + 1);
742
- return recorded;
743
- },
744
- ran: (result) => {
745
- fresh.add(result.output);
746
- },
747
- cutOff: (activation) => {
748
- const session = activation.brief === void 0 ? sessions.get(activation.id) : void 0;
749
- sessions.delete(activation.id);
750
- return session;
751
- },
752
- kept: () => [...history.keys()],
753
- ...mail === void 0 ? {} : { mail }
754
- };
755
- };
756
- /**
757
- * The run's `HumanChannel`, answering questions about loops out of rounds as
758
- * they were answered last time, in order; anything else goes to `human`.
759
- */
760
- const replayLoopAnswers = (human, answers) => {
761
- const left = [...answers];
762
- return {
763
- permit: (request) => human.permit(request),
764
- ask: (question) => {
765
- const index = left.findIndex((a) => a.question === question.question);
766
- const [given] = index === -1 ? [] : left.splice(index, 1);
767
- return given === void 0 ? human.ask(question) : Promise.resolve(given.answer);
768
501
  }
769
502
  };
770
503
  };
@@ -793,606 +526,6 @@ const createRecorder = (store, run, runDir) => {
793
526
  store.begin(run);
794
527
  return appendRecorder(store, run.id, runDir);
795
528
  };
796
- // ../core/src/store/schema.ts
797
- const MIGRATIONS = [
798
- `CREATE TABLE runs (
799
- id TEXT PRIMARY KEY,
800
- workflow TEXT NOT NULL,
801
- file TEXT NOT NULL,
802
- project TEXT NOT NULL,
803
- status TEXT NOT NULL,
804
- pid INTEGER NOT NULL,
805
- started_at TEXT NOT NULL,
806
- finished_at TEXT,
807
- cost_usd REAL NOT NULL DEFAULT 0
808
- );
809
- CREATE INDEX runs_by_project ON runs (project, started_at);
810
- CREATE TABLE events (
811
- run_id TEXT NOT NULL REFERENCES runs (id),
812
- seq INTEGER NOT NULL,
813
- at TEXT NOT NULL,
814
- type TEXT NOT NULL,
815
- node TEXT,
816
- data TEXT NOT NULL,
817
- PRIMARY KEY (run_id, seq)
818
- ) WITHOUT ROWID;`,
819
- `CREATE TABLE decisions (
820
- id TEXT PRIMARY KEY,
821
- run_id TEXT NOT NULL REFERENCES runs (id),
822
- node TEXT NOT NULL,
823
- kind TEXT NOT NULL,
824
- question TEXT NOT NULL,
825
- options TEXT NOT NULL,
826
- recommended TEXT,
827
- context TEXT,
828
- opened_at TEXT NOT NULL,
829
- status TEXT NOT NULL,
830
- answer TEXT,
831
- answered_by TEXT,
832
- answered_at TEXT
833
- );
834
- CREATE INDEX decisions_by_status ON decisions (status, run_id);
835
- CREATE TABLE commands (
836
- id INTEGER PRIMARY KEY AUTOINCREMENT,
837
- run_id TEXT NOT NULL REFERENCES runs (id),
838
- kind TEXT NOT NULL,
839
- at TEXT NOT NULL,
840
- handled_at TEXT
841
- );`,
842
- `ALTER TABLE commands ADD COLUMN node TEXT;
843
- ALTER TABLE commands ADD COLUMN body TEXT;`,
844
- `CREATE TABLE feedback (
845
- id INTEGER PRIMARY KEY AUTOINCREMENT,
846
- run_id TEXT NOT NULL REFERENCES runs (id),
847
- seq INTEGER NOT NULL,
848
- at TEXT NOT NULL,
849
- node TEXT,
850
- source TEXT NOT NULL,
851
- kind TEXT NOT NULL,
852
- target TEXT NOT NULL,
853
- severity TEXT NOT NULL,
854
- title TEXT NOT NULL,
855
- body TEXT NOT NULL,
856
- evidence TEXT NOT NULL,
857
- code TEXT,
858
- fingerprint TEXT NOT NULL,
859
- workflow TEXT,
860
- workflow_hash TEXT,
861
- node_type TEXT,
862
- model TEXT,
863
- driver TEXT,
864
- version TEXT,
865
- feedback_version INTEGER NOT NULL
866
- );
867
- CREATE INDEX feedback_by_fingerprint ON feedback (fingerprint, at);
868
- CREATE TABLE feedback_items (
869
- fingerprint TEXT PRIMARY KEY,
870
- status TEXT NOT NULL,
871
- updated_at TEXT NOT NULL
872
- );`,
873
- `CREATE TABLE projects (
874
- id TEXT PRIMARY KEY,
875
- root TEXT NOT NULL UNIQUE,
876
- mode TEXT NOT NULL,
877
- dir TEXT NOT NULL,
878
- registered_at TEXT NOT NULL
879
- );`
880
- ];
881
- /** A text column, or `""` when it is null. */
882
- const text$1 = (row, key) => {
883
- const value = row?.[key];
884
- return typeof value === "string" ? value : "";
885
- };
886
- /** Applies the migrations the database has not had yet. */
887
- const migrate$1 = (db) => {
888
- const version = Number(db.prepare("PRAGMA user_version").get()?.["user_version"] ?? 0);
889
- for (const [index, sql] of MIGRATIONS.entries()) {
890
- if (index < version) continue;
891
- db.exec("BEGIN");
892
- db.exec(sql);
893
- db.exec(`PRAGMA user_version = ${String(index + 1)}`);
894
- db.exec("COMMIT");
895
- }
896
- };
897
- // ../core/src/store/decisions.ts
898
- const choicesOf = (json) => {
899
- const parsed = JSON.parse(json);
900
- if (!Array.isArray(parsed)) return [];
901
- return parsed.flatMap((item) => {
902
- if (item === null || typeof item !== "object" || !("id" in item)) return [];
903
- const { id } = item;
904
- if (typeof id !== "string") return [];
905
- const choice = { id };
906
- if ("label" in item && typeof item.label === "string") choice.label = item.label;
907
- if ("description" in item && typeof item.description === "string") choice.description = item.description;
908
- return [choice];
909
- });
910
- };
911
- const kindOf = (value) => value === "permission" ? "permission" : "question";
912
- const statusOf$1 = (value) => value === "answered" || value === "withdrawn" ? value : "open";
913
- const decisionOf = (row) => {
914
- const record = {
915
- id: text$1(row, "id"),
916
- runId: text$1(row, "run_id"),
917
- node: text$1(row, "node"),
918
- kind: kindOf(text$1(row, "kind")),
919
- question: text$1(row, "question"),
920
- options: choicesOf(text$1(row, "options") || "[]"),
921
- openedAt: text$1(row, "opened_at"),
922
- status: statusOf$1(text$1(row, "status"))
923
- };
924
- const optional = {
925
- recommended: text$1(row, "recommended"),
926
- context: text$1(row, "context"),
927
- answer: text$1(row, "answer"),
928
- answeredAt: text$1(row, "answered_at")
929
- };
930
- for (const [key, value] of Object.entries(optional)) if (value !== "") Object.assign(record, { [key]: value });
931
- const by = text$1(row, "answered_by");
932
- if (by === "terminal" || by === "decide") record.answeredBy = by;
933
- return record;
934
- };
935
- const decisionQueries = (db) => {
936
- const insert = db.prepare(`INSERT INTO decisions (id, run_id, node, kind, question, options, recommended, context, opened_at, status)
937
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'open')`);
938
- const answer = db.prepare(`UPDATE decisions SET status = 'answered', answer = ?, answered_by = ?, answered_at = ?
939
- WHERE id = ? AND status = 'open'`);
940
- const withdraw = db.prepare("UPDATE decisions SET status = 'withdrawn', answered_at = ? WHERE id = ? AND status = 'open'");
941
- const byId = db.prepare("SELECT * FROM decisions WHERE id = ?");
942
- const filtered = db.prepare(`SELECT d.* FROM decisions d JOIN runs r ON r.id = d.run_id
943
- WHERE (?1 IS NULL OR d.run_id = ?1)
944
- AND (?2 IS NULL OR r.project = ?2)
945
- AND (?3 IS NULL OR d.status = ?3)
946
- ORDER BY d.opened_at, d.rowid`);
947
- const addCommand = db.prepare("INSERT INTO commands (run_id, kind, node, body, at) VALUES (?, ?, ?, ?, ?)");
948
- const pending = db.prepare("SELECT id, kind, node, body FROM commands WHERE run_id = ? AND handled_at IS NULL ORDER BY id");
949
- const handled = db.prepare("UPDATE commands SET handled_at = ? WHERE id = ?");
950
- return {
951
- openDecision: (decision) => {
952
- const openedAt = (/* @__PURE__ */ new Date()).toISOString();
953
- const masked = {
954
- ...decision,
955
- question: maskSecrets(decision.question),
956
- ...decision.context === void 0 ? {} : { context: maskSecrets(decision.context) }
957
- };
958
- insert.run(masked.id, masked.runId, masked.node, masked.kind, masked.question, JSON.stringify(masked.options), masked.recommended ?? null, masked.context ?? null, openedAt);
959
- return {
960
- ...masked,
961
- openedAt,
962
- status: "open"
963
- };
964
- },
965
- answerDecision: (id, choice, by) => Number(answer.run(choice, by, (/* @__PURE__ */ new Date()).toISOString(), id).changes) === 1,
966
- withdrawDecision: (id) => {
967
- withdraw.run((/* @__PURE__ */ new Date()).toISOString(), id);
968
- },
969
- decision: (id) => {
970
- const row = byId.get(id);
971
- return row === void 0 ? void 0 : decisionOf(row);
972
- },
973
- decisions: (filter) => filtered.all(filter.runId ?? null, filter.project ?? null, filter.status ?? null).map((row) => decisionOf(row)),
974
- command: (runId, command) => {
975
- const at = (/* @__PURE__ */ new Date()).toISOString();
976
- if (command.kind === "stop") addCommand.run(runId, "stop", null, null, at);
977
- else addCommand.run(runId, "message", command.node, maskSecrets(command.body), at);
978
- },
979
- takeCommands: (runId) => {
980
- const at = (/* @__PURE__ */ new Date()).toISOString();
981
- return pending.all(runId).flatMap((row) => {
982
- handled.run(at, Number(row["id"]));
983
- const kind = text$1(row, "kind");
984
- if (kind === "stop") return [{ kind }];
985
- if (kind === "message") return [{
986
- kind,
987
- node: text$1(row, "node"),
988
- body: text$1(row, "body")
989
- }];
990
- return [];
991
- });
992
- }
993
- };
994
- };
995
- // ../core/src/store/feedback.ts
996
- const STATUSES$1 = [
997
- "new",
998
- "triaged",
999
- "accepted",
1000
- "dismissed",
1001
- "published",
1002
- "resolved"
1003
- ];
1004
- const SOURCES = [
1005
- "system",
1006
- "agent",
1007
- "human"
1008
- ];
1009
- /** `value` if it is one of `values`, else `fallback`. */
1010
- const oneOf$1 = (values, value, fallback) => values.find((v) => v === value) ?? fallback;
1011
- /** A JSON array column of strings, without nulls. */
1012
- const strings = (row, key) => {
1013
- const parsed = JSON.parse(text$1(row, key) || "[]");
1014
- return Array.isArray(parsed) ? parsed.filter((v) => typeof v === "string" && v !== "") : [];
1015
- };
1016
- const ORIGIN_COLUMNS = [
1017
- ["workflow", "workflow"],
1018
- ["workflowHash", "workflow_hash"],
1019
- ["nodeType", "node_type"],
1020
- ["model", "model"],
1021
- ["driver", "driver"],
1022
- ["version", "version"]
1023
- ];
1024
- const recordOf$2 = (row) => {
1025
- const origin = {};
1026
- for (const [key, column] of ORIGIN_COLUMNS) {
1027
- const value = text$1(row, column);
1028
- if (value !== "") origin[key] = value;
1029
- }
1030
- const feedback = {
1031
- source: oneOf$1(SOURCES, text$1(row, "source"), "system"),
1032
- kind: oneOf$1(FEEDBACK_KINDS, text$1(row, "kind"), "idea"),
1033
- target: oneOf$1(FEEDBACK_TARGETS, text$1(row, "target"), "workflow"),
1034
- severity: oneOf$1(FEEDBACK_SEVERITIES, text$1(row, "severity"), "medium"),
1035
- title: text$1(row, "title"),
1036
- body: text$1(row, "body"),
1037
- evidence: strings(row, "evidence"),
1038
- fingerprint: text$1(row, "fingerprint"),
1039
- origin
1040
- };
1041
- const code = text$1(row, "code");
1042
- if (code !== "") feedback.code = code;
1043
- const record = {
1044
- id: Number(row?.["id"]),
1045
- runId: text$1(row, "run_id"),
1046
- seq: Number(row?.["seq"]),
1047
- at: text$1(row, "at"),
1048
- feedback
1049
- };
1050
- const node = text$1(row, "node");
1051
- if (node !== "") record.node = node;
1052
- return record;
1053
- };
1054
- const itemOf = (row) => {
1055
- const severities = strings(row, "severities");
1056
- return {
1057
- id: text$1(row, "fingerprint"),
1058
- status: oneOf$1(STATUSES$1, text$1(row, "status"), "new"),
1059
- title: text$1(row, "title"),
1060
- target: oneOf$1(FEEDBACK_TARGETS, text$1(row, "target"), "workflow"),
1061
- kind: oneOf$1(FEEDBACK_KINDS, text$1(row, "kind"), "idea"),
1062
- severity: FEEDBACK_SEVERITIES.toReversed().find((s) => severities.includes(s)) ?? "medium",
1063
- sources: strings(row, "sources").flatMap((s) => SOURCES.filter((known) => known === s)),
1064
- count: Number(row?.["count"]),
1065
- firstSeen: text$1(row, "first_seen"),
1066
- lastSeen: text$1(row, "last_seen"),
1067
- runs: strings(row, "runs"),
1068
- workflows: strings(row, "workflows"),
1069
- models: strings(row, "models")
1070
- };
1071
- };
1072
- const ITEMS = `SELECT f.fingerprint, i.status,
1073
- (SELECT title FROM feedback WHERE fingerprint = f.fingerprint ORDER BY id LIMIT 1) AS title,
1074
- MIN(f.target) AS target, MIN(f.kind) AS kind,
1075
- json_group_array(DISTINCT f.severity) AS severities,
1076
- json_group_array(DISTINCT f.source) AS sources,
1077
- COUNT(*) AS count, MIN(f.at) AS first_seen, MAX(f.at) AS last_seen,
1078
- json_group_array(DISTINCT f.run_id) AS runs,
1079
- json_group_array(DISTINCT f.workflow) AS workflows,
1080
- json_group_array(DISTINCT f.model) AS models
1081
- FROM feedback f JOIN feedback_items i ON i.fingerprint = f.fingerprint`;
1082
- const feedbackQueries = (db) => {
1083
- const insert = db.prepare(`INSERT INTO feedback (run_id, seq, at, node, source, kind, target, severity,
1084
- title, body, evidence, code, fingerprint, workflow, workflow_hash,
1085
- node_type, model, driver, version, feedback_version)
1086
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
1087
- const insertItem = db.prepare(`INSERT INTO feedback_items (fingerprint, status, updated_at)
1088
- VALUES (?, 'new', ?) ON CONFLICT (fingerprint) DO NOTHING`);
1089
- const shown = db.prepare(`${ITEMS} WHERE i.status != 'dismissed' GROUP BY f.fingerprint ORDER BY last_seen DESC`);
1090
- const every = db.prepare(`${ITEMS} GROUP BY f.fingerprint ORDER BY last_seen DESC`);
1091
- const byPrefix = db.prepare(`${ITEMS} WHERE substr(f.fingerprint, 1, ?) = ? GROUP BY f.fingerprint ORDER BY last_seen DESC`);
1092
- const records = db.prepare("SELECT * FROM feedback WHERE fingerprint = ? ORDER BY id");
1093
- const setStatus = db.prepare("UPDATE feedback_items SET status = ?, updated_at = ? WHERE fingerprint = ?");
1094
- return {
1095
- insertFeedback: (runId, seq, at, node, feedback) => {
1096
- const { origin } = feedback;
1097
- insert.run(runId, seq, at, node ?? null, feedback.source, feedback.kind, feedback.target, feedback.severity, maskSecrets(feedback.title), maskSecrets(feedback.body), JSON.stringify(feedback.evidence.map((e) => maskSecrets(e))), feedback.code ?? null, feedback.fingerprint, origin.workflow ?? null, origin.workflowHash ?? null, origin.nodeType ?? null, origin.model ?? null, origin.driver ?? null, origin.version ?? null, 1);
1098
- insertItem.run(feedback.fingerprint, at);
1099
- },
1100
- feedbackItems: (filter = {}) => (filter.all === true ? every : shown).all().map((r) => itemOf(r)),
1101
- findFeedback: (prefix) => byPrefix.all(prefix.length, prefix).map((r) => itemOf(r)),
1102
- feedbackRecords: (fingerprint) => records.all(fingerprint).map((r) => recordOf$2(r)),
1103
- setFeedbackStatus: (fingerprint, status) => Number(setStatus.run(status, (/* @__PURE__ */ new Date()).toISOString(), fingerprint).changes) > 0
1104
- };
1105
- };
1106
- // ../core/src/store/projects.ts
1107
- /**
1108
- * @file The store's registered projects (ADR-035): which repositories Ork
1109
- * knows, and where it keeps each one's files (in the repository, or in
1110
- * `~/.ork/projects/<id>/` when attached), and moving it from one to the other
1111
- * (`ork migrate`) with its runs' workflow files.
1112
- */
1113
- const recordOf$1 = (row) => ({
1114
- id: text$1(row, "id"),
1115
- root: text$1(row, "root"),
1116
- mode: PROJECT_MODES.find((m) => m === text$1(row, "mode")) ?? "in-repo",
1117
- dir: text$1(row, "dir"),
1118
- registeredAt: text$1(row, "registered_at")
1119
- });
1120
- const projectQueries = (db) => {
1121
- const all = db.prepare("SELECT * FROM projects ORDER BY id");
1122
- const insert = db.prepare(`INSERT INTO projects (id, root, mode, dir, registered_at)
1123
- VALUES (?, ?, ?, ?, ?)`);
1124
- const remove = db.prepare("DELETE FROM projects WHERE id = ?");
1125
- const move = db.prepare("UPDATE projects SET root = ?, mode = ?, dir = ? WHERE id = ?");
1126
- const rehome = db.prepare("UPDATE runs SET project = ? WHERE project = ?");
1127
- const refile = db.prepare(`UPDATE runs SET file = ? || substr(file, ?)
1128
- WHERE project = ? AND substr(file, 1, ?) = ?`);
1129
- return {
1130
- projects: () => all.all().map((r) => recordOf$1(r)),
1131
- addProject: (project) => {
1132
- const record = {
1133
- ...project,
1134
- registeredAt: (/* @__PURE__ */ new Date()).toISOString()
1135
- };
1136
- insert.run(record.id, record.root, record.mode, record.dir, record.registeredAt);
1137
- return record;
1138
- },
1139
- forgetProject: (id) => Number(remove.run(id).changes) > 0,
1140
- moveProject: (id, place) => {
1141
- const before = all.all().map((r) => recordOf$1(r)).find((p) => p.id === id);
1142
- if (before === void 0) return false;
1143
- const from = `${before.dir}${path.sep}`;
1144
- const root = place.root ?? before.root;
1145
- db.exec("BEGIN");
1146
- try {
1147
- move.run(root, place.mode, place.dir, id);
1148
- rehome.run(root, before.root);
1149
- refile.run(`${place.dir}${path.sep}`, from.length + 1, root, from.length, from);
1150
- db.exec("COMMIT");
1151
- } catch (error) {
1152
- db.exec("ROLLBACK");
1153
- throw error;
1154
- }
1155
- return true;
1156
- }
1157
- };
1158
- };
1159
- // ../core/src/store/queries.ts
1160
- /** Every query set on `db`; `insertFeedback` is the store's own. */
1161
- const storeQueries = (db) => ({
1162
- ...decisionQueries(db),
1163
- ...feedbackQueries(db),
1164
- ...projectQueries(db)
1165
- });
1166
- // ../core/src/store/sqlite.ts
1167
- /**
1168
- * @file The event store on `node:sqlite` (ADR-024): one database for every
1169
- * project, `~/.ork/state.db`, in WAL mode so other `ork` processes read while
1170
- * a run writes. Events are stored with secrets masked, feedback records
1171
- * beside them, and the registered projects; the schema is versioned with
1172
- * `user_version`.
1173
- */
1174
- /** `~/.ork/state.db`; `home` is `~` (tests pass a folder). */
1175
- const storePath = (home = homedir()) => path.join(home, ".ork", "state.db");
1176
- const STATUSES = [
1177
- "running",
1178
- "succeeded",
1179
- "failed",
1180
- "budget_exhausted",
1181
- "interrupted"
1182
- ];
1183
- const statusOf = (value) => STATUSES.find((s) => s === value) ?? "failed";
1184
- const recordOf = (row) => {
1185
- const record = {
1186
- id: text$1(row, "id"),
1187
- workflow: text$1(row, "workflow"),
1188
- file: text$1(row, "file"),
1189
- project: text$1(row, "project"),
1190
- status: statusOf(text$1(row, "status")),
1191
- pid: Number(row?.["pid"]),
1192
- startedAt: text$1(row, "started_at"),
1193
- costUsd: Number(row?.["cost_usd"])
1194
- };
1195
- const finished = text$1(row, "finished_at");
1196
- if (finished !== "") record.finishedAt = finished;
1197
- return record;
1198
- };
1199
- /** An event as the store wrote it. */
1200
- const eventOf = (data) => JSON.parse(data);
1201
- /** The node an event is about, for filtering. */
1202
- const nodeOf = (event) => {
1203
- if ("id" in event && typeof event.id === "string") return event.id;
1204
- if ("node" in event && typeof event.node === "string") return event.node;
1205
- return null;
1206
- };
1207
- /** Opens (and creates) the store at `file`; `:memory:` for tests. */
1208
- const openStore = (file = storePath()) => {
1209
- if (file !== ":memory:") mkdirSync(path.dirname(file), { recursive: true });
1210
- const db = new DatabaseSync(file);
1211
- db.exec("PRAGMA journal_mode = WAL");
1212
- db.exec("PRAGMA synchronous = NORMAL");
1213
- db.exec("PRAGMA busy_timeout = 5000");
1214
- db.exec("PRAGMA foreign_keys = ON");
1215
- migrate$1(db);
1216
- const insertRun = db.prepare(`INSERT INTO runs (id, workflow, file, project, status, pid, started_at)
1217
- VALUES (?, ?, ?, ?, 'running', ?, ?)`);
1218
- const lastSeq = db.prepare("SELECT COALESCE(MAX(seq), 0) AS seq FROM events WHERE run_id = ?");
1219
- const insertEvent = db.prepare("INSERT INTO events (run_id, seq, at, type, node, data) VALUES (?, ?, ?, ?, ?, ?)");
1220
- const finishRun = db.prepare("UPDATE runs SET status = ?, finished_at = ?, cost_usd = ? WHERE id = ?");
1221
- const reopenRun = db.prepare("UPDATE runs SET status = 'running', pid = ?, finished_at = NULL WHERE id = ?");
1222
- const addCost = db.prepare("UPDATE runs SET cost_usd = cost_usd + ? WHERE id = ?");
1223
- const byId = db.prepare("SELECT * FROM runs WHERE id = ?");
1224
- const byPrefix = db.prepare("SELECT * FROM runs WHERE substr(id, 1, ?) = ? ORDER BY started_at DESC, rowid DESC");
1225
- const allRuns = db.prepare("SELECT * FROM runs ORDER BY started_at DESC, rowid DESC LIMIT ?");
1226
- const projectRuns = db.prepare("SELECT * FROM runs WHERE project = ? ORDER BY started_at DESC, rowid DESC LIMIT ?");
1227
- const eventsAfter = db.prepare("SELECT seq, at, data FROM events WHERE run_id = ? AND seq > ? ORDER BY seq");
1228
- const { insertFeedback, ...queries } = storeQueries(db);
1229
- return {
1230
- ...queries,
1231
- begin: (run) => {
1232
- const startedAt = (/* @__PURE__ */ new Date()).toISOString();
1233
- insertRun.run(run.id, run.workflow, run.file, run.project, process.pid, startedAt);
1234
- return {
1235
- ...run,
1236
- status: "running",
1237
- pid: process.pid,
1238
- startedAt,
1239
- costUsd: 0
1240
- };
1241
- },
1242
- reopen: (runId) => {
1243
- reopenRun.run(process.pid, runId);
1244
- },
1245
- append: (runId, event) => {
1246
- const at = (/* @__PURE__ */ new Date()).toISOString();
1247
- const seq = Number(lastSeq.get(runId)?.["seq"] ?? 0) + 1;
1248
- insertEvent.run(runId, seq, at, event.type, nodeOf(event), JSON.stringify(event, maskReplacer));
1249
- if (event.type === "node.finished" && event.usage !== void 0) addCost.run(event.usage.costUsd, runId);
1250
- if (event.type === "feedback.recorded") insertFeedback(runId, seq, at, event.node, event.feedback);
1251
- if (event.type === "run.finished") finishRun.run(event.result.status, at, event.result.costUsd, runId);
1252
- return {
1253
- runId,
1254
- seq,
1255
- at,
1256
- event
1257
- };
1258
- },
1259
- runs: (filter = {}) => {
1260
- const limit = filter.limit ?? -1;
1261
- return (filter.project === void 0 ? allRuns.all(limit) : projectRuns.all(filter.project, limit)).map((r) => recordOf(r));
1262
- },
1263
- lookup: (prefix) => {
1264
- const exact = byId.get(prefix);
1265
- if (exact !== void 0) return [recordOf(exact)];
1266
- return byPrefix.all(prefix.length, prefix).map((r) => recordOf(r));
1267
- },
1268
- events: (runId, after = 0) => eventsAfter.all(runId, after).map((r) => ({
1269
- runId,
1270
- seq: Number(r["seq"]),
1271
- at: text$1(r, "at"),
1272
- event: eventOf(text$1(r, "data"))
1273
- })),
1274
- close: () => {
1275
- db.close();
1276
- }
1277
- };
1278
- };
1279
- // ../core/src/store/view.ts
1280
- const blank = (id, type, parent) => {
1281
- const node = {
1282
- id,
1283
- type,
1284
- state: "waiting",
1285
- runs: 0,
1286
- costUsd: 0,
1287
- inputTokens: 0,
1288
- outputTokens: 0,
1289
- children: []
1290
- };
1291
- if (parent !== void 0) node.parent = parent;
1292
- return node;
1293
- };
1294
- /** A spawned worker's template: `<template>-<n>` of a declared node. */
1295
- const templateOf$1 = (id, declared) => {
1296
- const base = /^(?<base>.+)-\d+$/u.exec(id)?.groups?.["base"];
1297
- return base !== void 0 && declared.has(base) ? base : void 0;
1298
- };
1299
- /** Folds a run's events into its ownership tree; roots in declared order. */
1300
- const runTree = (events) => {
1301
- const nodes = /* @__PURE__ */ new Map();
1302
- const declared = /* @__PURE__ */ new Map();
1303
- for (const event of events) if (event.type === "run.started") for (const info of event.nodes) {
1304
- if (nodes.has(info.id)) continue;
1305
- const node = blank(info.id, info.type, info.parent);
1306
- nodes.set(info.id, node);
1307
- declared.set(info.id, node);
1308
- }
1309
- else if (event.type === "node.started") {
1310
- let node = nodes.get(event.id);
1311
- if (node === void 0) {
1312
- node = blank(event.id, event.nodeType, event.owner);
1313
- const template = templateOf$1(event.id, declared);
1314
- if (template !== void 0) node.template = template;
1315
- nodes.set(event.id, node);
1316
- }
1317
- node.state = "running";
1318
- node.runs += 1;
1319
- } else if (event.type === "node.finished") {
1320
- const node = nodes.get(event.id);
1321
- if (node === void 0) continue;
1322
- node.state = event.ok ? "ok" : "failed";
1323
- node.output = event.output;
1324
- node.costUsd += event.usage?.costUsd ?? 0;
1325
- node.inputTokens += event.usage?.inputTokens ?? 0;
1326
- node.outputTokens += event.usage?.outputTokens ?? 0;
1327
- } else if (event.type === "node.skipped") {
1328
- const node = nodes.get(event.id);
1329
- if (node !== void 0) node.state = "skipped";
1330
- }
1331
- const roots = [];
1332
- for (const node of nodes.values()) {
1333
- const owner = node.parent === void 0 ? void 0 : nodes.get(node.parent);
1334
- if (owner === void 0) roots.push(node);
1335
- else owner.children.push(node);
1336
- }
1337
- return roots;
1338
- };
1339
- const alive = (pid) => {
1340
- try {
1341
- process.kill(pid, 0);
1342
- return true;
1343
- } catch (error) {
1344
- return error instanceof Error && "code" in error && error.code === "EPERM";
1345
- }
1346
- };
1347
- /** A run's status, with `crashed` for a `running` run whose process is gone. */
1348
- const liveStatus = (run) => run.status === "running" && !alive(run.pid) ? "crashed" : run.status;
1349
- // ../core/src/update/version.ts
1350
- const VERSION = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/u;
1351
- /** `1.2.3`, `v1.2.3-beta.1` and the like; `null` for anything else. */
1352
- const parseVersion = (text) => {
1353
- const match = VERSION.exec(text.trim());
1354
- if (match === null) return null;
1355
- const [, major = "0", minor = "0", patch = "0", pre] = match;
1356
- return {
1357
- core: [
1358
- Number(major),
1359
- Number(minor),
1360
- Number(patch)
1361
- ],
1362
- pre: pre === void 0 ? [] : pre.split(".")
1363
- };
1364
- };
1365
- const NUMERIC = /^\d+$/u;
1366
- /** One pre-release identifier against another: numbers sort below words. */
1367
- const compareIdentifier = (a, b) => {
1368
- const an = NUMERIC.test(a);
1369
- const bn = NUMERIC.test(b);
1370
- if (an && bn) return Number(a) - Number(b);
1371
- if (an !== bn) return an ? -1 : 1;
1372
- return a < b ? -1 : Number(a > b);
1373
- };
1374
- const comparePre = (a, b) => {
1375
- if (a.length === 0 || b.length === 0) return b.length - a.length;
1376
- for (let i = 0; i < Math.min(a.length, b.length); i += 1) {
1377
- const order = compareIdentifier(a[i] ?? "", b[i] ?? "");
1378
- if (order !== 0) return order;
1379
- }
1380
- return a.length - b.length;
1381
- };
1382
- /** Semver precedence: negative when `a` is older, 0 when equal. */
1383
- const compareVersions = (a, b) => {
1384
- for (let i = 0; i < 3; i += 1) {
1385
- const order = (a.core[i] ?? 0) - (b.core[i] ?? 0);
1386
- if (order !== 0) return order;
1387
- }
1388
- return comparePre(a.pre, b.pre);
1389
- };
1390
- /** Whether `candidate` is newer than `current`; false when either is invalid. */
1391
- const isNewer = (candidate, current) => {
1392
- const a = parseVersion(candidate);
1393
- const b = parseVersion(current);
1394
- return a !== null && b !== null && compareVersions(a, b) > 0;
1395
- };
1396
529
  const updateCachePath = (home = homedir()) => path.join(home, ".ork", "update-check.json");
1397
530
  const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
1398
531
  const text = (record, key) => {
@@ -1484,799 +617,80 @@ const pendingNotice = (cache, current, now) => {
1484
617
  const told = cache?.notified;
1485
618
  return told?.version === newer && age(told.at, now) < 864e5 ? null : newer;
1486
619
  };
1487
- // ../core/src/worktree/list.ts
620
+ // src/runs/chat.ts
1488
621
  /**
1489
- * @file What `ork worktrees` shows and cleans: every worktree and branch Ork's
1490
- * runs recorded creating, whatever the project's git rules named them, and
1491
- * any `ork/*` branch besides, with their state read from git itself.
622
+ * @file `ork chat`'s two paths. A node of a live run gets the message through
623
+ * the store and the run's socket, and the run's bus pushes it into the node's
624
+ * session (specs/messaging.md#chat; `tellLiveRun` and `nodeSession` are in
625
+ * core). A node of a finished run gets it by resuming its last Claude session
626
+ * where it ran, and the exchange is added to the run's record.
1492
627
  */
628
+ /** The declared node a spawned worker (`<template>-<n>`) is an instance of. */
629
+ const templateOf = (nodes, id) => {
630
+ if (id in nodes) return void 0;
631
+ const template = id.replace(/-\d+$/u, "");
632
+ return template in nodes ? template : void 0;
633
+ };
1493
634
  /**
1494
- * The worktrees the project's runs recorded creating, oldest run first; one
1495
- * entry per branch or folder, from the latest run that created it.
635
+ * Resumes a finished node's last session with the user's message, prints the
636
+ * session, and adds it to the run's record. Returns why it could not.
1496
637
  */
1497
- const createdWorktrees = (store, project) => {
1498
- const byName = /* @__PURE__ */ new Map();
1499
- for (const run of store.runs({ project }).toReversed()) for (const { event } of store.events(run.id)) {
1500
- if (event.type !== "worktree" || event.action !== "created") continue;
1501
- byName.set(event.branch ?? event.path, {
1502
- runId: run.id,
1503
- nodeId: event.id,
1504
- path: event.path,
1505
- ...event.branch === void 0 ? {} : { branch: event.branch }
638
+ const resumeChat = async (options) => {
639
+ const { store, run, node } = options;
640
+ const { session, outputs } = nodeSession(store.events(run.id, 0), node);
641
+ if (!session.known) return `${node} is not a node of run ${run.id}.`;
642
+ if (!session.chattable) return `${node} is not a leader or worker; only they have sessions to talk to.`;
643
+ if (session.sessionId === void 0) return `${node} has no Claude session to resume in run ${run.id}.`;
644
+ const cwd = session.cwd ?? session.checkout?.path;
645
+ if (cwd === void 0 || !existsSync(cwd)) return `${node}'s session ran in ${cwd ?? "a folder Ork did not record"}, which is gone (a merged worktree is removed), so it cannot be resumed.`;
646
+ const { workflow, place, workflowDir } = loadWorkflow(run.file, path.dirname(run.file), void 0, store.projects());
647
+ if (workflow === null) return `The workflow ${run.file} is gone or no longer valid.`;
648
+ const notice = chatNotice(store, place, options.home);
649
+ if (notice !== null) options.out(notice);
650
+ const runDir = runDirOf(place, run.id);
651
+ const template = templateOf(workflow.nodes, node);
652
+ const plan = planNodeSession(workflow, node, {
653
+ cwd,
654
+ inputs: {},
655
+ outputs,
656
+ inPlace: session.checkout === void 0,
657
+ workflowDir,
658
+ projectRoot: run.project,
659
+ orkDir: place.dir,
660
+ pluginDir: path.join(runDir, "plugins", node),
661
+ ...options.budgetLeftUsd === void 0 ? {} : { budgetLeftUsd: options.budgetLeftUsd },
662
+ ...session.checkout === void 0 ? {} : { checkout: session.checkout },
663
+ ...template === void 0 ? {} : { template }
664
+ });
665
+ if (!plan.ok) return plan.error;
666
+ const record = appendRecorder(store, run.id, runDir);
667
+ record({
668
+ type: "user.message",
669
+ node,
670
+ body: options.body,
671
+ delivered: "resumed"
672
+ });
673
+ const started = (await options.runtime(options.driver ?? session.driver ?? plan.driver)).start({
674
+ ...plan.spec,
675
+ resume: session.sessionId,
676
+ prompt: options.body
677
+ }, { human: options.human });
678
+ if (options.signal?.aborted === true) started.interrupt();
679
+ options.signal?.addEventListener("abort", () => {
680
+ started.interrupt();
681
+ }, { once: true });
682
+ const tools = /* @__PURE__ */ new Map();
683
+ let failure = null;
684
+ for await (const event of started.events) {
685
+ record({
686
+ type: "node.event",
687
+ id: node,
688
+ event
1506
689
  });
690
+ for (const line of formatEvent(node, event, tools)) options.out(line);
691
+ if (event.type === "session.finished" && event.outcome.subtype === "error_driver") failure = event.outcome.errors.join("; ");
1507
692
  }
1508
- return [...byName.values()];
1509
- };
1510
- const parsePorcelain = (text) => text.split("\n\n").map((block) => lines(block)).map((fields) => {
1511
- const value = (key) => fields.find((f) => f.startsWith(`${key} `))?.slice(key.length + 1);
1512
- return {
1513
- path: value("worktree") ?? "",
1514
- branch: (value("branch") ?? "").replace(/^refs\/heads\//u, ""),
1515
- prunable: fields.some((f) => f.startsWith("prunable"))
1516
- };
1517
- }).filter((entry) => entry.path !== "");
1518
- /** The `ork/<run>/<node>` branches of runs that recorded nothing. */
1519
- const prefixed = (branch) => {
1520
- const [, runId = "", ...rest] = branch.split("/");
1521
- return {
1522
- runId,
1523
- nodeId: rest.join("/"),
1524
- path: "",
1525
- branch
1526
- };
1527
- };
1528
- /** How many commits a detached worktree holds that no branch, tag or remote has. */
1529
- const onNoBranch = async (entry) => entry === void 0 ? "0" : (await git(entry.path, [
1530
- "rev-list",
1531
- "--count",
1532
- "HEAD",
1533
- "--not",
1534
- "--branches",
1535
- "--tags",
1536
- "--remotes"
1537
- ])).stdout;
1538
- /** One created worktree or branch, with its state read from git. */
1539
- const stateOf = async (root, entries, item) => {
1540
- const { branch } = item;
1541
- const entry = branch === void 0 ? entries.find((e) => e.path === item.path) : entries.find((e) => e.branch === branch);
1542
- const missing = entry !== void 0 && (entry.prunable || !existsSync(entry.path));
1543
- const status = entry === void 0 || missing ? "" : (await git(entry.path, ["status", "--porcelain"])).stdout;
1544
- const checkedOut = branch === void 0 && entry !== void 0 && entry.branch !== "" ? entry.branch : void 0;
1545
- const on = branch ?? checkedOut;
1546
- const counted = on === void 0 ? await onNoBranch(entry === void 0 || missing ? void 0 : entry) : (await git(root, [
1547
- "rev-list",
1548
- "--count",
1549
- `HEAD..${on}`
1550
- ])).stdout;
1551
- const ahead = Number(counted);
1552
- return {
1553
- ...branch === void 0 ? {} : { branch },
1554
- ...checkedOut === void 0 ? {} : { checkedOut },
1555
- runId: item.runId,
1556
- nodeId: item.nodeId,
1557
- path: entry?.path ?? null,
1558
- missing,
1559
- dirty: lines(status).length > 0,
1560
- ahead: Number.isNaN(ahead) ? 0 : ahead,
1561
- merged: on !== void 0 && ahead === 0,
1562
- inMainCheckout: entry?.path === root
1563
- };
1564
- };
1565
- /**
1566
- * Every worktree and branch Ork's runs created in the repository at `root`,
1567
- * with its state: those the runs recorded (`created`, whatever the project's
1568
- * git rules named them), and any `ork/` branch besides. A recorded one that git
1569
- * no longer has is left out.
1570
- */
1571
- const listOrkWorktrees = async (root, created = []) => {
1572
- const entries = parsePorcelain(await gitOk(root, [
1573
- "worktree",
1574
- "list",
1575
- "--porcelain"
1576
- ]));
1577
- const branches = lines(await gitOk(root, [
1578
- "for-each-ref",
1579
- "--format=%(refname:short)",
1580
- "refs/heads/"
1581
- ]));
1582
- const recordedBranches = new Set(created.flatMap((c) => c.branch ?? []));
1583
- const items = [...created.filter((c) => c.branch === void 0 ? entries.some((e) => e.path === c.path) : branches.includes(c.branch)), ...branches.filter((b) => b.startsWith("ork/") && !recordedBranches.has(b)).map((b) => prefixed(b))];
1584
- return Promise.all(items.map((item) => stateOf(root, entries, item)));
1585
- };
1586
- const cleanStep = (item, options) => {
1587
- if (item.inMainCheckout) return {
1588
- item,
1589
- removeWorktree: false,
1590
- deleteBranch: false,
1591
- skip: "checked out in the main checkout"
1592
- };
1593
- if (item.dirty && !options.force) return {
1594
- item,
1595
- removeWorktree: false,
1596
- deleteBranch: false,
1597
- skip: "uncommitted changes"
1598
- };
1599
- if (item.branch === void 0 && item.checkedOut === void 0 && item.ahead > 0 && !options.force) return {
1600
- item,
1601
- removeWorktree: false,
1602
- deleteBranch: false,
1603
- skip: "commits on no branch"
1604
- };
1605
- const removable = item.path !== null && !item.missing;
1606
- if (options.branches && item.branch !== void 0 && !item.merged && !options.force) return {
1607
- item,
1608
- removeWorktree: removable,
1609
- deleteBranch: false,
1610
- skip: "branch not merged"
1611
- };
1612
- return {
1613
- item,
1614
- removeWorktree: removable,
1615
- deleteBranch: options.branches && item.branch !== void 0
1616
- };
1617
- };
1618
- /**
1619
- * What `ork worktrees clean` would do. A worktree with uncommitted changes, an
1620
- * unmerged branch, or a detached worktree holding commits no branch has, is
1621
- * kept unless `force`.
1622
- */
1623
- const planClean = (items, filter, options) => items.filter((item) => filter.runId === void 0 || item.runId === filter.runId).filter((item) => !filter.merged || item.merged).map((item) => cleanStep(item, options));
1624
- /** Carries out a clean plan, then prunes stale worktree entries. */
1625
- const applyClean = async (root, steps, force) => {
1626
- const done = [];
1627
- await gitOk(root, ["worktree", "prune"]);
1628
- for (const step of steps) {
1629
- const { item } = step;
1630
- if (step.removeWorktree && item.path !== null) {
1631
- await removeWorktree(root, item.path, force);
1632
- done.push(`removed ${item.path}`);
1633
- }
1634
- if (step.deleteBranch && item.branch !== void 0) {
1635
- await gitOk(root, [
1636
- "branch",
1637
- force ? "-D" : "-d",
1638
- item.branch
1639
- ]);
1640
- done.push(`deleted ${item.branch}`);
1641
- }
1642
- }
1643
- await gitOk(root, ["worktree", "prune"]);
1644
- return done;
1645
- };
1646
- // src/output.ts
1647
- /**
1648
- * @file Terminal output helpers: styled text that turns plain when stdout is
1649
- * not a TTY or `NO_COLOR` is set, and a yes-or-no question on the terminal.
1650
- */
1651
- /** Styles text for stdout; plain when stdout is not a TTY or NO_COLOR is set. */
1652
- const style = (format, text) => styleText(format, text, { stream: process.stdout });
1653
- const write = (text) => {
1654
- process.stdout.write(`${text}\n`);
1655
- };
1656
- const writeErr = (text) => {
1657
- process.stderr.write(`${text}\n`);
1658
- };
1659
- /** Asks a yes-or-no question on the terminal; anything but yes is no. */
1660
- const confirm = async (question) => {
1661
- const rl = createInterface({
1662
- input: process.stdin,
1663
- output: process.stderr
1664
- });
1665
- try {
1666
- const answer = await rl.question(question);
1667
- return ["y", "yes"].includes(answer.trim().toLowerCase());
1668
- } finally {
1669
- rl.close();
1670
- }
1671
- };
1672
- // src/human.ts
1673
- /**
1674
- * @file The terminal `HumanChannel`: answers `ask_user` questions, gates and
1675
- * permission prompts on the terminal, one at a time, and denies or defers
1676
- * when no one is there. In a run it is the decision queue's local answerer,
1677
- * racing `ork decide`.
1678
- */
1679
- const PREVIEW = 160;
1680
- const NO_ONE_TO_PERMIT = "No one is at the terminal to approve this tool, so it is not allowed in this run.";
1681
- const NO_ONE_TO_ASK = "no one (nobody is at the terminal; use your best judgment and say so in your report)";
1682
- /** One line that shows what a tool is about to do. */
1683
- const previewInput = (toolName, input) => {
1684
- const key = [
1685
- "command",
1686
- "file_path",
1687
- "path",
1688
- "url",
1689
- "pattern"
1690
- ].find((k) => typeof input[k] === "string");
1691
- const line = (key === void 0 ? JSON.stringify(input) : `${key}: ${String(input[key])}`).replaceAll(/\s+/gu, " ").trim();
1692
- return line.length > PREVIEW ? `${toolName} ${line.slice(0, 159)}…` : `${toolName} ${line}`;
1693
- };
1694
- /** The option an answer names: its number, or its id. */
1695
- const pickOption = (answer, options, recommended) => {
1696
- const value = answer.trim();
1697
- if (value === "") return recommended;
1698
- const index = Number(value);
1699
- if (Number.isInteger(index) && index >= 1 && index <= options.length) return options[index - 1]?.id;
1700
- return options.find((o) => o.id === value)?.id;
1701
- };
1702
- const optionLine = (option, index, recommended) => {
1703
- const label = option.label === void 0 ? "" : ` ${option.label}`;
1704
- const description = option.description === void 0 ? "" : style("dim", ` - ${option.description}`);
1705
- const mark = option.id === recommended ? style("green", " (recommended)") : "";
1706
- return ` ${String(index + 1)}) ${style("bold", option.id)}${label}${description}${mark}`;
1707
- };
1708
- /**
1709
- * Answers permission prompts and `ask_user` questions on the terminal, one at a
1710
- * time. Without a terminal it denies tools and leaves questions to the node. A
1711
- * withdrawn prompt (answered elsewhere) rejects, which frees the turn.
1712
- */
1713
- const createTerminalHuman = (io) => {
1714
- let rl;
1715
- let turn = Promise.resolve();
1716
- const say = (text) => {
1717
- io.output.write(`${text}\n`);
1718
- };
1719
- const prompt = (text, signal) => {
1720
- rl ??= createInterface({
1721
- input: io.input,
1722
- output: io.output
1723
- });
1724
- return signal === void 0 ? rl.question(text) : rl.question(text, { signal });
1725
- };
1726
- const choose = async (hint, options, recommended, signal) => {
1727
- const picked = pickOption(await prompt(` Choose${hint}: `, signal), options, recommended);
1728
- if (picked !== void 0) return picked;
1729
- say(style("dim", " Type an option's number or id."));
1730
- return choose(hint, options, recommended, signal);
1731
- };
1732
- const oneAtATime = (work) => {
1733
- const result = turn.then(work);
1734
- turn = result.catch(() => null);
1735
- return result;
1736
- };
1737
- return {
1738
- permit: ({ nodeId, toolName, input, signal }) => oneAtATime(async () => {
1739
- if (signal?.aborted === true) throw new Error("the prompt was withdrawn");
1740
- say(`${style(["yellow", "bold"], "?")} ${style("bold", nodeId)} wants to run ${previewInput(toolName, input)}`);
1741
- if (!io.interactive) {
1742
- say(style("dim", ` denied: ${NO_ONE_TO_PERMIT}`));
1743
- return {
1744
- allow: false,
1745
- message: NO_ONE_TO_PERMIT
1746
- };
1747
- }
1748
- const answer = (await prompt(" Allow? [y/N] ", signal)).trim().toLowerCase();
1749
- return answer === "y" || answer === "yes" ? { allow: true } : {
1750
- allow: false,
1751
- message: "The user did not allow this tool."
1752
- };
1753
- }),
1754
- ask: ({ nodeId, question, options, recommended, context, signal }) => oneAtATime(() => {
1755
- if (signal?.aborted === true) return Promise.reject(/* @__PURE__ */ new Error("the question was withdrawn"));
1756
- say(`${style(["yellow", "bold"], "?")} ${style("bold", nodeId)} asks: ${question}`);
1757
- if (context !== void 0) say(style("dim", ` ${context}`));
1758
- for (const [index, option] of options.entries()) say(optionLine(option, index, recommended));
1759
- if (!io.interactive) {
1760
- const answer = recommended ?? NO_ONE_TO_ASK;
1761
- say(style("dim", ` no terminal; answered ${answer}`));
1762
- return Promise.resolve(answer);
1763
- }
1764
- const fallback = options.findIndex((o) => o.id === recommended);
1765
- const hint = fallback === -1 ? "" : ` [${String(fallback + 1)}]`;
1766
- return choose(hint, options, recommended, signal);
1767
- }),
1768
- close: () => {
1769
- rl?.close();
1770
- }
1771
- };
1772
- };
1773
- // src/project/place.ts
1774
- /**
1775
- * @file Which project a folder belongs to, from the projects the store has
1776
- * registered (ADR-035): an attached project's files are in
1777
- * `~/.ork/projects/<id>/`, an in-repo one's in its `.ork/`.
1778
- */
1779
- /** The registered projects; none before Ork has a store (never creates one). */
1780
- const registeredProjects = (home) => {
1781
- const file = storePath(home);
1782
- if (!existsSync(file)) return [];
1783
- const store = openStore(file);
1784
- try {
1785
- return store.projects();
1786
- } finally {
1787
- store.close();
1788
- }
1789
- };
1790
- /** The project `dir` is in: registered, or the in-repo one at its git root. */
1791
- const projectAt = (dir, home) => placeFor(dir, registeredProjects(home));
1792
- /** A path to show: relative to `cwd` when inside it, else as it is. */
1793
- const fromCwd = (target, cwd) => inside(cwd, target) ? path.relative(cwd, target) || "." : target;
1794
- // src/workflows.ts
1795
- /**
1796
- * @file Finding workflow files: the project's workflows folder by default
1797
- * (`.ork/workflows/`, or an attached project's), directories expanded, and a
1798
- * workflow given by name.
1799
- */
1800
- const WORKFLOW_DIR = path.join(".ork", "workflows");
1801
- const WORKFLOW_SUFFIX = ".ork.yaml";
1802
- /**
1803
- * Expands the given paths to workflow files: a directory contributes its
1804
- * `*.ork.yaml` files, and a name one in the project's workflows folder
1805
- * (`workflows`, default `.ork/workflows/` under `cwd`). With no paths, uses
1806
- * that folder.
1807
- */
1808
- const findWorkflowFiles = (paths, cwd, workflows = path.join(cwd, WORKFLOW_DIR)) => {
1809
- return (paths.length > 0 ? paths : [fromCwd(workflows, cwd)]).flatMap((target) => {
1810
- const absolute = path.resolve(cwd, target);
1811
- if (existsSync(absolute) && statSync(absolute).isDirectory()) return readdirSync(absolute).filter((name) => name.endsWith(WORKFLOW_SUFFIX)).toSorted().map((name) => path.join(target, name));
1812
- return [resolveWorkflowArg(target, cwd, workflows)];
1813
- });
1814
- };
1815
- /**
1816
- * A workflow given by path, or by name for one in the project's workflows
1817
- * folder: `hello` is `.ork/workflows/hello.ork.yaml` when no file `hello`
1818
- * exists.
1819
- */
1820
- const resolveWorkflowArg = (arg, cwd, workflows = path.join(cwd, WORKFLOW_DIR)) => {
1821
- if (existsSync(path.resolve(cwd, arg)) || arg.endsWith(".ork.yaml")) return arg;
1822
- const named = path.join(workflows, `${arg}${WORKFLOW_SUFFIX}`);
1823
- return existsSync(named) ? fromCwd(named, cwd) : arg;
1824
- };
1825
- /**
1826
- * A command's workflow argument, by path or by name in the project `cwd` is in,
1827
- * with the project the file belongs to and a run's folder there.
1828
- */
1829
- const workflowArg = (arg, cwd) => {
1830
- const file = resolveWorkflowArg(arg, cwd, placeFiles(projectAt(cwd)).workflows);
1831
- const place = projectAt(path.dirname(path.resolve(cwd, file)));
1832
- return {
1833
- file,
1834
- place,
1835
- runDir: (runId) => runDirOf(place, runId)
1836
- };
1837
- };
1838
- // src/commands/validate.ts
1839
- /**
1840
- * @file `ork validate`: loads, parses and compiles workflow files with the
1841
- * project's rules (`.ork/project.yaml`) and the agents they name, checks
1842
- * their skill and context references, and reports diagnostics compiler-style,
1843
- * with file, line and column. `loadWorkflow` is shared with `ork run`.
1844
- */
1845
- const hasErrors = (diagnostics) => diagnostics.some((d) => d.severity === "error");
1846
- /**
1847
- * Parses and compiles one workflow file, with the agents the user and its
1848
- * project offer. `home` is the user's `~` (tests pass a folder); `registered`
1849
- * the projects a caller's store has, else those of the store in `home`.
1850
- */
1851
- const loadWorkflow = (file, cwd, home, registered = registeredProjects(home)) => {
1852
- const absolute = path.resolve(cwd, file);
1853
- const place = placeFor(path.dirname(absolute), registered);
1854
- const workflowDir = workflowDirOf(place, absolute);
1855
- const where = {
1856
- place,
1857
- workflowDir
1858
- };
1859
- let text;
1860
- try {
1861
- text = readFileSync(path.resolve(cwd, file), "utf8");
1862
- } catch (error) {
1863
- return {
1864
- report: {
1865
- file,
1866
- diagnostics: [{
1867
- severity: "error",
1868
- code: "file.read",
1869
- message: error instanceof Error && "code" in error && error.code === "ENOENT" ? "file not found" : "cannot read file",
1870
- path: []
1871
- }]
1872
- },
1873
- workflow: null,
1874
- graph: null,
1875
- ...where
1876
- };
1877
- }
1878
- const parsed = parseWorkflow(text);
1879
- if (!parsed.ok) return {
1880
- report: {
1881
- file,
1882
- diagnostics: parsed.diagnostics
1883
- },
1884
- workflow: null,
1885
- graph: null,
1886
- ...where
1887
- };
1888
- const projectRoot = place.root;
1889
- const catalog = loadAgents({
1890
- projectRoot,
1891
- orkDir: place.dir,
1892
- ...home === void 0 ? {} : { home }
1893
- });
1894
- const loaded = loadProject(projectRoot, placeFiles(place).rules);
1895
- const compiled = compileWorkflow(parsed.workflow, {
1896
- catalog,
1897
- workflowDir,
1898
- projectRoot,
1899
- ...loaded.project === void 0 ? {} : { project: loaded.project }
1900
- });
1901
- const { graph } = compiled;
1902
- const diagnostics = [
1903
- ...loaded.diagnostics,
1904
- ...compiled.diagnostics,
1905
- ...graph === null ? [] : contextDiagnostics(graph.workflow, {
1906
- workflowDir,
1907
- projectRoot
1908
- })
1909
- ];
1910
- const located = [];
1911
- for (const diagnostic of diagnostics) {
1912
- const position = parsed.locate(diagnostic.path);
1913
- located.push(position === null ? diagnostic : {
1914
- ...diagnostic,
1915
- ...position
1916
- });
1917
- }
1918
- if (graph === null) return {
1919
- report: {
1920
- file,
1921
- diagnostics: located
1922
- },
1923
- workflow: null,
1924
- graph: null,
1925
- ...where
1926
- };
1927
- return {
1928
- report: {
1929
- file,
1930
- diagnostics: located,
1931
- summary: {
1932
- nodes: graph.nodes.size,
1933
- edges: graph.edges.length,
1934
- entries: graph.entries
1935
- }
1936
- },
1937
- workflow: hasErrors(located) ? null : graph.workflow,
1938
- graph: hasErrors(located) ? null : graph,
1939
- ...where
1940
- };
1941
- };
1942
- const validateFile = (file, cwd) => loadWorkflow(file, cwd).report;
1943
- const plural$1 = (count, word) => `${count} ${word}${count === 1 ? "" : "s"}`;
1944
- /** Human-readable report, one line per diagnostic, compiler style. */
1945
- const formatReport = (report) => {
1946
- const lines = report.diagnostics.map((d) => {
1947
- const where = d.line === void 0 ? report.file : `${report.file}:${d.line}:${d.column ?? 1}`;
1948
- const severity = d.severity === "error" ? style(["red", "bold"], "error") : style(["yellow", "bold"], "warning");
1949
- return `${style("dim", where)} ${severity} ${d.message} ${style("dim", `(${d.code})`)}`;
1950
- });
1951
- const errors = report.diagnostics.filter((d) => d.severity === "error").length;
1952
- const warnings = report.diagnostics.length - errors;
1953
- if (report.summary !== void 0 && errors === 0) {
1954
- const { nodes, edges, entries } = report.summary;
1955
- const extra = warnings > 0 ? `, ${plural$1(warnings, "warning")}` : "";
1956
- lines.push(`${style("green", "✓")} ${report.file} ${style("dim", `(${plural$1(nodes, "node")}, ${plural$1(edges, "edge")}, starts at ${entries.join(", ")}${extra})`)}`);
1957
- } else lines.push(`${style("red", "✗")} ${report.file} ${style("dim", `(${plural$1(errors, "error")}, ${plural$1(warnings, "warning")})`)}`);
1958
- return lines;
1959
- };
1960
- const validate = defineCommand({
1961
- meta: {
1962
- name: "validate",
1963
- description: "Check workflow files: schema, ownership tree, edges, loops, and references"
1964
- },
1965
- args: { json: {
1966
- type: "boolean",
1967
- description: "Print the reports as JSON",
1968
- default: false
1969
- } },
1970
- run: ({ args }) => {
1971
- const cwd = process.cwd();
1972
- const files = findWorkflowFiles(args._, cwd, placeFiles(projectAt(cwd)).workflows);
1973
- if (files.length === 0) {
1974
- write("No workflow files found. Run `ork init`, or pass a path.");
1975
- process.exitCode = 1;
1976
- return;
1977
- }
1978
- const reports = files.map((file) => validateFile(file, cwd));
1979
- if (args.json) write(JSON.stringify(reports, void 0, 2));
1980
- else for (const report of reports) for (const line of formatReport(report)) write(line);
1981
- if (reports.some((r) => hasErrors(r.diagnostics))) process.exitCode = 1;
1982
- }
1983
- });
1984
- // src/run/format.ts
1985
- const STATUS_MARK = {
1986
- pass: style("green", "✓"),
1987
- done: style("green", "✓"),
1988
- changes_requested: style("yellow", "!"),
1989
- fail: style("red", "✗")
1990
- };
1991
- const seconds = (ms) => `${(ms / 1e3).toFixed(1)}s`;
1992
- const indent = (text) => text.trimEnd().split("\n").map((line) => ` ${line}`);
1993
- /** The usage line: cost, tokens, turns and time. */
1994
- const formatUsage = ({ usage }) => style("dim", [
1995
- `$${usage.costUsd.toFixed(4)}`,
1996
- `${String(usage.inputTokens)} in / ${String(usage.outputTokens)} out (${String(usage.cacheReadTokens)} cached)`,
1997
- `${String(usage.turns)} turns`,
1998
- seconds(usage.durationMs)
1999
- ].join(" · "));
2000
- /** How a finished session reads on the terminal. */
2001
- const formatOutcome = (nodeId, outcome) => {
2002
- const { report } = outcome;
2003
- if (report === null) return [
2004
- `${style("red", "✗")} ${style("bold", nodeId)} ended without a report (${outcome.subtype})`,
2005
- ...outcome.errors.map((e) => ` ${style("red", e)}`),
2006
- ` ${formatUsage(outcome)}`
2007
- ];
2008
- const lines = [`${STATUS_MARK[report.status] ?? "-"} ${style("bold", nodeId)} ${report.status}: ${report.summary}`];
2009
- if (report.branch !== void 0) lines.push(` branch: ${report.branch}`);
2010
- for (const [name, file] of Object.entries(report.artifacts)) lines.push(` ${name}: ${file}`);
2011
- for (const [name, value] of Object.entries(report.fields)) lines.push(` ${name}: ${JSON.stringify(value)}`);
2012
- lines.push(` ${formatUsage(outcome)}`);
2013
- return lines;
2014
- };
2015
- /** The transcript lines for one event; tool names come from `tools`. */
2016
- const formatEvent = (nodeId, event, tools) => {
2017
- if (event.type === "session.started") return [style("dim", `▸ ${nodeId} (${event.model}, session ${event.sessionId})`)];
2018
- if (event.type === "assistant.text") return indent(event.text);
2019
- if (event.type === "tool.called") {
2020
- tools.set(event.id, event.name);
2021
- return [style("dim", ` · ${previewInput(event.name, event.input)}`)];
2022
- }
2023
- if (event.type === "tool.result") return event.isError ? [style("red", ` ✗ ${tools.get(event.id) ?? "tool"} failed`)] : [];
2024
- return formatOutcome(nodeId, event.outcome);
2025
- };
2026
- /** A one-line verdict for a finished node, by what its type outputs. */
2027
- const nodeVerdict = (output) => {
2028
- const { status, summary, exitCode, choice, answeredBy, count } = output;
2029
- if (typeof status === "string") return typeof summary === "string" && summary !== "" ? `${status}: ${summary}` : status;
2030
- if (typeof exitCode === "number") return `exit ${String(exitCode)}`;
2031
- if (typeof choice === "string") return `${choice} (${String(answeredBy)})`;
2032
- if (typeof count === "number") return `${String(count)} ran`;
2033
- return "done";
2034
- };
2035
- /** Transcript lines for one run event, prefixed with the node they are from. */
2036
- const formatRunEvent = (event, width, tools) => {
2037
- const tag = (id) => style("cyan", `${id.padEnd(width)} │`);
2038
- if (event.type === "run.started") return [style("dim", `▸ run ${event.runId} of ${event.workflow} (starts at ${event.entries.join(", ")})`)];
2039
- if (event.type === "node.started") {
2040
- const round = event.round > 1 ? ` (round ${String(event.round)})` : "";
2041
- const owner = event.owner === void 0 ? "" : `, for ${event.owner}`;
2042
- return [`${tag(event.id)} ${style("dim", `${event.nodeType}${round}${owner}`)}`];
2043
- }
2044
- if (event.type === "node.event") {
2045
- const inner = event.event;
2046
- if (inner.type === "session.finished") return [];
2047
- if (inner.type === "session.started") return [`${tag(event.id)} ${style("dim", `${inner.model}, session ${inner.sessionId}`)}`];
2048
- return formatEvent(event.id, event.event, tools).map((line) => `${tag(event.id)} ${line.replace(/^ {2}/u, "")}`);
2049
- }
2050
- if (event.type === "node.finished") {
2051
- const mark = event.ok ? style("green", "✓") : style("red", "✗");
2052
- const cost = event.usage === void 0 ? "" : style("dim", ` · $${event.usage.costUsd.toFixed(4)}`);
2053
- return [`${tag(event.id)} ${mark} ${nodeVerdict(event.output)}${cost}`];
2054
- }
2055
- if (event.type === "node.skipped") return [`${tag(event.id)} ${style("dim", "- skipped")}`];
2056
- if (event.type === "loop.exhausted") return [`${style(["yellow", "bold"], "!")} the loop ${event.from} -> ${event.to} used all ${String(event.maxRounds)} rounds`];
2057
- if (event.type === "edge.taken") return [];
2058
- if (event.type === "worktree") {
2059
- const detail = event.detail === void 0 ? "" : ` (${event.detail})`;
2060
- const line = `${event.action} ${event.branch ?? event.path}${detail}`;
2061
- return [`${tag(event.id)} ${event.action === "conflict" ? style("yellow", `! ${line}`) : style("dim", `⎇ ${line}`)}`];
2062
- }
2063
- if (event.type === "decision.opened") {
2064
- const what = event.kind === "permission" ? "asks to" : "asks:";
2065
- const question = event.kind === "permission" ? event.question.replace(/^Allow /u, "run ").replace(/\?$/u, "") : event.question;
2066
- return [`${tag(event.node)} ${style(["yellow", "bold"], "?")} ${what} ${question} ${style("dim", `(decision ${event.decision}; ork decide)`)}`];
2067
- }
2068
- if (event.type === "decision.answered") {
2069
- const by = event.by === "decide" ? "ork decide" : "terminal";
2070
- return [`${tag(event.node)} ${style("dim", `→ ${event.answer} (${by})`)}`];
2071
- }
2072
- if (event.type === "decision.withdrawn") return [`${tag(event.node)} ${style("dim", "→ question withdrawn")}`];
2073
- if (event.type === "run.command") return [style(["yellow", "bold"], `! ork ${event.command} received`)];
2074
- if (event.type === "run.finished") return formatRunResult(event.result);
2075
- if (event.type === "run.resumed") {
2076
- const [one] = event.kept;
2077
- const kept = one === void 0 ? "every node runs again" : event.kept.length === 1 ? `${one} keeps its result` : `${event.kept.join(", ")} keep their results`;
2078
- return ["", style("dim", `▸ resumed: ${kept}`)];
2079
- }
2080
- if (event.type === "feedback.recorded") {
2081
- const { target, kind, title } = event.feedback;
2082
- const line = style("dim", `✎ feedback (${target}, ${kind}): ${title}`);
2083
- return [event.node === void 0 ? line : `${tag(event.node)} ${line}`];
2084
- }
2085
- if (event.type === "user.message") {
2086
- const note = event.delivered === "queued" ? " (for its next session)" : "";
2087
- return [`${tag(event.node)} ${style(["magenta", "bold"], "you:")} ${firstLine(event.body)}${style("dim", note)}`];
2088
- }
2089
- return formatBusEvent(event).map((line) => `${tag(event.node)} ${line}`);
2090
- };
2091
- /** The first line of a message body, cut to fit one terminal line. */
2092
- const firstLine = (body) => {
2093
- const [line = ""] = body.trim().split("\n");
2094
- return line.length > 100 ? `${line.slice(0, 99)}…` : line;
2095
- };
2096
- /** Messages, owner questions and claims, without the node prefix. */
2097
- const formatBusEvent = (event) => {
2098
- if (event.type === "claim.taken") return [style("dim", `⚑ claimed ${event.resource}`)];
2099
- if (event.type === "claim.refused") {
2100
- const held = event.held === event.resource ? "" : ` (it holds ${event.held})`;
2101
- return [style("yellow", `⚑ ${event.resource} is ${event.holder}'s${held}`)];
2102
- }
2103
- if (event.type === "claim.released") return [style("dim", `⚑ released ${event.resource}`)];
2104
- if (event.type === "message.sent") return [style("dim", `✉ to ${event.to}: ${firstLine(event.body)}`)];
2105
- if (event.type === "message.read") return [];
2106
- if (event.type === "owner.asked") return [`${style(["yellow", "bold"], "?")} asked as the owner of ${event.about}: ${event.text} ${style("dim", `(${event.question})`)}`];
2107
- if (event.type === "owner.answered") return [style("dim", `→ ${event.answer} (owner)`)];
2108
- return [style("dim", "→ its session ended first; asking you")];
2109
- };
2110
- const RUN_MARK = {
2111
- succeeded: style("green", "✓"),
2112
- failed: style("red", "✗"),
2113
- budget_exhausted: style("yellow", "!"),
2114
- interrupted: style("yellow", "!")
2115
- };
2116
- /** The run summary: status, cost, what ran, branches to look at, and why. */
2117
- const formatRunResult = (result) => {
2118
- const ran = result.nodes.filter((n) => n.runs > 0).length;
2119
- const skipped = result.nodes.length - ran;
2120
- const lines = ["", `${RUN_MARK[result.status]} run ${result.runId} ${result.status.replace("_", " ")} ${style("dim", `· $${result.costUsd.toFixed(4)} · ${String(ran)} ran, ${String(skipped)} skipped`)}`];
2121
- for (const kept of result.worktrees) lines.push(` ${kept.id}: ${kept.branch ?? "detached"} ${style("dim", `at ${kept.path} (${kept.reason})`)}`);
2122
- for (const reason of result.reasons) lines.push(` ${style("red", reason)}`);
2123
- return lines;
2124
- };
2125
- // src/runs/chat.ts
2126
- /**
2127
- * @file `ork chat`'s two paths. A node of a live run gets the message through
2128
- * the store and the run's socket, and the run's bus pushes it into the node's
2129
- * session (specs/messaging.md#chat). A node of a finished run gets it by
2130
- * resuming its last Claude session where it ran, and the exchange is added to
2131
- * the run's record.
2132
- */
2133
- const chattable = (type) => type === "claude.leader" || type === "claude.worker";
2134
- /** Reads `node`'s session from a run's events, and every node's last output. */
2135
- const nodeSession = (events, node) => {
2136
- const session = {
2137
- known: false,
2138
- chattable: false,
2139
- running: false
2140
- };
2141
- const outputs = {};
2142
- for (const { event } of events) {
2143
- if (event.type === "run.started") {
2144
- const declared = event.nodes.find((n) => n.id === node);
2145
- if (declared !== void 0) {
2146
- session.known = true;
2147
- session.chattable = chattable(declared.type);
2148
- }
2149
- }
2150
- if (event.type === "node.finished") outputs[event.id] = event.output;
2151
- if (!("id" in event) || event.id !== node) continue;
2152
- if (event.type === "node.started") {
2153
- session.known = true;
2154
- session.chattable = chattable(event.nodeType);
2155
- session.running = true;
2156
- }
2157
- if (event.type === "node.finished") session.running = false;
2158
- if (event.type === "worktree" && event.action === "created") session.checkout = {
2159
- path: event.path,
2160
- ...event.branch === void 0 ? {} : { branch: event.branch },
2161
- own: true,
2162
- owner: node
2163
- };
2164
- if (event.type !== "node.event") continue;
2165
- const inner = event.event;
2166
- if (inner.type === "session.started") {
2167
- session.sessionId = inner.sessionId;
2168
- if (inner.cwd !== void 0) session.cwd = inner.cwd;
2169
- if (inner.driver !== void 0) session.driver = inner.driver;
2170
- }
2171
- if (inner.type === "session.finished" && inner.outcome.sessionId !== "") session.sessionId = inner.outcome.sessionId;
2172
- }
2173
- return {
2174
- session,
2175
- outputs
2176
- };
2177
- };
2178
- /** Sends a live run's node a message; false when the run did not wake. */
2179
- const tellLiveRun = (store, run, node, body, home) => {
2180
- store.command(run.id, {
2181
- kind: "message",
2182
- node,
2183
- body
2184
- });
2185
- return wakeRun(socketPath(run.id, home));
2186
- };
2187
- /** The declared node a spawned worker (`<template>-<n>`) is an instance of. */
2188
- const templateOf = (nodes, id) => {
2189
- if (id in nodes) return void 0;
2190
- const template = id.replace(/-\d+$/u, "");
2191
- return template in nodes ? template : void 0;
2192
- };
2193
- /**
2194
- * Resumes a finished node's last session with the user's message, prints the
2195
- * session, and adds it to the run's record. Returns why it could not.
2196
- */
2197
- const resumeChat = async (options) => {
2198
- const { store, run, node } = options;
2199
- const { session, outputs } = nodeSession(store.events(run.id, 0), node);
2200
- if (!session.known) return `${node} is not a node of run ${run.id}.`;
2201
- if (!session.chattable) return `${node} is not a leader or worker; only they have sessions to talk to.`;
2202
- if (session.sessionId === void 0) return `${node} has no Claude session to resume in run ${run.id}.`;
2203
- const cwd = session.cwd ?? session.checkout?.path;
2204
- if (cwd === void 0 || !existsSync(cwd)) return `${node}'s session ran in ${cwd ?? "a folder Ork did not record"}, which is gone (a merged worktree is removed), so it cannot be resumed.`;
2205
- const { workflow, place, workflowDir } = loadWorkflow(run.file, path.dirname(run.file), void 0, store.projects());
2206
- if (workflow === null) return `The workflow ${run.file} is gone or no longer valid.`;
2207
- const runDir = runDirOf(place, run.id);
2208
- const template = templateOf(workflow.nodes, node);
2209
- const plan = planNodeSession(workflow, node, {
2210
- cwd,
2211
- inputs: {},
2212
- outputs,
2213
- inPlace: session.checkout === void 0,
2214
- workflowDir,
2215
- projectRoot: run.project,
2216
- orkDir: place.dir,
2217
- pluginDir: path.join(runDir, "plugins", node),
2218
- ...session.checkout === void 0 ? {} : { checkout: session.checkout },
2219
- ...template === void 0 ? {} : { template }
2220
- });
2221
- if (!plan.ok) return plan.error;
2222
- const record = appendRecorder(store, run.id, runDir);
2223
- record({
2224
- type: "user.message",
2225
- node,
2226
- body: options.body,
2227
- delivered: "resumed"
2228
- });
2229
- const started = (await options.runtime(options.driver ?? session.driver ?? plan.driver)).start({
2230
- ...plan.spec,
2231
- resume: session.sessionId,
2232
- prompt: options.body
2233
- }, { human: options.human });
2234
- const tools = /* @__PURE__ */ new Map();
2235
- let failure = null;
2236
- for await (const event of started.events) {
2237
- record({
2238
- type: "node.event",
2239
- id: node,
2240
- event
2241
- });
2242
- for (const line of formatEvent(node, event, tools)) options.out(line);
2243
- if (event.type === "session.finished" && event.outcome.subtype === "error_driver") failure = event.outcome.errors.join("; ");
2244
- }
2245
- return failure;
2246
- };
2247
- // src/runs/find.ts
2248
- /**
2249
- * @file Finding a stored run from a command's argument: a run id or a unique
2250
- * prefix of one, or, with none, the newest run of this project.
2251
- */
2252
- const findRun = (store, arg, cwd) => {
2253
- if (arg === void 0 || arg === "") {
2254
- const [latest] = store.runs({
2255
- project: placeFor(cwd, store.projects()).root,
2256
- limit: 1
2257
- });
2258
- return latest === void 0 ? {
2259
- ok: false,
2260
- error: "No runs in this project yet; see `ork ps --all`."
2261
- } : {
2262
- ok: true,
2263
- run: latest
2264
- };
2265
- }
2266
- const found = store.lookup(arg);
2267
- const [only] = found;
2268
- if (only === void 0) return {
2269
- ok: false,
2270
- error: `No run "${arg}".`
2271
- };
2272
- if (found.length > 1) return {
2273
- ok: false,
2274
- error: `"${arg}" matches ${String(found.length)} runs: ${found.map((r) => r.id).join(", ")}`
2275
- };
2276
- return {
2277
- ok: true,
2278
- run: only
2279
- };
693
+ return failure;
2280
694
  };
2281
695
  // src/runs/logs.ts
2282
696
  /**
@@ -2284,887 +698,50 @@ const findRun = (store, arg, cwd) => {
2284
698
  * printed, for the whole run or one node, and tails a live run until it
2285
699
  * ends.
2286
700
  */
2287
- /** Turns stored events into lines, keeping the state a transcript needs. */
2288
- const createLogPrinter = (options) => {
2289
- const tools = /* @__PURE__ */ new Map();
2290
- let width = 8;
2291
- return (stored) => {
2292
- const { event } = stored;
2293
- if (event.type === "run.started") width = Math.max(...event.nodes.map((n) => n.id.length), 1);
2294
- if (options.node !== void 0 && nodeOf(event) !== options.node) return [];
2295
- if (options.json) return [JSON.stringify(stored)];
2296
- return formatRunEvent(event, width, tools);
2297
- };
2298
- };
2299
- /**
2300
- * Prints a run's events; with `follow`, then each new one as it is stored:
2301
- * pushed over the run's socket, or polled from the store when there is none.
2302
- * Returns when `run.finished` is stored, the run's process is gone, or `signal`
2303
- * aborts.
2304
- */
2305
- const followRun = async (store, run, out, options) => {
2306
- const print = createLogPrinter(options);
2307
- /** Prints what the run's socket pushes after `after`, until it closes. */
2308
- const viaSocket = async (after) => {
2309
- let last = after;
2310
- let finished = false;
2311
- return {
2312
- via: await followSocket(socketPath(run.id, options.home), after, (stored) => {
2313
- if (stored.seq <= last) return;
2314
- last = stored.seq;
2315
- for (const line of print(stored)) out(line);
2316
- if (stored.event.type === "run.finished") finished = true;
2317
- }, options.signal),
2318
- seq: last,
2319
- finished
2320
- };
2321
- };
2322
- let seq = options.after ?? 0;
2323
- for (;;) {
2324
- let finished = false;
2325
- for (const stored of store.events(run.id, seq)) {
2326
- seq = stored.seq;
2327
- for (const line of print(stored)) out(line);
2328
- finished ||= stored.event.type === "run.finished";
2329
- }
2330
- if (finished || !options.follow) return;
2331
- const pushed = await viaSocket(seq);
2332
- seq = pushed.seq;
2333
- if (pushed.finished || options.signal?.aborted === true) return;
2334
- const { via } = pushed;
2335
- if (via === "ended") continue;
2336
- const [now] = store.lookup(run.id);
2337
- if (now === void 0 || liveStatus(now) !== "running") {
2338
- if (now !== void 0 && liveStatus(now) === "crashed") out(style("red", `run ${run.id} stopped without finishing (its process is gone)`));
2339
- return;
2340
- }
2341
- try {
2342
- await setTimeout$1(options.intervalMs ?? 250, void 0, { signal: options.signal });
2343
- } catch {
2344
- return;
2345
- }
2346
- }
2347
- };
2348
- // src/run/detach.ts
2349
- /**
2350
- * @file `ork run --detach` and `ork resume --detach`: starts the same command
2351
- * as a background process with no terminal, its output in the run's folder
2352
- * (`.ork/runs/<run>/`, or the attached project's) as `output.log`, and
2353
- * returns once the run is running. Its questions wait for `ork decide`.
2354
- */
2355
- /** Set in a detached run's environment: no terminal answers its questions. */
2356
- const DETACHED_ENV = "ORK_DETACHED";
2357
- const detachRun = async (options) => {
2358
- const { runId } = options;
2359
- const dir = options.runDir;
2360
- mkdirSync(dir, { recursive: true });
2361
- const log = path.join(dir, "output.log");
2362
- const fd = openSync(log, "a");
2363
- let exited = null;
2364
- const script = process.argv[1] ?? "";
2365
- const child = spawn(process.execPath, [
2366
- ...process.execArgv,
2367
- script,
2368
- ...options.args
2369
- ], {
2370
- cwd: options.cwd,
2371
- detached: true,
2372
- stdio: [
2373
- "ignore",
2374
- fd,
2375
- fd
2376
- ],
2377
- env: {
2378
- ...process.env,
2379
- [DETACHED_ENV]: "1",
2380
- NO_COLOR: "1"
2381
- }
2382
- });
2383
- child.on("exit", (code) => {
2384
- exited = code ?? 1;
2385
- });
2386
- child.unref();
2387
- const deadline = Date.now() + (options.timeoutMs ?? 15e3);
2388
- while (Date.now() < deadline) {
2389
- if (options.started?.(child.pid) ?? options.store.lookup(runId).length > 0) return {
2390
- ok: true,
2391
- runId,
2392
- log
2393
- };
2394
- if (exited !== null) return {
2395
- ok: false,
2396
- error: readFileSync(log, "utf8").trim(),
2397
- log
2398
- };
2399
- await setTimeout$1(100);
2400
- }
2401
- return {
2402
- ok: false,
2403
- error: "the run did not start in time",
2404
- log
2405
- };
2406
- };
2407
- // ../nodes/src/claude.ts
2408
- /**
2409
- * @file The Claude node type: runs a `claude.leader` or `claude.worker` as one
2410
- * session, planned with the run's outputs (and the brief its owner gave it),
2411
- * and turns its report into the node's output. A leader gets its children
2412
- * through the session's ports, and resumes its session on re-entry
2413
- * (ADR-019).
2414
- */
2415
- const MAX_INPUT = 8192;
2416
- /** On a loop re-entry, the brief carries what the workflow sent back. */
2417
- const reentryNote = (round, input) => {
2418
- const json = JSON.stringify(input ?? {}, void 0, 2);
2419
- const body = json.length > MAX_INPUT ? `${json.slice(0, MAX_INPUT)}\n…` : json;
2420
- return `\n\n## Round ${String(round)}\n\nThe workflow sent this task back to you with:\n\n\`\`\`json\n${body}\n\`\`\``;
2421
- };
2422
- /**
2423
- * What a resumed leader is told: it already has its brief and its earlier work,
2424
- * so only the new round, and the new task when its owner sent one.
2425
- */
2426
- const resumeMessage = (round, input, brief) => [brief === void 0 ? sentBack(round, input) : `## New task from \`${brief.from}\`\n\n${brief.text}`, "End with a new structured report."].join("\n\n");
2427
- /** A first-round resume is a session the run was cut off in (`ork resume`). */
2428
- const sentBack = (round, input) => round === 1 ? "Ork was stopped while you worked on this task, and the run is resumed. Your earlier turns are above: check what you finished, and carry on from there." : `The workflow sent your task back to you.${reentryNote(round, input)}`;
2429
- /** What a session is told when messages came in as it finished. */
2430
- const lateMessage = (count) => `[ork] ${String(count)} message${count === 1 ? "" : "s"} came in while you finished. Call read_inbox, act on ${count === 1 ? "it" : "them"} if needed, and end with a new structured report.`;
2431
- /** The later outcome, with the cost and tokens of both sessions. */
2432
- const withUsageOf = (first, later) => {
2433
- const sum = (key) => first.usage[key] + later.usage[key];
2434
- const usage = {
2435
- costUsd: sum("costUsd"),
2436
- inputTokens: sum("inputTokens"),
2437
- outputTokens: sum("outputTokens"),
2438
- cacheReadTokens: sum("cacheReadTokens"),
2439
- cacheWriteTokens: sum("cacheWriteTokens"),
2440
- turns: sum("turns"),
2441
- durationMs: sum("durationMs")
2442
- };
2443
- return {
2444
- ...later,
2445
- usage
2446
- };
2447
- };
2448
- /** A finished session as a node result: its report is the node's output. */
2449
- const resultOf = (outcome) => {
2450
- if (outcome === null) return {
2451
- ok: false,
2452
- output: {
2453
- status: "fail",
2454
- summary: "the session ended without a result"
2455
- },
2456
- error: "the session ended without a result"
2457
- };
2458
- const { report, usage } = outcome;
2459
- if (report === null) {
2460
- const reason = outcome.errors[0] ?? "no valid report";
2461
- return {
2462
- ok: false,
2463
- output: {
2464
- status: "fail",
2465
- summary: reason,
2466
- usage
2467
- },
2468
- usage,
2469
- error: `ended without a report (${outcome.subtype}): ${reason}`,
2470
- sessionId: outcome.sessionId
2471
- };
2472
- }
2473
- const ran = { sessionId: outcome.sessionId };
2474
- const output = {
2475
- ...report.fields,
2476
- status: report.status,
2477
- summary: report.summary,
2478
- artifacts: report.artifacts,
2479
- usage
2480
- };
2481
- if (report.branch !== void 0) output["branch"] = report.branch;
2482
- const result = {
2483
- ok: report.status !== "fail",
2484
- output,
2485
- usage,
2486
- ...ran
2487
- };
2488
- if (!result.ok) result.error = report.summary;
2489
- return result;
2490
- };
2491
- /** A re-entered leader resumes; anyone else starts with the full brief. */
2492
- const specFor = (spec, context) => {
2493
- if (context.resume !== void 0) return {
2494
- ...spec,
2495
- resume: context.resume,
2496
- prompt: resumeMessage(context.round, context.input, context.brief)
2497
- };
2498
- if (context.round > 1 && context.brief === void 0) return {
2499
- ...spec,
2500
- prompt: spec.prompt + reentryNote(context.round, context.input)
2501
- };
2502
- return spec;
2503
- };
2504
- /** Runs a `claude.leader` or `claude.worker` as one session. */
2505
- const claudeExecutor = (options) => async (context) => {
2506
- const planOptions = {
2507
- cwd: context.cwd,
2508
- inputs: context.inputs,
2509
- outputs: context.outputs,
2510
- inPlace: options.inPlace,
2511
- workflowDir: context.workflowDir,
2512
- projectRoot: context.projectRoot,
2513
- ...context.orkDir === void 0 ? {} : { orkDir: context.orkDir },
2514
- pluginDir: path.join(context.runDir, "plugins", context.id)
2515
- };
2516
- if (context.budgetLeftUsd !== void 0) planOptions.budgetLeftUsd = context.budgetLeftUsd;
2517
- if (context.checkout !== void 0) planOptions.checkout = context.checkout;
2518
- if (context.brief !== void 0) planOptions.brief = context.brief;
2519
- if (context.template !== void 0) planOptions.template = context.template;
2520
- if (context.ownership !== void 0) planOptions.ownership = context.ownership;
2521
- if (context.mailbox !== void 0) planOptions.mailbox = context.mailbox;
2522
- if (context.claims !== void 0) planOptions.claims = context.claims;
2523
- const plan = planNodeSession(context.workflow, context.id, planOptions);
2524
- if (!plan.ok) return {
2525
- ok: false,
2526
- output: {
2527
- status: "fail",
2528
- summary: plan.error
2529
- },
2530
- error: plan.error
2531
- };
2532
- const spec = specFor(plan.spec, context);
2533
- const runtime = await options.runtimeFor(options.driver ?? plan.driver);
2534
- const ports = { human: context.human };
2535
- if (context.ownership !== void 0) ports.ownership = context.ownership;
2536
- if (context.mailbox !== void 0) ports.mailbox = context.mailbox;
2537
- if (context.claims !== void 0) ports.claims = context.claims;
2538
- if (context.feedback !== void 0) ports.feedback = context.feedback;
2539
- const run = async (asked) => {
2540
- const session = runtime.start(asked, ports);
2541
- const stop = () => {
2542
- session.interrupt();
2543
- };
2544
- context.signal.addEventListener("abort", stop, { once: true });
2545
- const disconnect = context.mailbox?.connect((notice) => {
2546
- session.send(notice);
2547
- });
2548
- let outcome = null;
2549
- try {
2550
- for await (const event of session.events) {
2551
- if (event.type === "session.finished") outcome = event.outcome;
2552
- context.emit(event);
2553
- }
2554
- } finally {
2555
- disconnect?.();
2556
- context.signal.removeEventListener("abort", stop);
2557
- }
2558
- return outcome;
2559
- };
2560
- const first = await run(spec);
2561
- const late = context.mailbox?.pending() ?? 0;
2562
- const stopped = context.signal.aborted;
2563
- if (first === null || first.report === null || late === 0 || stopped) return resultOf(first);
2564
- const again = await run({
2565
- ...spec,
2566
- resume: first.sessionId,
2567
- prompt: lateMessage(late)
2568
- });
2569
- return resultOf(again === null ? first : withUsageOf(first, again));
2570
- };
2571
- // ../nodes/src/gate.ts
2572
- /**
2573
- * @file The `human.gate` node type: asks the human the rendered question, and
2574
- * answers with the `default` when the `timeout` passes.
2575
- */
2576
- const UNITS = {
2577
- s: 1e3,
2578
- m: 6e4,
2579
- h: 36e5
2580
- };
2581
- /** `30s`, `15m`, `2h` in milliseconds; `undefined` without a timeout. */
2582
- const timeoutMs = (timeout) => {
2583
- const match = /^(\d+)([smh])$/u.exec(timeout ?? "");
2584
- if (match === null) return void 0;
2585
- const [, amount = "0", unit = "s"] = match;
2586
- return Number(amount) * (UNITS[unit] ?? 1e3);
2587
- };
2588
- const choiceOf = (option) => {
2589
- if (typeof option === "string") return { id: option };
2590
- const choice = { id: option.id };
2591
- if (option.label !== void 0) choice.label = option.label;
2592
- if (option.description !== void 0) choice.description = option.description;
2593
- return choice;
2594
- };
2595
- /**
2596
- * Asks the human and outputs `choice` and `answeredBy`. With `timeout`, the
2597
- * `default` answers when no one has.
2598
- */
2599
- const gateExecutor = async (context) => {
2600
- const { node } = context;
2601
- if (node.type !== "human.gate") return {
2602
- ok: false,
2603
- output: {},
2604
- error: `${context.id} is not a gate`
2605
- };
2606
- const template = parseTemplate(node.question);
2607
- const question = template.ok ? renderTemplate(template.parts, runScope(context.inputs, context.outputs, context.runId)) : node.question;
2608
- const options = node.options.map((o) => choiceOf(o));
2609
- const withdraw = new AbortController();
2610
- context.signal.addEventListener("abort", () => {
2611
- withdraw.abort();
2612
- });
2613
- const asked = {
2614
- nodeId: context.id,
2615
- question,
2616
- options,
2617
- signal: withdraw.signal
2618
- };
2619
- if (node.recommended !== void 0) asked.recommended = node.recommended;
2620
- const answer = context.human.ask(asked).then((choice) => ({
2621
- choice,
2622
- answeredBy: "user"
2623
- }));
2624
- const ms = timeoutMs(node.timeout);
2625
- let timer;
2626
- const expired = ms === void 0 || node.default === void 0 ? [] : [new Promise((resolve) => {
2627
- timer = setTimeout(() => {
2628
- withdraw.abort();
2629
- resolve({
2630
- choice: node.default ?? "",
2631
- answeredBy: "timeout"
2632
- });
2633
- }, ms);
2634
- })];
2635
- try {
2636
- const output = await Promise.race([answer, ...expired]);
2637
- if (!options.some((o) => o.id === output.choice)) return {
2638
- ok: false,
2639
- output,
2640
- error: "no one answered the gate"
2641
- };
2642
- return {
2643
- ok: true,
2644
- output
2645
- };
2646
- } catch {
2647
- return {
2648
- ok: false,
2649
- output: {},
2650
- error: "the gate was withdrawn"
2651
- };
2652
- } finally {
2653
- clearTimeout(timer);
2654
- }
2655
- };
2656
- // ../nodes/src/tools.ts
2657
- /**
2658
- * @file The tool node executor, for every registered integration (`tool.gh`,
2659
- * `tool.aws`, `tool.git`, `tool.shell`, …): the user's own program, run
2660
- * without a shell, checked against `allow` (or the integration's default),
2661
- * its arguments and observed output fields from the integration's adapter,
2662
- * long output kept in run files.
2663
- */
2664
- /** How much of stdout and stderr a node's output keeps. */
2665
- const OUTPUT_LIMIT = 65536;
2666
- /**
2667
- * Splits a command into arguments the way a shell would, without running one:
2668
- * whitespace separates, `'…'` is literal, `"…"` and `\` escape, and `{{ … }}`
2669
- * stays whole so a template is rendered inside its own argument.
2670
- */
2671
- const tokenize = (command) => {
2672
- const args = [];
2673
- let current = null;
2674
- let index = 0;
2675
- const add = (text) => {
2676
- current = (current ?? "") + text;
2677
- };
2678
- while (index < command.length) {
2679
- const char = command.charAt(index);
2680
- if (command.startsWith("{{", index)) {
2681
- const end = command.indexOf("}}", index + 2);
2682
- if (end === -1) return {
2683
- ok: false,
2684
- error: "unclosed {{ in command"
2685
- };
2686
- add(command.slice(index, end + 2));
2687
- index = end + 2;
2688
- } else if (/\s/u.test(char)) {
2689
- if (current !== null) args.push(current);
2690
- current = null;
2691
- index += 1;
2692
- } else if (char === "'") {
2693
- const end = command.indexOf("'", index + 1);
2694
- if (end === -1) return {
2695
- ok: false,
2696
- error: "unclosed ' in command"
2697
- };
2698
- add(command.slice(index + 1, end));
2699
- index = end + 1;
2700
- } else if (char === "\"") {
2701
- let text = "";
2702
- index += 1;
2703
- while (index < command.length && command.charAt(index) !== "\"") {
2704
- if (command.charAt(index) === "\\") index += 1;
2705
- text += command.charAt(index);
2706
- index += 1;
2707
- }
2708
- if (index >= command.length) return {
2709
- ok: false,
2710
- error: "unclosed \" in command"
2711
- };
2712
- add(text);
2713
- index += 1;
2714
- } else if (char === "\\") {
2715
- add(command.charAt(index + 1));
2716
- index += 2;
2717
- } else {
2718
- add(char);
2719
- index += 1;
2720
- }
2721
- }
2722
- if (current !== null) args.push(current);
2723
- return {
2724
- ok: true,
2725
- args
2726
- };
2727
- };
2728
- /** Whether `args` starts with one of the allowed prefixes (`"pr merge"`). */
2729
- const isAllowed = (args, allow) => allow === void 0 || allow.some((prefix) => {
2730
- return prefix.trim().split(/\s+/u).every((word, i) => args[i] === word);
2731
- });
2732
- const render = (text, context) => {
2733
- const template = parseTemplate(text);
2734
- if (!template.ok) return {
2735
- ok: false,
2736
- error: template.message
2737
- };
2738
- return {
2739
- ok: true,
2740
- value: renderTemplate(template.parts, runScope(context.inputs, context.outputs, context.runId))
2741
- };
2742
- };
2743
- /** The program and arguments a tool node runs, before the allow check. */
2744
- const commandOf = (node, context) => {
2745
- const tokens = tokenize(node.command);
2746
- if (!tokens.ok) return tokens;
2747
- const args = [];
2748
- for (const token of tokens.args) {
2749
- const rendered = render(token, context);
2750
- if (!rendered.ok) return rendered;
2751
- args.push(rendered.value);
2752
- }
2753
- const integration = integrationFor(node.type);
2754
- if (integration === void 0) return {
2755
- ok: false,
2756
- error: `no integration for ${node.type}`
2757
- };
2758
- const extra = integration.args(node);
2759
- if (integration.program !== null) return {
2760
- ok: true,
2761
- program: integration.program,
2762
- args: [...args, ...extra]
2763
- };
2764
- const [program, ...rest] = [...args, ...extra];
2765
- if (program === void 0 || program === "") return {
2766
- ok: false,
2767
- error: "the command names no program"
2768
- };
2769
- return {
2770
- ok: true,
2771
- program,
2772
- args: rest
2773
- };
2774
- };
2775
- /** Keeps the first `OUTPUT_LIMIT` characters; the rest goes to a file. */
2776
- const capped = (text, file) => {
2777
- if (text.length <= 65536) return text;
2778
- return `${text.slice(0, OUTPUT_LIMIT)}\n… (truncated; the full output is in ${file()})`;
2779
- };
2780
- const failed$1 = (error) => ({
2781
- ok: false,
2782
- output: {
2783
- exitCode: -1,
2784
- stdout: "",
2785
- stderr: error
2786
- },
2787
- error
2788
- });
2789
- /** Runs a user's CLI through its integration's adapter, with its own login. */
2790
- const toolExecutor = (runner) => async (context) => {
2791
- const { node } = context;
2792
- if (!isToolNode(node)) return failed$1(`${context.id} is not a tool node`);
2793
- const command = commandOf(node, context);
2794
- if (!command.ok) return failed$1(command.error);
2795
- const integration = integrationFor(node.type);
2796
- const checked = integration?.program === null ? [command.program, ...command.args] : command.args;
2797
- const allow = node.allow ?? integration?.allow;
2798
- if (!isAllowed(checked, allow)) return failed$1(`"${checked.join(" ")}" is not in the node's allow list (${(allow ?? []).join(", ")})`);
2799
- let cwd = context.cwd;
2800
- if (node.cwd !== void 0) {
2801
- const rendered = render(node.cwd, context);
2802
- if (!rendered.ok) return failed$1(rendered.error);
2803
- cwd = path.resolve(context.cwd, rendered.value);
2804
- }
2805
- context.emit({
2806
- type: "tool.called",
2807
- id: context.id,
2808
- name: command.program,
2809
- input: { command: [command.program, ...command.args].join(" ") }
2810
- });
2811
- const run = await runner(command.program, command.args, {
2812
- cwd,
2813
- signal: context.signal
2814
- });
2815
- const observed = await integration?.observe?.read(async (args) => {
2816
- const seen = await runner(command.program, args, {
2817
- cwd,
2818
- signal: context.signal
2819
- });
2820
- return {
2821
- ok: seen.exitCode === 0,
2822
- stdout: seen.stdout
2823
- };
2824
- }) ?? {};
2825
- const keep = (field, text) => () => {
2826
- const dir = context.runDir;
2827
- mkdirSync(dir, { recursive: true });
2828
- const file = path.join(dir, `${context.id}.${String(context.round)}.${field}.txt`);
2829
- writeFileSync(file, text);
2830
- return file;
2831
- };
2832
- const output = {
2833
- exitCode: run.exitCode,
2834
- stdout: capped(run.stdout, keep("stdout", run.stdout)),
2835
- stderr: capped(run.stderr, keep("stderr", run.stderr))
2836
- };
2837
- for (const [field, text] of Object.entries(observed)) output[field] = capped(text, keep(field, text));
2838
- context.emit({
2839
- type: "tool.result",
2840
- id: context.id,
2841
- isError: run.exitCode !== 0
2842
- });
2843
- if (run.exitCode === 0) return {
2844
- ok: true,
2845
- output
2846
- };
2847
- const last = run.stderr.trim().split("\n").at(-1) ?? "";
2848
- return {
2849
- ok: false,
2850
- output,
2851
- error: `${command.program} exited with ${String(run.exitCode)}${last === "" ? "" : `: ${last}`}`
2852
- };
2853
- };
2854
- /** The real runner: `spawn` without a shell, output collected as text. */
2855
- const spawnTool = (program, args, { cwd, signal }) => new Promise((resolve) => {
2856
- const stdout = [];
2857
- const stderr = [];
2858
- const child = spawn(program, args, {
2859
- cwd,
2860
- signal,
2861
- stdio: [
2862
- "ignore",
2863
- "pipe",
2864
- "pipe"
2865
- ]
2866
- });
2867
- child.stdout.setEncoding("utf8").on("data", (chunk) => {
2868
- stdout.push(chunk);
2869
- });
2870
- child.stderr.setEncoding("utf8").on("data", (chunk) => {
2871
- stderr.push(chunk);
2872
- });
2873
- child.on("error", (error) => {
2874
- resolve({
2875
- exitCode: -1,
2876
- stdout: stdout.join(""),
2877
- stderr: `could not run ${program}: ${error.message}`
2878
- });
2879
- });
2880
- child.on("close", (code) => {
2881
- resolve({
2882
- exitCode: code ?? -1,
2883
- stdout: stdout.join(""),
2884
- stderr: stderr.join("")
2885
- });
2886
- });
2887
- });
2888
- // ../nodes/src/builtins.ts
2889
- /** Every built-in node type that does work; control nodes are the scheduler's. */
2890
- const builtinExecutors = (options) => {
2891
- const claude = claudeExecutor(options);
2892
- const tool = toolExecutor(options.tools ?? spawnTool);
2893
- const executors = {
2894
- "claude.leader": claude,
2895
- "claude.worker": claude,
2896
- "human.gate": gateExecutor
2897
- };
2898
- for (const integration of INTEGRATIONS) executors[`${TOOL_PREFIX}${integration.id}`] = tool;
2899
- return executors;
2900
- };
2901
- // ../nodes/src/programs.ts
2902
- /**
2903
- * @file Which programs a workflow's tool nodes run, and which of them are not
2904
- * installed. Integrations are optional: `ork run` checks only the programs
2905
- * the workflow uses, before anything starts.
2906
- */
2907
- /**
2908
- * The programs the workflow's tool nodes run: an integration's own (`gh`,
2909
- * `aws`), or a `tool.shell` command's first word when it is not a template.
2910
- */
2911
- const neededPrograms = (workflow) => Object.entries(workflow.nodes).flatMap(([id, node]) => {
2912
- if (!isToolNode(node)) return [];
2913
- const integration = integrationFor(node.type);
2914
- if (integration === void 0) return [];
2915
- if (integration.program !== null) return [{
2916
- node: id,
2917
- type: node.type,
2918
- program: integration.program
2919
- }];
2920
- const tokens = tokenize(node.command);
2921
- const first = tokens.ok ? tokens.args[0] : void 0;
2922
- return first === void 0 || first.includes("{{") ? [] : [{
2923
- node: id,
2924
- type: node.type,
2925
- program: first
2926
- }];
2927
- });
2928
- const runnable = (file) => {
2929
- try {
2930
- accessSync(file, constants.X_OK);
2931
- return true;
2932
- } catch {
2933
- return false;
2934
- }
2935
- };
2936
- /** Whether `program` runs: a path that is executable, or a name on `PATH`. */
2937
- const onPath = (program, env = process.env) => {
2938
- if (program.includes(path.sep)) return runnable(program);
2939
- return (env["PATH"] ?? "").split(path.delimiter).filter((dir) => dir !== "").some((dir) => runnable(path.join(dir, program)));
2940
- };
2941
- /** The needed programs that are not installed, one entry per program. */
2942
- const missingPrograms = (workflow, installed = (program) => onPath(program)) => {
2943
- const missing = /* @__PURE__ */ new Map();
2944
- for (const need of neededPrograms(workflow)) {
2945
- if (installed(need.program)) continue;
2946
- const entry = missing.get(need.program) ?? {
2947
- program: need.program,
2948
- nodes: [],
2949
- type: need.type
2950
- };
2951
- entry.nodes.push(need.node);
2952
- missing.set(need.program, entry);
2953
- }
2954
- return [...missing.values()];
2955
- };
2956
- // src/__build_info__.ts
2957
- /**
2958
- * @file The published package's name and version, bound to
2959
- * `packages/cli/package.json`. Do not edit by hand: `pnpm changesets:version`
2960
- * and `pnpm build` rewrite it (`tools/release/build-info.ts`), and a test
2961
- * fails when it is stale.
2962
- */
2963
- const ORK_NAME = "@krapht/ork";
2964
- const ORK_VERSION = "0.2.0";
2965
- // src/run/origin.ts
2966
- /**
2967
- * @file Where a run's feedback comes from, beyond what its events say: Ork's
2968
- * version (bound from the CLI's `package.json`) and the workflow file's
2969
- * content hash.
2970
- */
2971
- /** Ork's version and the workflow's hash, for the run's feedback records. */
2972
- const feedbackOrigin = (workflowFile) => {
2973
- const origin = { version: ORK_VERSION };
2974
- try {
2975
- origin.workflowHash = createHash("sha256").update(readFileSync(workflowFile)).digest("hex").slice(0, 12);
2976
- } catch {}
2977
- return origin;
2978
- };
2979
- // src/run/checkout.ts
2980
- /**
2981
- * @file Before a run with worktree nodes: the project must be a git repository
2982
- * with a commit, its instructions, skills and agents must be committed
2983
- * (worktrees get only committed files, unless `--allow-dirty`), and the
2984
- * project's `git.base` must name a commit, which root worktrees start from.
2985
- */
2986
- /** Why a run refuses to start with uncommitted agent files. */
2987
- const dirtyAgentMessage = (files) => [
2988
- "These instructions, skills or agents have uncommitted changes, and the worktrees only get committed files:",
2989
- ...files.slice(0, 20).map((f) => ` ${f}`),
2990
- ...files.length > 20 ? [` … and ${String(files.length - 20)} more`] : [],
2991
- "Commit or stash them, or pass --allow-dirty to run with the committed versions."
2992
- ];
2993
- /** Claude nodes that get their own worktree. */
2994
- const worktreeNodes = (workflow) => Object.entries(workflow.nodes).filter(([, node]) => isClaudeNode(node) && node.type !== "claude.subagent" && (node.isolation ?? workflow.defaults?.isolation ?? "worktree") === "worktree").map(([id]) => id);
2995
- /** Checks the project for a run's worktrees, and resolves `git.base`. */
2996
- const prepareCheckout = async (workflow, options, inputs) => {
2997
- const isolated = worktreeNodes(workflow);
2998
- if (isolated.length === 0 || options.inPlace) return {
2999
- ok: true,
3000
- base: ""
3001
- };
3002
- const project = await inspectProject(options.cwd);
3003
- if (project === null) return {
3004
- ok: false,
3005
- lines: [`${isolated.join(", ")} need a git worktree, but ${options.cwd} is not in a git repository with a commit; pass --in-place to run them here`]
3006
- };
3007
- if (project.dirtyAgentFiles.length > 0 && options.allowDirty !== true) return {
3008
- ok: false,
3009
- lines: dirtyAgentMessage(project.dirtyAgentFiles)
3010
- };
3011
- if (project.dirtyOther > 0) options.err(`${style(["yellow", "bold"], "warning")} ${String(project.dirtyOther)} uncommitted change(s) in this checkout are not in the worktrees`);
3012
- try {
3013
- const base = await resolveBase(project.root, gitRulesOf(workflow), inputs);
3014
- return {
3015
- ok: true,
3016
- root: project.root,
3017
- base
3018
- };
3019
- } catch (error) {
3020
- return {
3021
- ok: false,
3022
- lines: [error instanceof Error ? error.message : String(error)]
3023
- };
3024
- }
3025
- };
3026
- // src/run/workflow.ts
3027
- /**
3028
- * @file `ork run` for a whole workflow: checks the workflow, its inputs and the
3029
- * project (git, uncommitted agent files), sets up the worktree workspace, and
3030
- * streams the scheduler's run. `ork resume` comes through here too, with the
3031
- * run's recorded state.
3032
- */
3033
- /** Why a run refuses to start when a program its tool nodes need is missing. */
3034
- const missingProgramMessage = (missing) => [
3035
- "This workflow runs programs that are not installed:",
3036
- ...missing.map((m) => ` ${m.program}, for ${m.nodes.join(", ")} (${m.type})`),
3037
- "Install them, or take those nodes out. Integrations are optional: only the ones a workflow uses must be installed (`ork doctor` shows what you have)."
3038
- ];
3039
- /** A run id no stored run has yet; `node-…` for a run of one node. */
3040
- const newRunId = (store, prefix = "") => {
3041
- for (;;) {
3042
- const id = `${prefix}${randomUUID().slice(0, 8)}`;
3043
- if (store.lookup(id).length === 0) return id;
3044
- }
3045
- };
3046
- /**
3047
- * Runs a whole workflow: checks it and its inputs, then hands the compiled
3048
- * graph to the scheduler with the built-in node types, and streams the run.
3049
- */
3050
- const runWorkflowFile = async (options) => {
3051
- const failed = (lines) => {
3052
- for (const line of lines) options.err(line);
3053
- return {
3054
- exitCode: 1,
3055
- result: null
3056
- };
3057
- };
3058
- const { report, workflow, graph, place, workflowDir } = loadWorkflow(options.file, options.cwd, options.home, options.store.projects());
3059
- if (workflow === null || graph === null) return failed(formatReport(report));
3060
- const pairs = parseInputPairs(options.inputs);
3061
- if (!pairs.ok) return failed([`--input: ${pairs.error}`]);
3062
- const recorded = Object.fromEntries(Object.entries(options.resume?.inputs ?? {}).map(([k, v]) => [k, String(v)]));
3063
- const inputs = resolveInputs(workflow, {
3064
- ...recorded,
3065
- ...pairs.raw
3066
- });
3067
- if (!inputs.ok) return failed(inputs.errors);
3068
- const installed = options.installed ?? (options.tools === void 0 ? (p) => onPath(p) : () => true);
3069
- const missing = missingPrograms(workflow, installed);
3070
- if (missing.length > 0) return failed(missingProgramMessage(missing));
3071
- const checkout = await prepareCheckout(workflow, {
3072
- ...options,
3073
- cwd: place.root
3074
- }, inputs.inputs);
3075
- if (!checkout.ok) return failed(checkout.lines);
3076
- const { root, base } = checkout;
3077
- const cwd = root ?? (inside(place.root, options.cwd) ? options.cwd : place.root);
3078
- const runId = options.runId ?? newRunId(options.store);
3079
- const runDir = runDirOf(place, runId);
3080
- if (options.resume !== void 0) options.store.reopen(runId);
3081
- const record = options.resume === void 0 ? createRecorder(options.store, {
3082
- id: runId,
3083
- workflow: workflow.name,
3084
- file: path.resolve(options.cwd, options.file),
3085
- project: place.root
3086
- }, runDir) : appendRecorder(options.store, runId, runDir);
3087
- const width = Math.max(...Object.keys(workflow.nodes).map((id) => id.length));
701
+ /** Turns stored events into lines, keeping the state a transcript needs. */
702
+ const createLogPrinter = (options) => {
3088
703
  const tools = /* @__PURE__ */ new Map();
3089
- const started = Promise.withResolvers();
3090
- const control = await startControl({
3091
- store: options.store,
3092
- runId,
3093
- emit: (event) => {
3094
- onEvent(event);
3095
- },
3096
- onStop: () => {
3097
- (async () => {
3098
- (await started.promise).interrupt();
3099
- })();
3100
- },
3101
- onMessage: (node, body) => {
3102
- (async () => {
3103
- (await started.promise).tell(node, body);
3104
- })();
3105
- },
3106
- ...options.human === void 0 ? {} : { local: options.human },
3107
- ...options.home === void 0 ? {} : { home: options.home },
3108
- ...options.intervalMs === void 0 ? {} : { intervalMs: options.intervalMs }
3109
- });
3110
- const signals = createSignals(feedbackOrigin(path.resolve(options.cwd, options.file)));
3111
- const onEvent = (raw) => {
3112
- for (const event of signals(raw)) {
3113
- control.publish(record(event));
3114
- if (options.json) options.out(JSON.stringify(event));
3115
- else for (const line of formatRunEvent(event, width, tools)) options.out(line);
3116
- }
3117
- };
3118
- const workspace = root === void 0 ? void 0 : createWorkspace({
3119
- graph,
3120
- runId,
3121
- root,
3122
- head: base,
3123
- inputs: inputs.inputs,
3124
- onEvent,
3125
- ...options.resume === void 0 ? {} : {
3126
- adopt: options.resume.worktrees,
3127
- known: options.resume.branches
3128
- }
3129
- });
3130
- const executorOptions = {
3131
- runtimeFor: options.runtime,
3132
- inPlace: options.inPlace
3133
- };
3134
- if (options.driver !== void 0) executorOptions.driver = options.driver;
3135
- if (options.tools !== void 0) executorOptions.tools = options.tools;
3136
- const replay = options.resume === void 0 ? void 0 : createReplay(options.resume.history, options.resume.cutOff, options.resume.mail);
3137
- if (replay !== void 0) onEvent({
3138
- type: "run.resumed",
3139
- kept: replay.kept()
3140
- });
3141
- const handle = startRun({
3142
- graph,
3143
- runId,
3144
- inputs: inputs.inputs,
3145
- cwd,
3146
- executors: builtinExecutors(executorOptions),
3147
- human: options.resume === void 0 ? control.human : replayLoopAnswers(control.human, options.resume.loopAnswers),
3148
- onEvent,
3149
- settings: {
3150
- inPlace: options.inPlace,
3151
- ...options.driver === void 0 ? {} : { driver: options.driver }
3152
- },
3153
- ...replay === void 0 ? {} : { replay },
3154
- workflowDir,
3155
- projectRoot: root ?? place.root,
3156
- orkDir: place.dir,
3157
- ...workspace === void 0 ? {} : { workspace }
3158
- });
3159
- started.resolve(handle);
3160
- options.onRun?.(handle);
3161
- const result = await handle.done;
3162
- await control.close();
3163
- return {
3164
- exitCode: result.status === "succeeded" ? 0 : 1,
3165
- result
704
+ let width = 8;
705
+ return (stored) => {
706
+ const { event } = stored;
707
+ if (event.type === "run.started") width = Math.max(...event.nodes.map((n) => n.id.length), 1);
708
+ if (options.node !== void 0 && nodeOf(event) !== options.node) return [];
709
+ if (options.json) return [JSON.stringify(stored)];
710
+ return formatRunEvent(event, width, tools);
3166
711
  };
3167
712
  };
713
+ /**
714
+ * Prints a run's events; with `follow`, then each new one as it is stored:
715
+ * pushed over the run's socket, or polled from the store when there is none.
716
+ * Returns when `run.finished` is stored, the run's process is gone, or `signal`
717
+ * aborts.
718
+ */
719
+ const followRun = async (store, run, out, options) => {
720
+ const print = createLogPrinter(options);
721
+ if (await followEvents(store, run.id, (stored) => {
722
+ for (const line of print(stored)) out(line);
723
+ }, {
724
+ follow: options.follow,
725
+ ...options.after === void 0 ? {} : { after: options.after },
726
+ ...options.intervalMs === void 0 ? {} : { intervalMs: options.intervalMs },
727
+ ...options.home === void 0 ? {} : { home: options.home },
728
+ ...options.signal === void 0 ? {} : { signal: options.signal }
729
+ }) === "crashed") out(style("red", `run ${run.id} stopped without finishing (its process is gone)`));
730
+ };
731
+ // src/run/origin.ts
732
+ /**
733
+ * @file Where a run's feedback comes from, beyond what its events say: Ork's
734
+ * version (bound from the CLI's `package.json`) and the workflow file's
735
+ * content hash.
736
+ */
737
+ /** Ork's version and the workflow's hash, for the run's feedback records. */
738
+ const feedbackOrigin = (workflowFile) => {
739
+ const origin = { version: ORK_VERSION };
740
+ try {
741
+ origin.workflowHash = createHash("sha256").update(readFileSync(workflowFile)).digest("hex").slice(0, 12);
742
+ } catch {}
743
+ return origin;
744
+ };
3168
745
  // src/run/node.ts
3169
746
  /**
3170
747
  * @file `ork run --node`: checks a workflow and its inputs, plans one Claude
@@ -3186,15 +763,21 @@ const runNode = async (options) => {
3186
763
  };
3187
764
  const { report, workflow, graph, place, workflowDir } = loadWorkflow(options.file, options.cwd, options.home, options.store.projects());
3188
765
  if (workflow === null) return failed(formatReport(report));
766
+ for (const line of warningLines(report)) options.err(line);
3189
767
  const pairs = parseInputPairs(options.inputs);
3190
768
  if (!pairs.ok) return failed([`--input: ${pairs.error}`]);
3191
769
  const inputs = resolveInputs(workflow, pairs.raw);
3192
770
  if (!inputs.ok) return failed(inputs.errors);
771
+ const limits = runLimits(options.store, place, options.home);
772
+ const refused = limitsRefusal(limits, workflow);
773
+ if (refused.length > 0) return failed(refused);
774
+ const left = limits.now().leftUsd;
3193
775
  const projectRoot = place.root;
3194
776
  const runId = options.runId ?? newRunId(options.store, "node-");
3195
777
  const runDir = runDirOf(place, runId);
3196
778
  const cwd = inside(projectRoot, options.cwd) ? options.cwd : projectRoot;
3197
779
  const plan = planNodeSession(workflow, options.nodeId, {
780
+ ...left === void 0 ? {} : { budgetLeftUsd: left },
3198
781
  cwd,
3199
782
  inputs: inputs.inputs,
3200
783
  inPlace: options.inPlace,
@@ -3204,6 +787,10 @@ const runNode = async (options) => {
3204
787
  pluginDir: path.join(runDir, "plugins", options.nodeId)
3205
788
  });
3206
789
  if (!plan.ok) return failed([plan.error]);
790
+ const planned = workflow.nodes[options.nodeId];
791
+ if (planned !== void 0 && "notify" in planned && planned.notify) options.err(`${style(["yellow", "bold"], "warning")} ${options.nodeId}'s notify: does nothing here: a run of one node has no message bus.`);
792
+ const skipped = planned === void 0 ? [] : [...beforeOf(planned, workflow.defaults) === void 0 ? [] : ["before"], ..."verify" in planned && planned.verify !== void 0 ? ["verify"] : []];
793
+ for (const step of skipped) options.err(`${style(["yellow", "bold"], "warning")} ${options.nodeId}'s ${step}: does not run in a run of one node.`);
3207
794
  for (const warning of plan.warnings) options.err(`${style(["yellow", "bold"], "warning")} ${warning}`);
3208
795
  const id = options.nodeId;
3209
796
  const recorder = createRecorder(options.store, {
@@ -3313,11 +900,362 @@ const runNode = async (options) => {
3313
900
  });
3314
901
  await control.close();
3315
902
  return {
3316
- exitCode: succeeded(outcome) ? 0 : 1,
3317
- outcome
903
+ exitCode: succeeded(outcome) ? 0 : 1,
904
+ outcome
905
+ };
906
+ };
907
+ /** Every value of a repeatable flag: `--input a=1 --input=b=2`. */
908
+ // src/run/calls.ts
909
+ /**
910
+ * @file What a run needs to reach other runs: its port for `workflow.call`
911
+ * nodes (RFC-004), its relay when links are on (RFC-003), and the budget a
912
+ * caller has capped it to. A called run being resumed keeps its caller and
913
+ * its cap, which the run recorded.
914
+ */
915
+ const callPorts = (options) => {
916
+ const { graph, store, runId, home, human, runtime, out } = options;
917
+ const linksOff = graph.workflow.rules?.links === "off";
918
+ const cap = options.budgetCap ?? options.resume?.settings.budgetCap;
919
+ const calledBy = options.calledBy ?? options.resume?.settings.calledBy;
920
+ const baseRef = options.baseRef ?? options.resume?.settings.baseRef;
921
+ const trigger = options.trigger ?? options.resume?.settings.trigger;
922
+ const untrusted = options.untrusted ?? options.resume?.settings.untrusted;
923
+ const own = graph.workflow.limits?.budgetUsd;
924
+ const budgetUsd = cap === void 0 ? own : Math.min(cap, own ?? Number.POSITIVE_INFINITY);
925
+ return {
926
+ graph: {
927
+ ...graph,
928
+ workflow: {
929
+ ...graph.workflow,
930
+ limits: {
931
+ ...graph.workflow.limits,
932
+ ...budgetUsd === void 0 ? {} : { budgetUsd }
933
+ }
934
+ }
935
+ },
936
+ calls: createCalls({
937
+ store,
938
+ runId,
939
+ place: options.place,
940
+ emit: options.emit,
941
+ home,
942
+ adopt: adoptedCalls(store, runId),
943
+ newId: () => newRunId(store),
944
+ env: maskedEnv(maskedNamesOfRun(graph.workflow, options.place, store.projects())),
945
+ resumeSession: (run, node, body, signal) => {
946
+ const { blocked, leftUsd } = runLimits(store, options.place, home).now();
947
+ return blocked === void 0 ? resumeChat({
948
+ store,
949
+ run,
950
+ node,
951
+ body,
952
+ runtime,
953
+ human,
954
+ out,
955
+ signal,
956
+ ...home === void 0 ? {} : { home },
957
+ ...leftUsd === void 0 ? {} : { budgetLeftUsd: leftUsd }
958
+ }) : Promise.resolve(`${NOT_STARTED}${blocked}.`);
959
+ }
960
+ }),
961
+ ...linksOff ? {} : { relay: createRelay({
962
+ store,
963
+ runId,
964
+ home,
965
+ human
966
+ }) },
967
+ settings: {
968
+ ...linksOff ? { links: "off" } : {},
969
+ ...cap === void 0 ? {} : { budgetCap: cap },
970
+ ...calledBy === void 0 ? {} : { calledBy },
971
+ ...baseRef === void 0 ? {} : { baseRef },
972
+ ...trigger === void 0 ? {} : { trigger },
973
+ ...untrusted === void 0 ? {} : { untrusted }
974
+ }
975
+ };
976
+ };
977
+ // src/run/live-handlers.ts
978
+ /**
979
+ * Takes what is on its way before the run ends: with no socket, a program's
980
+ * message may not have been seen yet. Each command waits on `started`, so
981
+ * waiting on it too lets them all land first.
982
+ */
983
+ const takeLastCommands = (control, started) => async () => {
984
+ control.check();
985
+ await started;
986
+ };
987
+ /** `ork pause`, `ork continue` and `ork stop --node`, on the run as it is. */
988
+ const liveControl = (live) => (command, node) => {
989
+ const { handle } = live;
990
+ if (handle === void 0) return false;
991
+ if (command === "pause") return handle.pause(node);
992
+ if (command === "continue") return handle.proceed(node);
993
+ return node !== void 0 && handle.stopNode(node);
994
+ };
995
+ const liveHandlers = (started) => {
996
+ const when = (act) => {
997
+ started.then(act);
998
+ };
999
+ return {
1000
+ onStop: () => {
1001
+ when((run) => {
1002
+ run.interrupt();
1003
+ });
1004
+ },
1005
+ onMessage: (node, body) => {
1006
+ when((run) => {
1007
+ run.tell(node, body);
1008
+ });
1009
+ },
1010
+ onRelay: (node, from, body) => {
1011
+ when((run) => {
1012
+ run.receive(node, from, body);
1013
+ });
1014
+ },
1015
+ onScript: (node, from, body) => {
1016
+ when((run) => {
1017
+ run.receive(node, from, body, "script");
1018
+ });
1019
+ },
1020
+ onNotice: (node, text) => {
1021
+ when((run) => {
1022
+ run.notice(node, text);
1023
+ });
1024
+ }
1025
+ };
1026
+ };
1027
+ // src/run/checkout.ts
1028
+ /**
1029
+ * @file Before a run with worktree nodes: the project must be a git repository
1030
+ * with a commit, its instructions, skills and agents must be committed
1031
+ * (worktrees get only committed files, unless `--allow-dirty`), and the
1032
+ * project's `git.base` must name a commit, which root worktrees start from.
1033
+ */
1034
+ /** Why a run refuses to start with uncommitted agent files. */
1035
+ const dirtyAgentMessage = (files) => [
1036
+ "These instructions, skills or agents have uncommitted changes, and the worktrees only get committed files:",
1037
+ ...files.slice(0, 20).map((f) => ` ${f}`),
1038
+ ...files.length > 20 ? [` … and ${String(files.length - 20)} more`] : [],
1039
+ "Commit or stash them, or pass --allow-dirty to run with the committed versions."
1040
+ ];
1041
+ /** Claude nodes that get their own worktree. */
1042
+ const worktreeNodes = (workflow) => Object.entries(workflow.nodes).filter(([, node]) => isClaudeNode(node) && node.type !== "claude.subagent" && (node.isolation ?? workflow.defaults?.isolation ?? "worktree") === "worktree").map(([id]) => id);
1043
+ /** Checks the project for a run's worktrees, and resolves `git.base`. */
1044
+ const prepareCheckout = async (workflow, options, inputs) => {
1045
+ const isolated = worktreeNodes(workflow);
1046
+ if (isolated.length === 0 || options.inPlace) return {
1047
+ ok: true,
1048
+ base: ""
1049
+ };
1050
+ const project = await inspectProject(options.cwd);
1051
+ if (project === null) return {
1052
+ ok: false,
1053
+ lines: [`${isolated.join(", ")} need a git worktree, but ${options.cwd} is not in a git repository with a commit; pass --in-place to run them here`]
1054
+ };
1055
+ if (project.dirtyAgentFiles.length > 0 && options.allowDirty !== true) return {
1056
+ ok: false,
1057
+ lines: dirtyAgentMessage(project.dirtyAgentFiles)
1058
+ };
1059
+ if (project.dirtyOther > 0) options.err(`${style(["yellow", "bold"], "warning")} ${String(project.dirtyOther)} uncommitted change(s) in this checkout are not in the worktrees`);
1060
+ try {
1061
+ const base = await resolveBase(project.root, gitRulesOf(workflow), inputs);
1062
+ return {
1063
+ ok: true,
1064
+ root: project.root,
1065
+ base
1066
+ };
1067
+ } catch (error) {
1068
+ return {
1069
+ ok: false,
1070
+ lines: [error instanceof Error ? error.message : String(error)]
1071
+ };
1072
+ }
1073
+ };
1074
+ // src/run/preflight.ts
1075
+ const preflight = async (options, given) => {
1076
+ const { workflow, place, projects } = given;
1077
+ const installed = options.installed ?? (options.tools === void 0 ? (p) => onPath(p) : () => true);
1078
+ const missing = missingPrograms(workflow, installed);
1079
+ if (missing.length > 0) return {
1080
+ ok: false,
1081
+ lines: missingProgramMessage(missing)
1082
+ };
1083
+ const unset = envProblems(workflow, place, projects, options.resume?.history, options.err);
1084
+ if (unset.length > 0) return {
1085
+ ok: false,
1086
+ lines: unset
1087
+ };
1088
+ const limits = runLimits(options.store, place, options.home);
1089
+ const refused = limitsRefusal(limits, workflow, options.resume?.history);
1090
+ if (refused.length > 0) return {
1091
+ ok: false,
1092
+ lines: refused
1093
+ };
1094
+ const checkout = await prepareCheckout(workflow, {
1095
+ ...options,
1096
+ cwd: place.root
1097
+ }, given.inputs);
1098
+ if (!checkout.ok) return {
1099
+ ok: false,
1100
+ lines: checkout.lines
1101
+ };
1102
+ return {
1103
+ ...checkout,
1104
+ limits: limits.now
1105
+ };
1106
+ };
1107
+ // src/run/workflow.ts
1108
+ /**
1109
+ * @file `ork run` for a whole workflow: checks the workflow, its inputs and the
1110
+ * project (git, uncommitted agent files), sets up the worktree workspace, and
1111
+ * streams the scheduler's run. `ork resume` comes through here too, with the
1112
+ * run's recorded state.
1113
+ */
1114
+ /**
1115
+ * Runs a whole workflow: checks it and its inputs, then hands the compiled
1116
+ * graph to the scheduler with the built-in node types, and streams the run.
1117
+ */
1118
+ const runWorkflowFile = async (options) => {
1119
+ const failed = (lines) => {
1120
+ for (const line of lines) options.err(line);
1121
+ return {
1122
+ exitCode: 1,
1123
+ result: null
1124
+ };
1125
+ };
1126
+ const loaded = loadWorkflow(options.file, options.cwd, options.home, options.store.projects());
1127
+ const { workflow, graph, place, workflowDir, textHash } = loaded;
1128
+ if (options.expectWorkflow !== void 0 && options.expectWorkflow !== textHash) return failed([`${options.file} isn't what was accepted: it changed after ork watch read it. Accept it again with \`ork watch --accept\`.`]);
1129
+ if (workflow === null || graph === null) return failed(formatReport(loaded.report));
1130
+ const { report } = options.resume === void 0 ? await walkLoaded(loaded) : loaded;
1131
+ for (const line of warningLines(report)) options.err(line);
1132
+ if (report.walk?.complete === false) options.err(walkSummary(report.walk));
1133
+ const pairs = parseInputPairs(options.inputs);
1134
+ if (!pairs.ok) return failed([`--input: ${pairs.error}`]);
1135
+ const recorded = Object.fromEntries(Object.entries(options.resume?.inputs ?? {}).map(([k, v]) => [k, String(v)]));
1136
+ const inputs = resolveInputs(workflow, {
1137
+ ...recorded,
1138
+ ...pairs.raw
1139
+ });
1140
+ if (!inputs.ok) return failed(inputs.errors);
1141
+ const projects = options.store.projects();
1142
+ const ready = await preflight(options, {
1143
+ workflow,
1144
+ place,
1145
+ projects,
1146
+ inputs: inputs.inputs
1147
+ });
1148
+ if (!ready.ok) return failed(ready.lines);
1149
+ const { root, base } = ready;
1150
+ const cwd = root ?? (inside(place.root, options.cwd) ? options.cwd : place.root);
1151
+ const runId = options.runId ?? newRunId(options.store);
1152
+ const runDir = runDirOf(place, runId);
1153
+ if (options.resume !== void 0) options.store.reopen(runId);
1154
+ const record = options.resume === void 0 ? createRecorder(options.store, {
1155
+ id: runId,
1156
+ workflow: workflow.name,
1157
+ file: path.resolve(options.cwd, options.file),
1158
+ project: place.root,
1159
+ ...options.calledBy === void 0 ? {} : { parent: options.calledBy.split("/")[0] ?? "" }
1160
+ }, runDir) : appendRecorder(options.store, runId, runDir);
1161
+ const width = Math.max(...Object.keys(workflow.nodes).map((id) => id.length));
1162
+ const tools = /* @__PURE__ */ new Map();
1163
+ const started = Promise.withResolvers();
1164
+ const live = {};
1165
+ const control = await startControl({
1166
+ store: options.store,
1167
+ runId,
1168
+ emit: (event) => {
1169
+ onEvent(event);
1170
+ },
1171
+ ...liveHandlers(started.promise),
1172
+ onControl: liveControl(live),
1173
+ ...options.human === void 0 ? {} : { local: options.human },
1174
+ ...options.home === void 0 ? {} : { home: options.home },
1175
+ ...options.intervalMs === void 0 ? {} : { intervalMs: options.intervalMs }
1176
+ });
1177
+ const signals = createSignals(feedbackOrigin(path.resolve(options.cwd, options.file)));
1178
+ const onEvent = (raw) => {
1179
+ for (const event of signals(raw)) {
1180
+ control.publish(record(event));
1181
+ if (options.json) options.out(JSON.stringify(event));
1182
+ else for (const line of formatRunEvent(event, width, tools)) options.out(line);
1183
+ }
1184
+ };
1185
+ const workspace = root === void 0 ? void 0 : createWorkspace({
1186
+ graph,
1187
+ runId,
1188
+ root,
1189
+ head: options.baseRef ?? options.resume?.settings.baseRef ?? base,
1190
+ inputs: inputs.inputs,
1191
+ onEvent,
1192
+ ...options.resume === void 0 ? {} : {
1193
+ adopt: options.resume.worktrees,
1194
+ known: options.resume.branches
1195
+ }
1196
+ });
1197
+ const maskedNames = maskedNamesOfRun(workflow, place, projects);
1198
+ const executorOptions = {
1199
+ runtimeFor: options.runtime,
1200
+ inPlace: options.inPlace,
1201
+ maskedNames
1202
+ };
1203
+ if (options.driver !== void 0) executorOptions.driver = options.driver;
1204
+ if (options.tools !== void 0) executorOptions.tools = options.tools;
1205
+ const script = process.argv[1];
1206
+ if (script !== void 0 && onPath(script)) executorOptions.orkBin = script;
1207
+ const replay = options.resume === void 0 ? void 0 : createReplay(options.resume.history, options.resume.cutOff, options.resume.mail, options.resume.earlier);
1208
+ if (replay !== void 0) onEvent({
1209
+ type: "run.resumed",
1210
+ kept: replay.kept()
1211
+ });
1212
+ const ports = callPorts({
1213
+ ...options,
1214
+ graph,
1215
+ runId,
1216
+ place,
1217
+ human: control.human,
1218
+ emit: onEvent
1219
+ });
1220
+ clearOutputFiles(runDir, options.err);
1221
+ const handle = startRun({
1222
+ graph: ports.graph,
1223
+ calls: ports.calls,
1224
+ runId,
1225
+ runDir,
1226
+ inputs: inputs.inputs,
1227
+ cwd,
1228
+ executors: builtinExecutors(executorOptions),
1229
+ human: options.resume === void 0 ? control.human : replayLoopAnswers(control.human, options.resume.loopAnswers),
1230
+ onEvent,
1231
+ settings: {
1232
+ inPlace: options.inPlace,
1233
+ ...options.driver === void 0 ? {} : { driver: options.driver },
1234
+ ...ports.settings
1235
+ },
1236
+ ...ports.relay === void 0 ? {} : { relay: ports.relay },
1237
+ ...replay === void 0 ? {} : { replay },
1238
+ workflowDir,
1239
+ projectRoot: root ?? place.root,
1240
+ orkDir: place.dir,
1241
+ ...workspace === void 0 ? {} : { workspace },
1242
+ scrub: runScrubber(maskedNames, process.env),
1243
+ limits: ready.limits,
1244
+ beforeFinish: takeLastCommands(control, started.promise)
1245
+ });
1246
+ live.handle = handle;
1247
+ started.resolve(handle);
1248
+ options.onRun?.(handle);
1249
+ const result = await handle.done;
1250
+ clearOutputFiles(runDir, options.err);
1251
+ if (result.status === "succeeded") clearStateFiles(runDir, options.err);
1252
+ await endLinksOf(options.store, runId, result.status, options.home);
1253
+ await control.close();
1254
+ return {
1255
+ exitCode: result.status === "succeeded" ? 0 : 1,
1256
+ result
3318
1257
  };
3319
1258
  };
3320
- /** Every value of a repeatable flag: `--input a=1 --input=b=2`. */
3321
1259
  // src/commands/run.ts
3322
1260
  /**
3323
1261
  * @file `ork run`: the command and its flags. It runs a whole workflow, or one
@@ -3335,13 +1273,22 @@ const flagValues = (rawArgs, flag) => {
3335
1273
  };
3336
1274
  const isDriver = (value) => CLAUDE_DRIVERS.some((d) => d.id === value);
3337
1275
  /** Loads the drivers only when a run starts, so other commands stay fast. */
3338
- const loadRuntime = async (driver) => (await import("./src-DAsOYtoR.mjs")).createRuntime(driver, process.env);
3339
- /** Ctrl-C asks the run to stop. */
1276
+ const loadRuntime = async (driver) => (await import("./src-CZOVF2yU.mjs")).createRuntime(driver, sessionEnv(process.env));
1277
+ /**
1278
+ * Ctrl-C asks the run to stop, and so does a terminal closing or a polite kill,
1279
+ * so that the run ends tidily and leaves no result unscrubbed.
1280
+ */
3340
1281
  const onInterrupt = (stop) => {
3341
- process.once("SIGINT", () => {
1282
+ let asked = false;
1283
+ const interrupt = () => {
1284
+ if (asked) return;
1285
+ asked = true;
3342
1286
  writeErr(style("dim", "\ninterrupting…"));
3343
1287
  stop();
3344
- });
1288
+ };
1289
+ process.once("SIGINT", interrupt);
1290
+ process.once("SIGTERM", interrupt);
1291
+ process.once("SIGHUP", interrupt);
3345
1292
  };
3346
1293
  /** The terminal that answers a run's questions; none in a detached run. */
3347
1294
  const terminalHuman = () => process.env["ORK_DETACHED"] === "1" ? void 0 : createTerminalHuman({
@@ -3395,11 +1342,35 @@ const run = defineCommand({
3395
1342
  "run-id": {
3396
1343
  type: "string",
3397
1344
  description: "Internal: the id `--detach` picked for the run"
1345
+ },
1346
+ "called-by": {
1347
+ type: "string",
1348
+ description: "Internal: the workflow.call that started this run"
1349
+ },
1350
+ "budget-cap": {
1351
+ type: "string",
1352
+ description: "Internal: what the caller has left of its budget"
1353
+ },
1354
+ "base-ref": {
1355
+ type: "string",
1356
+ description: "Internal: what this run's worktrees start from"
1357
+ },
1358
+ trigger: {
1359
+ type: "string",
1360
+ description: "Internal: the trigger that started this run"
1361
+ },
1362
+ "expect-workflow": {
1363
+ type: "string",
1364
+ description: "Internal: the hash the workflow's text must have"
1365
+ },
1366
+ untrusted: {
1367
+ type: "string",
1368
+ description: "Internal: an input from what a trigger's check reported"
3398
1369
  }
3399
1370
  },
3400
1371
  run: async ({ args, rawArgs }) => {
3401
1372
  if (args.driver !== void 0 && !isDriver(args.driver)) {
3402
- writeErr(`--driver must be ${CLAUDE_DRIVERS.map((d) => d.id).join(" or ")}, got "${args.driver}"`);
1373
+ writeErr(`--driver must be ${CLAUDE_DRIVERS.map((d) => d.id).join(" or ")}, got \`${args.driver}\``);
3403
1374
  process.exitCode = 1;
3404
1375
  return;
3405
1376
  }
@@ -3454,6 +1425,14 @@ const run = defineCommand({
3454
1425
  const { exitCode } = args.node === void 0 || args.node === "" ? await runWorkflowFile({
3455
1426
  ...common,
3456
1427
  allowDirty: args["allow-dirty"],
1428
+ ...args["called-by"] === void 0 ? {} : { calledBy: args["called-by"] },
1429
+ ...args["budget-cap"] === void 0 ? {} : { budgetCap: Number(args["budget-cap"]) },
1430
+ ...args["base-ref"] === void 0 ? {} : { baseRef: args["base-ref"] },
1431
+ ...args["expect-workflow"] === void 0 ? {} : { expectWorkflow: args["expect-workflow"] },
1432
+ ...args.trigger === void 0 ? {} : {
1433
+ trigger: args.trigger,
1434
+ untrusted: flagValues(rawArgs, "--untrusted")
1435
+ },
3457
1436
  onRun: (handle) => {
3458
1437
  onInterrupt(handle.interrupt);
3459
1438
  }
@@ -3551,6 +1530,12 @@ const chat = defineCommand({
3551
1530
  }
3552
1531
  },
3553
1532
  run: async ({ args }) => {
1533
+ const refusal = chatRefusal();
1534
+ if (refusal !== null) {
1535
+ writeErr(refusal);
1536
+ process.exitCode = 1;
1537
+ return;
1538
+ }
3554
1539
  const store = openStore();
3555
1540
  try {
3556
1541
  const found = findRun(store, args.run, process.cwd());
@@ -3567,7 +1552,7 @@ const chat = defineCommand({
3567
1552
  return;
3568
1553
  }
3569
1554
  if (args.driver !== void 0 && !isDriver(args.driver)) {
3570
- writeErr(`--driver must be sdk or cli, not "${args.driver}".`);
1555
+ writeErr(`--driver must be sdk or cli, not \`${args.driver}\`.`);
3571
1556
  process.exitCode = 1;
3572
1557
  return;
3573
1558
  }
@@ -3606,257 +1591,13 @@ const chat = defineCommand({
3606
1591
  }
3607
1592
  }
3608
1593
  });
3609
- // src/commands/context.ts
3610
- /**
3611
- * @file `ork context explain <wf> <node>`: what one node's session would load,
3612
- * layer by layer. Its project instructions, every skill with the layer it
3613
- * came from and whether it is live, shadowed or switched off, its context
3614
- * files, its practices, and estimated tokens. Plans the session without
3615
- * writing anything.
3616
- */
3617
- /** A rough count, for comparing sizes: about four characters a token. */
3618
- const tokens = (text) => Math.ceil(text.length / 4);
3619
- /** Which project instruction file Claude Code reads from the checkout. */
3620
- const instructionsOf = (stack, checkout) => {
3621
- if (stack.instructions === "ignore") return "left out (project.instructions: ignore)";
3622
- const has = (file) => existsSync(path.join(checkout, file));
3623
- if ([
3624
- "CLAUDE.md",
3625
- ".claude/CLAUDE.md",
3626
- "CLAUDE.local.md"
3627
- ].some((file) => has(file))) return "CLAUDE.md, loaded by Claude Code";
3628
- if (has("AGENTS.md")) return "AGENTS.md, loaded by Claude Code";
3629
- return "none in the project";
3630
- };
3631
- const skillLines = (stack, root) => stack.skills.map((skill) => {
3632
- const source = skill.text === void 0 ? path.relative(root, skill.dir) || "." : "built-in";
3633
- const line = {
3634
- name: skill.layer !== "project" ? `ork:${skill.name}` : skill.name,
3635
- layer: skill.layer,
3636
- source: source.startsWith("..") ? skill.dir : source,
3637
- state: skill.shadowedBy === void 0 && skill.ignored === void 0 ? "live" : "off",
3638
- tokens: tokens(skill.text ?? readFileSync(path.join(skill.dir, "SKILL.md"), "utf8"))
3639
- };
3640
- if (skill.shadowedBy !== void 0) line.why = `shadowed by the ${skill.shadowedBy} layer`;
3641
- else if (skill.ignored !== void 0) line.why = skill.ignored === "ignore" ? "project.skills: ignore" : "not in project.skills.allow";
3642
- return line;
3643
- });
3644
- const explainNode = (file, nodeId, cwd, home) => {
3645
- const { report, workflow, graph, place, workflowDir } = loadWorkflow(file, cwd, home);
3646
- if (workflow === null) return {
3647
- ok: false,
3648
- lines: formatReport(report)
3649
- };
3650
- const { root } = place;
3651
- const plan = planNodeSession(workflow, nodeId, {
3652
- cwd: root,
3653
- inputs: {},
3654
- inPlace: true,
3655
- workflowDir,
3656
- projectRoot: root,
3657
- orkDir: place.dir,
3658
- ...home === void 0 ? {} : { home }
3659
- });
3660
- if (!plan.ok) return {
3661
- ok: false,
3662
- lines: [plan.error]
3663
- };
3664
- const { context } = plan;
3665
- const practices = practiceInstructions(resolvePractices(workflow, nodeId), root);
3666
- const names = ["caveman", "ponytail"].filter((n) => !practices.skipped.includes(n));
3667
- const ref = graph?.agents.get(nodeId);
3668
- const role = ref === void 0 ? void 0 : workflow.agents?.[ref.name]?.instructions;
3669
- return {
3670
- ok: true,
3671
- explanation: {
3672
- node: nodeId,
3673
- type: workflow.nodes[nodeId]?.type ?? "",
3674
- workflow: workflow.name,
3675
- ...ref === void 0 ? {} : { agent: {
3676
- name: ref.name,
3677
- where: ref.where,
3678
- tokens: tokens(role ?? "")
3679
- } },
3680
- instructions: instructionsOf(context, root),
3681
- skills: skillLines(context, root),
3682
- files: context.files.map((f) => ({
3683
- layer: f.layer,
3684
- name: f.name,
3685
- tokens: tokens(f.text)
3686
- })),
3687
- practices: practices.text.map((text, index) => ({
3688
- name: names[index] ?? "practice",
3689
- tokens: tokens(text)
3690
- })),
3691
- skippedPractices: practices.skipped,
3692
- appendedTokens: tokens(plan.spec.systemAppend)
3693
- }
3694
- };
3695
- };
3696
- const approx = (count) => style("dim", `≈${count.toLocaleString("en")} tokens`);
3697
- const formatExplanation = (e) => {
3698
- const width = Math.max(8, ...e.skills.map((s) => s.name.length));
3699
- const sourceWidth = Math.max(0, ...e.skills.map((s) => s.source.length));
3700
- const lines = [
3701
- `${style("bold", e.node)} ${style("dim", `(${e.type}) in ${e.workflow}`)}`,
3702
- "",
3703
- ...e.agent === void 0 ? [] : [`${style("bold", "Agent")} ${e.agent.name} ${style("dim", `(${e.agent.where})`)} ${approx(e.agent.tokens)}`],
3704
- `${style("bold", "Instructions")} ${e.instructions}`,
3705
- "",
3706
- style("bold", "Skills")
3707
- ];
3708
- if (e.skills.length === 0) lines.push(style("dim", " none"));
3709
- for (const s of e.skills) {
3710
- const mark = s.state === "live" ? style("green", "✓") : style("dim", "−");
3711
- const state = s.why === void 0 ? approx(s.tokens).replace("tokens", "tokens when used") : style("yellow", `off: ${s.why}`);
3712
- lines.push(` ${mark} ${s.name.padEnd(width)} ${s.layer.padEnd(8)} ${style("dim", s.source.padEnd(sourceWidth))} ${state}`);
3713
- }
3714
- lines.push("", style("bold", "Context files"));
3715
- if (e.files.length === 0) lines.push(style("dim", " none"));
3716
- for (const f of e.files) lines.push(` ${f.layer.padEnd(8)} ${f.name} ${approx(f.tokens)}`);
3717
- lines.push("", style("bold", "Practices"));
3718
- for (const p of e.practices) lines.push(` ${p.name.padEnd(8)} ${approx(p.tokens)}`);
3719
- for (const name of e.skippedPractices) lines.push(` ${name.padEnd(8)} ${style("dim", "the project's own skill wins")}`);
3720
- if (e.practices.length === 0 && e.skippedPractices.length === 0) lines.push(style("dim", " off"));
3721
- lines.push("", `Appended to Claude Code's system prompt: ${approx(e.appendedTokens)}`);
3722
- return lines;
3723
- };
3724
- const explain = defineCommand({
3725
- meta: {
3726
- name: "explain",
3727
- description: "Show what one node's session loads, layer by layer"
3728
- },
3729
- args: {
3730
- workflow: {
3731
- type: "positional",
3732
- description: "A workflow file, or the name of one in the project's workflows (.ork/workflows/)",
3733
- required: true
3734
- },
3735
- node: {
3736
- type: "positional",
3737
- description: "Node id",
3738
- required: true
3739
- },
3740
- json: {
3741
- type: "boolean",
3742
- description: "Print JSON",
3743
- default: false
3744
- }
3745
- },
3746
- run: ({ args }) => {
3747
- const cwd = process.cwd();
3748
- const result = explainNode(workflowArg(args.workflow, cwd).file, args.node, cwd);
3749
- if (!result.ok) {
3750
- for (const line of result.lines) process.stderr.write(`${line}\n`);
3751
- process.exitCode = 1;
3752
- return;
3753
- }
3754
- if (args.json) write(JSON.stringify(result.explanation, void 0, 2));
3755
- else for (const line of formatExplanation(result.explanation)) write(line);
3756
- }
3757
- });
3758
- const context = defineCommand({
3759
- meta: {
3760
- name: "context",
3761
- description: "Inspect the context Ork compiles for each node"
3762
- },
3763
- subCommands: { explain }
3764
- });
3765
- // src/runs/decide.ts
3766
- /**
3767
- * @file `ork decide`: the open decisions of live runs, from any terminal, and
3768
- * answering them. An answer goes to the store (the first one wins), then the
3769
- * run is woken on its socket so it picks the answer up at once.
3770
- */
3771
- /** Open decisions whose run is still live, oldest first. */
3772
- const pendingDecisions = (store, filter) => {
3773
- const live = /* @__PURE__ */ new Map();
3774
- return store.decisions({
3775
- ...filter,
3776
- status: "open"
3777
- }).filter((decision) => {
3778
- if (!live.has(decision.runId)) {
3779
- const [run] = store.lookup(decision.runId);
3780
- live.set(decision.runId, run !== void 0 && liveStatus(run) === "running");
3781
- }
3782
- return live.get(decision.runId) === true;
3783
- });
3784
- };
3785
- /** Answers an open decision with one of its options, and wakes its run. */
3786
- const answerDecision = async (store, id, choice, home) => {
3787
- const decision = store.decision(id);
3788
- if (decision === void 0) return {
3789
- ok: false,
3790
- error: `No decision "${id}".`
3791
- };
3792
- if (!decision.options.some((o) => o.id === choice)) return {
3793
- ok: false,
3794
- error: `"${choice}" is not an option of ${id}: ${decision.options.map((o) => o.id).join(", ")}`
3795
- };
3796
- if (!store.answerDecision(id, choice, "decide")) {
3797
- const now = store.decision(id);
3798
- return {
3799
- ok: false,
3800
- error: now?.status === "answered" ? `${id} was already answered: ${now.answer ?? ""} (${now.answeredBy ?? ""})` : `${id} was withdrawn`
3801
- };
3802
- }
3803
- return {
3804
- ok: true,
3805
- woke: await wakeRun(socketPath(decision.runId, home))
3806
- };
3807
- };
3808
- // src/runs/resume.ts
3809
- /**
3810
- * @file Whether a stored run can be resumed, and with what: its recorded state,
3811
- * and the flags it was started with unless the user changes them; and `ork
3812
- * resume --detach`, which resumes it in the background.
3813
- */
3814
- /** A run `ork resume` can continue: not running, not done, of a workflow. */
3815
- const resumable = (store, run, flags) => {
3816
- if (liveStatus(run) === "running") return {
3817
- ok: false,
3818
- error: `Run ${run.id} is still running.`
3819
- };
3820
- const events = store.events(run.id, 0).map((stored) => stored.event);
3821
- const state = resumeState(events);
3822
- if (state.succeeded) return {
3823
- ok: false,
3824
- error: `Run ${run.id} succeeded; there is nothing to resume.`
3825
- };
3826
- if (run.id.startsWith("node-")) return {
3827
- ok: false,
3828
- error: `Run ${run.id} ran one node; run it again with ork run --node.`
3829
- };
3830
- const driver = flags.driver ?? state.settings.driver;
3831
- return {
3832
- ok: true,
3833
- state,
3834
- inPlace: flags.inPlace ?? state.settings.inPlace ?? false,
3835
- ...driver === void 0 ? {} : { driver }
3836
- };
3837
- };
3838
- /**
3839
- * `ork resume --detach`: resumes `run` in a background process with the same
3840
- * flags, and returns once it is running again.
3841
- */
3842
- const detachResume = (store, run, flags) => detachRun({
3843
- store,
3844
- runId: run.id,
3845
- args: [
3846
- "resume",
3847
- run.id,
3848
- ...flags
3849
- ],
3850
- cwd: run.project,
3851
- runDir: workflowArg(run.file, run.project).runDir(run.id),
3852
- started: (pid) => store.lookup(run.id)[0]?.pid === pid
3853
- });
3854
1594
  // src/commands/control.ts
3855
1595
  /**
3856
1596
  * @file `ork decide`, `ork stop` and `ork resume`: answer a live run's open
3857
- * decisions and stop a live run from any terminal (both write to the store,
3858
- * then wake the run on its socket; specs/messaging.md), and continue a
3859
- * stopped or crashed run with its recorded state (ADR-026).
1597
+ * decisions and stop a live run, or one of its nodes (ADR-041), from any
1598
+ * terminal (both write to the store, then wake the run on its socket;
1599
+ * specs/messaging.md), and continue a stopped or crashed run with its
1600
+ * recorded state (ADR-026).
3860
1601
  */
3861
1602
  const failed = (message) => {
3862
1603
  writeErr(message);
@@ -3900,6 +1641,11 @@ const decide = defineCommand({
3900
1641
  }
3901
1642
  },
3902
1643
  run: async ({ args }) => {
1644
+ const refusal = args.id !== void 0 || args.choose !== void 0 || process.stdin.isTTY && !args.list && !args.json ? decideRefusal() : null;
1645
+ if (refusal !== null) {
1646
+ failed(refusal);
1647
+ return;
1648
+ }
3903
1649
  const store = openStore();
3904
1650
  const human = createTerminalHuman({
3905
1651
  input: process.stdin,
@@ -3957,13 +1703,19 @@ const decide = defineCommand({
3957
1703
  const stop = defineCommand({
3958
1704
  meta: {
3959
1705
  name: "stop",
3960
- description: "Stop a live run"
1706
+ description: "Stop a live run, or one of its nodes"
1707
+ },
1708
+ args: {
1709
+ run: {
1710
+ type: "positional",
1711
+ description: "A run id or a prefix (default: this project's newest)",
1712
+ required: false
1713
+ },
1714
+ node: {
1715
+ type: "string",
1716
+ description: "Stop only this running node: it fails, and the run goes on"
1717
+ }
3961
1718
  },
3962
- args: { run: {
3963
- type: "positional",
3964
- description: "A run id or a prefix (default: this project's newest)",
3965
- required: false
3966
- } },
3967
1719
  run: async ({ args }) => {
3968
1720
  const store = openStore();
3969
1721
  try {
@@ -3973,20 +1725,22 @@ const stop = defineCommand({
3973
1725
  return;
3974
1726
  }
3975
1727
  const { run } = found;
3976
- const status = liveStatus(run);
3977
- if (status !== "running") {
3978
- failed(`Run ${run.id} is not running (${status}).`);
1728
+ const asked = await requestStop(store, run, void 0, args.node);
1729
+ if (!asked.ok) {
1730
+ failed(asked.error);
1731
+ return;
1732
+ }
1733
+ if (args.node !== void 0) {
1734
+ write(`${style("green", "✓")} ${args.node} in run ${run.id} stops: its session is interrupted and it fails, "stopped by the user"; the run goes on.`);
3979
1735
  return;
3980
1736
  }
3981
- store.command(run.id, { kind: "stop" });
3982
- await wakeRun(socketPath(run.id));
3983
1737
  for (let i = 0; i < 150; i += 1) {
3984
1738
  const [now] = store.lookup(run.id);
3985
1739
  if (now !== void 0 && liveStatus(now) !== "running") {
3986
1740
  write(`${style("green", "✓")} run ${run.id} ${liveStatus(now)}`);
3987
1741
  return;
3988
1742
  }
3989
- await setTimeout$1(100);
1743
+ await setTimeout(100);
3990
1744
  }
3991
1745
  write(`Asked run ${run.id} to stop; it is still finishing its running nodes (see ork logs ${run.id} -f).`);
3992
1746
  } finally {
@@ -4036,7 +1790,7 @@ const resume = defineCommand({
4036
1790
  },
4037
1791
  run: async ({ args, rawArgs }) => {
4038
1792
  if (args.driver !== void 0 && !isDriver(args.driver)) {
4039
- failed(`--driver must be sdk or cli, got "${args.driver}"`);
1793
+ failed(`--driver must be sdk or cli, got \`${args.driver}\``);
4040
1794
  return;
4041
1795
  }
4042
1796
  const store = openStore();
@@ -4275,7 +2029,7 @@ const checkGit = async (run) => {
4275
2029
  return [{
4276
2030
  name: "git",
4277
2031
  status: "ok",
4278
- detail: firstLine$1(version.stdout)
2032
+ detail: firstLine(version.stdout)
4279
2033
  }, inside.ok ? {
4280
2034
  name: "git repo",
4281
2035
  status: "ok",
@@ -4309,6 +2063,134 @@ const runChecks = async (run, env, nodeVersion) => {
4309
2063
  ok: drivers.some((d) => d.status === "ok") && !results.some((r) => r.status === "fail")
4310
2064
  };
4311
2065
  };
2066
+ /**
2067
+ * What it says of triggers (RFC-011): a long-lived process can die quietly, so
2068
+ * a live trigger that hasn't looked when it should have is worth saying. `home`
2069
+ * is the user's `~` (tests pass a folder).
2070
+ */
2071
+ const triggerChecks = (home) => {
2072
+ const store = openStore(storePath(home));
2073
+ try {
2074
+ const report = triggersReport(store, store.projects(), home);
2075
+ const broken = report.broken.map(({ file, message }) => ({
2076
+ name: "triggers",
2077
+ status: "warn",
2078
+ detail: `${file}: ${message}`,
2079
+ hint: "Its triggers are off until it can be read."
2080
+ }));
2081
+ const live = report.triggers.filter(({ standing }) => standing === "live");
2082
+ if (live.length === 0) return broken;
2083
+ const late = lateTriggers(report, /* @__PURE__ */ new Date());
2084
+ if (late.length === 0) return [...broken, {
2085
+ name: "ork watch",
2086
+ status: "ok",
2087
+ detail: `${String(live.length)} trigger(s) looking`
2088
+ }];
2089
+ return [...broken, {
2090
+ name: "ork watch",
2091
+ status: "warn",
2092
+ detail: `not looking: ${late.map(({ trigger }) => trigger).join(", ")}`,
2093
+ hint: report.pid === void 0 ? "Start it with `ork watch --detach`." : "See `ork watch --status`."
2094
+ }];
2095
+ } finally {
2096
+ store.close();
2097
+ }
2098
+ };
2099
+ // src/project/ork-skills.ts
2100
+ /**
2101
+ * @file The Claude Code plugin's skills (`plugins/ork/skills/`) as text, by
2102
+ * name, for `ork init --skills` (RFC-012). Do not edit by hand:
2103
+ * `node tools/release/plugin-skills.ts` and `pnpm build` rewrite it, and a
2104
+ * test fails when it is stale.
2105
+ */
2106
+ const ORK_SKILLS = {
2107
+ principles: "---\nname: principles\ndescription: Use when deciding how to split work into an Ork workflow - which steps are programs, which are Claude sessions, where a person decides - so it costs less than doing it by hand and can't go wrong quietly.\n---\n\n# How Ork splits work\n\nOrk exists to do long jobs for less than doing them by hand, and safely. Four\nrules decide the shape of a workflow.\n\n## 1. A program for what a program can do\n\nA model turn is for judgment. Anything a program can decide, it should: run the\ntests, read a pull request's state, count what changed, wait for CI.\n\n- **A tool node** (`tool.shell`, `tool.git`, `tool.gh`, `tool.aws`) or an\n **`mcp.call`** does it with no session. With `output: { format: json }`, its\n answer routes the run: `when: \"json.state == 'OPEN'\"`.\n- **`verify:`** on a session runs a check before its report counts, so a session\n can't claim success past failing tests. **`before:`** hands it what a program\n found, so it doesn't spend turns finding it.\n- **`watch:`** waits on CI or a review with a program that looks every few\n seconds; hours of waiting cost nothing.\n- **`notify:`** lets a program tell a running session what it found, with no\n model turn to carry the news.\n\n## 2. Spend where it pays\n\n- A small model and low effort for a narrow task (`model: haiku`,\n `effort: low`); the large ones where thinking pays.\n- `budgetUsd` on each session and on the run (`limits.budgetUsd`). A loop of\n sessions without a budget or `limits.timeout` gets a warning, for good reason.\n- Give a session what it needs in its `prompt` and `before:`, rather than\n letting it search; and `isolation: readonly` for a reviewer that only reads.\n\n## 3. Make a mistake impossible before checking for it\n\n- Declare what a node says: `output.schema` lists on tool JSON and Claude\n reports (`verdict: [approve, escalate]`). Conditions then compare against a\n closed list, and a run fails a value off it rather than matching nothing.\n- A verdict is a declared field, never words to search a summary for.\n- Give each condition its opposite, and each failure a handler (`when: \"!ok\"`).\n- Check every draft with Ork's `check` tool until it's clean.\n\n## 4. A person decides what can't be undone\n\nMerging, deploying, publishing, spending a lot: put a `human.gate` before it,\nwith the safe option `recommended`. For a run nobody watches, give it a\n`timeout` and the safe `default`. You never answer a gate yourself.\n\n## An example\n\nA review that a program checks first, and a person signs off:\n\n```yaml\nschemaVersion: 1\nname: careful-review\ndescription: Lint with a program, review with a small model, and ask before merging.\ninputs:\n pr:\n type: string\n required: true\nlimits:\n budgetUsd: 3\nnodes:\n lint:\n type: tool.shell\n command: pnpm lint\n allow: [pnpm lint]\n review:\n type: claude.worker\n prompt: \"Review pull request {{inputs.pr}}. The linter passed.\"\n model: haiku\n effort: low\n isolation: readonly\n budgetUsd: 1\n output:\n schema:\n verdict: [approve, escalate]\n ask:\n type: human.gate\n question: \"Merge pull request {{inputs.pr}}? The review says {{nodes.review.output.verdict}}.\"\n options: [merge, wait]\n recommended: wait\n merge:\n type: tool.gh\n command: pr merge {{inputs.pr}} --squash\n allow: [pr merge]\nedges:\n - lint -> review\n - review -> ask\n - from: ask\n to: merge\n when: \"choice == 'merge'\"\n```\n\n`lint` costs nothing, `review` is a small model with a cap, and nothing merges\nwithout a person.\n",
2108
+ run: "---\nname: run\ndescription: Use when starting, following or explaining an Ork run - what it's doing, why it failed, what it cost, and what waits on the person - and when the person asks about their Ork runs.\n---\n\n# Running and following Ork workflows\n\nOrk runs workflows on the person's machine with their accounts, so a run spends\ntheir money and can push, merge or deploy. Start one only when they ask, and\nnever answer a decision for them.\n\n## Starting\n\n1. Find it: Ork's `workflows` tool gives each workflow's name and inputs.\n2. Check it first with `check` (`workflow: <name>`). Don't start one with an\n error.\n3. Start it with `start_run`, giving every required input. Claude Code asks the\n person first, unless they've allowed it. It answers with the run's id; the run goes on in the\n background, whatever happens to this conversation.\n\nWithout Ork's tools: `ork run <workflow> --detach --input name=value`.\n\n## Following\n\n- `run` gives a run's status, what it waits on, its cost so far, and each\n node's state. `runs` lists the newest runs.\n- `run_events` gives its newest events; `node` narrows them, and `last` or\n `after` widens them. A `node.finished` event with `ok: false` says why a node\n failed.\n- `explain_context` says what a node's session was given: its agent,\n instructions, skills and files. Use it when a node didn't know something.\n- `usage` says what runs spent, by workflow and model, and how close the limits\n are.\n\nSay what you found plainly: which node, what it said, and what it cost. Quote a\nnode's own words rather than retelling them.\n\n## What only the person does\n\n- **Decisions.** `decisions` lists the questions waiting on them: a gate, a loop\n that ran out of rounds, a permission. Tell them what each asks, the options,\n and the recommended one, and that they answer in `ork ui` or with\n `ork decide` in their own terminal. Don't answer one: never run\n `ork decide --id … --choose …`; with Ork's plugin, a hook stops it.\n- **Stopping, pausing, resuming.** Tell them the command: `ork stop <run>`,\n `ork pause <run>`, `ork continue <run>`, `ork resume <run>`. A node can be\n stopped alone with `--node <id>`.\n\n## Without Ork's tools\n\n| Want | Command |\n| ---------------------- | ------------------------------ |\n| Runs, newest first | `ork ps` |\n| A run's transcript | `ork logs <run>` (`--follow`) |\n| Its nodes and cost | `ork tree <run>` |\n| What runs spent | `ork usage` |\n| What waits on a person | `ork decide --list` |\n| A node's context | `ork context explain <wf> <n>` |\n",
2109
+ write: "---\nname: write\ndescription: Use when writing or changing an Ork workflow (a `.ork/workflows/*.ork.yaml` file) - its nodes, edges, conditions and loops - and checking each draft until Ork accepts it.\n---\n\n# Writing an Ork workflow\n\nAn Ork workflow is one YAML file in the project, `.ork/workflows/<name>.ork.yaml`.\nIt names the nodes (Claude sessions, programs, a person's decisions) and the\nedges that carry work between them. Ork runs it on the person's machine with\ntheir own accounts.\n\n## How to work\n\n1. **Look first.** Ork's `workflows` tool lists the project's workflows; read\n one that's close. `agents` lists the agents a node can use. Before you split\n the work, read `/ork:principles`: a program where a program will do, a\n session only for judgment.\n2. **Don't guess a field.** `workflow_format` gives the format; ask it for one\n node type (`nodeType: \"human.gate\"`) to see that type's fields.\n3. **Check every draft** with Ork's `check` tool (`text`, or `workflow` for a\n file), and fix what it says until it has no error. Read its warnings: they\n mean something. Without Ork's tools, run `ork validate <file>`.\n4. **Then save the file**, beginning with the schema line below, so editors can\n check it too.\n\n## The shape of a file\n\n```yaml\n# yaml-language-server: $schema=https://raw.githubusercontent.com/mimshins/ork/main/packages/schema/workflow.schema.json\nschemaVersion: 1\nname: fix-and-ship\ndescription: Fix a failing test, check it with the test suite, and ask before opening a pull request.\ninputs:\n issue:\n type: string\n required: true\n description: What fails.\ndefaults:\n model: sonnet\n budgetUsd: 2\nlimits:\n budgetUsd: 10\nnodes:\n fix:\n type: claude.worker\n prompt: \"Fix this, and keep the change small: {{inputs.issue}}\"\n tests:\n type: tool.shell\n command: pnpm test\n allow: [pnpm test]\n ask:\n type: human.gate\n question: \"Open a pull request for {{nodes.fix.output.branch}}?\"\n options: [open, hold]\n recommended: hold\n pr:\n type: tool.gh\n command: pr create --fill --head {{nodes.fix.output.branch}}\n allow: [pr create]\nedges:\n - fix -> tests\n - from: tests\n to: fix\n when: \"!ok\"\n maxRounds: 2\n - from: tests\n to: ask\n when: ok\n - from: ask\n to: pr\n when: \"choice == 'open'\"\n```\n\nNode ids are lowercase: letters, digits, `-` and `_`. Secrets never go in the\nfile: a tool reads one with `env:` and `{{env.NAME}}`.\n\n## Nodes\n\n| Type | Does |\n| --------------------------------- | ------------------------------------------------------------------- |\n| `claude.worker` | A Claude session that does one task and reports |\n| `claude.leader` | A session that plans and delegates to the nodes it owns (`parent:`) |\n| `claude.subagent` | A subagent inside its parent's session; it takes no edges |\n| `tool.shell` | Any program, run without a shell; `allow:` lists command prefixes |\n| `tool.git`, `tool.gh`, `tool.aws` | The person's own `git`, `gh`, `aws`, with an allow list |\n| `mcp.call` | One tool call on an MCP server, with no session |\n| `human.gate` | A question with options and a recommended one |\n| `control.join` | Waits for its inputs: `all` (default), `any`, or `n` of them |\n| `control.router`, `control.split` | Pass their input on; a router takes its first matching edge |\n| `workflow.call` | Starts another workflow as its own run |\n\n- **A Claude node reports** `status` (`pass`, `changes_requested`, `done` or\n `fail`), `summary`, `artifacts`, `branch`, and any field its `output.schema`\n declares, such as `verdict: [approve, escalate]`. For another verdict, declare\n a field: `status` can't take other values.\n- **A tool node reports** `exitCode`, `stdout`, `stderr`, and with\n `output: { format: json, schema: { state: [OPEN, CLOSED] } }` its JSON as\n `json`. Declare the fields you read: a run then checks them, and a typo can't\n pass.\n- **A gate reports** `choice` and `answeredBy`. A `timeout` needs a `default`.\n\n## Edges and conditions\n\n- `a -> b`, `a -> b, c`, `b, c -> j` are plain edges, taken when the source\n succeeded. A failed node takes no plain edge: handle a failure with\n `when: \"!ok\"`, or `status == 'fail'` on a Claude node.\n- `{ from: a, to: b, when: \"...\" }` is taken when the condition holds. A bare\n name reads the source's output: `when: \"verdict == 'approve'\"`. Quote any\n condition that starts with `!`, or YAML reads it as a tag.\n- Conditions have `==`, `!=`, `<`, `>`, `&&`, `||`, `!`, and `any`, `all`,\n `count`, `len`, `contains`. A join's `statuses` suits `any(statuses, 'fail')`.\n- A node runs when any edge into it is taken; only a join waits. A node with no\n way in taken is skipped, and so is what follows it.\n- Templates, `{{inputs.x}}`, `{{nodes.a.output.field}}`, `{{run.id}}`, go in\n `prompt`, `instructions`, `command`, `cwd` and `question`. Each value stays\n one argument of a command, so it can't inject one.\n\n## Loops\n\nEvery cycle needs an edge with `maxRounds`. Give the way back a condition and\nthe way out its opposite, as `!ok` and `ok` above; a plain edge beside a loop\nfires every round. When a loop runs out, Ork asks a person what to do.\n\n## What `check` refuses, and why\n\n- **A value the source can't have** (`when.value`): `status == 'failed'` (it's\n `fail`), a gate's `choice == 'aprove'` for `approve`, a value outside a\n declared list. The message names the values it can have.\n- **A loop no condition ends**, a plain edge beside a loop, a loop of sessions\n with no `limits.budgetUsd` or `limits.timeout`: warnings, each worth fixing.\n- **`no way the walk tried reaches x`**: Ork tried every way through, and none\n got to `x`. A join waiting on a branch a first-match router never takes is\n the usual cause.\n"
2110
+ };
2111
+ // src/project/init-skills.ts
2112
+ /**
2113
+ * @file Ork's skills for Claude Code in a project (RFC-012): `ork init
2114
+ * --skills` writes the plugin's skills into `.ork/skills/ork-<name>/`, each
2115
+ * naming the Ork that wrote it, and links Claude Code's skills folder there,
2116
+ * so teammates get them through git. It rewrites Ork's own copies, but never
2117
+ * one a newer Ork wrote or a skill someone else wrote; `ork doctor` says when
2118
+ * a copy is older than Ork.
2119
+ */
2120
+ /** A project copy's folder name: `ork-write` for the plugin's `write`. */
2121
+ const SKILL_PREFIX = "ork-";
2122
+ const STAMP = /^<!-- Ork (\S+) wrote this with `ork init --skills`/mu;
2123
+ /** The Ork version a copy names, or `null` for a skill Ork didn't write. */
2124
+ const stampOf = (text) => STAMP.exec(text)?.[1] ?? null;
2125
+ /**
2126
+ * A plugin skill as a project skill: named `ork-<name>`, its references to
2127
+ * `/ork:<name>` as `/ork-<name>`, and the Ork that wrote it after its
2128
+ * frontmatter.
2129
+ */
2130
+ const projectSkill = (name, text, version) => text.replace(/^name: .*$/mu, `name: ${SKILL_PREFIX}${name}`).replace(/^(---\n[\s\S]*?\n---\n)/u, `$1\n<!-- Ork ${version} wrote this with \`ork init --skills\`, which rewrites it. To keep your own version, rename its folder. -->\n`).replaceAll("/ork:", `/${SKILL_PREFIX}`);
2131
+ const fileOf = (root, name) => path.join(root, ORK_LAYOUT.skills, `${SKILL_PREFIX}${name}`, "SKILL.md");
2132
+ const sameFolder = (a, b) => {
2133
+ try {
2134
+ return realpathSync(a) === realpathSync(b);
2135
+ } catch {
2136
+ return false;
2137
+ }
2138
+ };
2139
+ /**
2140
+ * Writes Ork's skills into the project at `root`, and links Claude Code's
2141
+ * folder.
2142
+ */
2143
+ const writeSkills = (root, version, options) => {
2144
+ const result = {
2145
+ created: [],
2146
+ updated: [],
2147
+ skipped: [],
2148
+ newer: [],
2149
+ links: []
2150
+ };
2151
+ for (const [name, text] of Object.entries(ORK_SKILLS)) {
2152
+ const file = fileOf(root, name);
2153
+ const shown = path.relative(root, file);
2154
+ const wanted = projectSkill(name, text, version);
2155
+ const current = existsSync(file) ? readFileSync(file, "utf8") : null;
2156
+ const stamp = current === null ? null : stampOf(current);
2157
+ if (current !== null && (current === wanted || stamp === null)) result.skipped.push(shown);
2158
+ else if (stamp !== null && isNewer(stamp, version)) result.newer.push(`${shown} (Ork ${stamp})`);
2159
+ else {
2160
+ mkdirSync(path.dirname(file), { recursive: true });
2161
+ writeFileSync(file, wanted);
2162
+ (current === null ? result.created : result.updated).push(shown);
2163
+ }
2164
+ }
2165
+ const claude = path.join(root, ".claude", "skills");
2166
+ const ork = path.join(root, ORK_LAYOUT.skills);
2167
+ if (options.link && !sameFolder(claude, ork)) {
2168
+ const linked = linkTools(root, ["claude"]);
2169
+ if (linked.ok) {
2170
+ result.created.push(...linked.created);
2171
+ result.links = linked.links;
2172
+ }
2173
+ }
2174
+ return result;
2175
+ };
2176
+ /** Whether the project has Ork's skills, current or not. */
2177
+ const hasSkills = (root) => Object.keys(ORK_SKILLS).some((name) => existsSync(fileOf(root, name)));
2178
+ /** What `ork doctor` says of the project's copies of Ork's skills. */
2179
+ const skillChecks = (root, version) => {
2180
+ const folder = path.join(root, ORK_LAYOUT.skills);
2181
+ if (!existsSync(folder)) return [];
2182
+ const old = readdirSync(folder).filter((name) => name.startsWith(SKILL_PREFIX)).flatMap((name) => {
2183
+ const file = path.join(folder, name, "SKILL.md");
2184
+ const stamp = existsSync(file) ? stampOf(readFileSync(file, "utf8")) : null;
2185
+ return stamp !== null && isNewer(version, stamp) ? [`${name} (${stamp})`] : [];
2186
+ });
2187
+ return old.length === 0 ? [] : [{
2188
+ name: "skills",
2189
+ status: "warn",
2190
+ detail: `older than Ork ${version}: ${old.join(", ")}`,
2191
+ hint: "Rewrite them with `ork init --skills`."
2192
+ }];
2193
+ };
4312
2194
  // src/project/link.ts
4313
2195
  /**
4314
2196
  * @file What `ork link` and `ork doctor` say about a project's tool folders:
@@ -4365,6 +2247,8 @@ const linkChecks = (projectRoot) => {
4365
2247
  const links = toolLinks(projectRoot, toolsInUse(projectRoot));
4366
2248
  return Object.entries(Object.groupBy(links, (link) => link.tool)).map(([tool, group = []]) => checkOf(tool, group));
4367
2249
  };
2250
+ // src/doctor/project-checks.ts
2251
+ const projectChecks = (root) => [...linkChecks(root), ...skillChecks(root, ORK_VERSION)];
4368
2252
  // src/commands/install.ts
4369
2253
  /**
4370
2254
  * @file This installation of Ork: its name and version for the root command;
@@ -4404,7 +2288,8 @@ const doctor = defineCommand({
4404
2288
  for (const result of [
4405
2289
  orkCheck(),
4406
2290
  ...results,
4407
- ...place.mode === "attached" ? [] : linkChecks(place.root)
2291
+ ...place.mode === "attached" ? [] : projectChecks(place.root),
2292
+ ...triggerChecks()
4408
2293
  ]) for (const line of formatCheck(result)) write(line);
4409
2294
  if (!ok) {
4410
2295
  write(`\n${style("red", "Not ready:")} Ork needs Node, git, and at least one Claude driver.`);
@@ -4741,7 +2626,7 @@ const registerProject = (store, target, options = {}) => {
4741
2626
  if (options.id !== void 0) {
4742
2627
  if (!ID.test(options.id)) return {
4743
2628
  ok: false,
4744
- error: `--id "${options.id}" must be lowercase letters, digits, ".", "_" or "-"`
2629
+ error: `--id \`${options.id}\` must be lowercase letters, digits, ".", "_" or "-"`
4745
2630
  };
4746
2631
  if (taken.includes(options.id)) return {
4747
2632
  ok: false,
@@ -5335,119 +3220,6 @@ const migrate = defineCommand({
5335
3220
  }, (file) => formatReport(validateFile(file, process.cwd())));
5336
3221
  }
5337
3222
  });
5338
- // src/runs/format.ts
5339
- /**
5340
- * @file How stored runs read on the terminal: the `ork ps` table and the `ork
5341
- * tree` ownership tree with each node's state, tokens and cost.
5342
- */
5343
- const STATUS_STYLE = {
5344
- running: "cyan",
5345
- succeeded: "green",
5346
- failed: "red",
5347
- crashed: "red",
5348
- budget_exhausted: "yellow",
5349
- interrupted: "yellow"
5350
- };
5351
- /** `just now`, `5m ago`, `3h ago`, `2d ago`, then the date. */
5352
- const ago = (iso, now = Date.now()) => {
5353
- const minutes = Math.floor((now - Date.parse(iso)) / 6e4);
5354
- if (minutes < 1) return "just now";
5355
- if (minutes < 60) return `${String(minutes)}m ago`;
5356
- if (minutes < 1440) return `${String(Math.floor(minutes / 60))}h ago`;
5357
- if (minutes < 10080) return `${String(Math.floor(minutes / 1440))}d ago`;
5358
- return iso.slice(0, 10);
5359
- };
5360
- /** `42s`, `3m12s`, `1h05m`. */
5361
- const duration = (ms) => {
5362
- const s = Math.max(0, Math.round(ms / 1e3));
5363
- if (s < 60) return `${String(s)}s`;
5364
- if (s < 3600) return `${String(Math.floor(s / 60))}m${String(s % 60).padStart(2, "0")}s`;
5365
- return `${String(Math.floor(s / 3600))}h${String(Math.floor(s % 3600 / 60)).padStart(2, "0")}m`;
5366
- };
5367
- const cost = (usd) => `$${usd.toFixed(4)}`;
5368
- /** The `ork ps` table; `withProject` adds each run's project. */
5369
- const formatRuns = (runs, withProject, now = Date.now()) => {
5370
- if (runs.length === 0) return ["No runs."];
5371
- const rows = runs.map((run) => {
5372
- const status = liveStatus(run);
5373
- const end = run.finishedAt === void 0 ? now : Date.parse(run.finishedAt);
5374
- return {
5375
- id: run.id,
5376
- workflow: run.workflow,
5377
- status,
5378
- when: `${ago(run.startedAt, now)} · ${duration(end - Date.parse(run.startedAt))}`,
5379
- cost: cost(run.costUsd),
5380
- project: run.project
5381
- };
5382
- });
5383
- const width = (pick, title) => Math.max(title.length, ...rows.map((r) => pick(r).length));
5384
- const w = {
5385
- id: width((r) => r.id, "RUN"),
5386
- workflow: width((r) => r.workflow, "WORKFLOW"),
5387
- status: width((r) => r.status, "STATUS"),
5388
- when: width((r) => r.when, "STARTED"),
5389
- cost: width((r) => r.cost, "COST")
5390
- };
5391
- const header = [
5392
- "RUN".padEnd(w.id),
5393
- "WORKFLOW".padEnd(w.workflow),
5394
- "STATUS".padEnd(w.status),
5395
- "STARTED".padEnd(w.when),
5396
- "COST".padStart(w.cost),
5397
- ...withProject ? ["PROJECT"] : []
5398
- ].join(" ");
5399
- return [style("dim", header), ...rows.map((r) => [
5400
- style("bold", r.id.padEnd(w.id)),
5401
- r.workflow.padEnd(w.workflow),
5402
- style(STATUS_STYLE[r.status] ?? "reset", r.status.padEnd(w.status)),
5403
- style("dim", r.when.padEnd(w.when)),
5404
- r.cost.padStart(w.cost),
5405
- ...withProject ? [style("dim", r.project)] : []
5406
- ].join(" "))];
5407
- };
5408
- const STATE_MARK = {
5409
- waiting: style("dim", "·"),
5410
- running: style("cyan", "●"),
5411
- ok: style("green", "✓"),
5412
- failed: style("red", "✗"),
5413
- skipped: style("dim", "-")
5414
- };
5415
- /** `950`, `12.3k`, `1.2M`. */
5416
- const count = (n) => {
5417
- if (n < 1e3) return String(n);
5418
- if (n < 1e6) return `${(n / 1e3).toFixed(1)}k`;
5419
- return `${(n / 1e6).toFixed(1)}M`;
5420
- };
5421
- const stateText = (node) => {
5422
- if (node.state === "running") return node.runs > 1 ? `running (round ${String(node.runs)})` : "running";
5423
- if (node.state === "ok" || node.state === "failed") {
5424
- const verdict = nodeVerdict(node.output ?? {});
5425
- const line = verdict.length > 60 ? `${verdict.slice(0, 59)}…` : verdict;
5426
- return node.runs > 1 ? `${line} (${String(node.runs)} runs)` : line;
5427
- }
5428
- return node.state;
5429
- };
5430
- /** The ownership tree, one line per node, children under their owner. */
5431
- const formatTree = (roots) => {
5432
- const lines = [];
5433
- const walk = (node, lead, branch) => {
5434
- lines.push({
5435
- label: `${lead}${branch}${node.id}`,
5436
- node
5437
- });
5438
- const next = lead + (branch === "├─ " ? "│ " : branch === "" ? "" : " ");
5439
- node.children.forEach((child, index) => {
5440
- walk(child, next, index === node.children.length - 1 ? "└─ " : "├─ ");
5441
- });
5442
- };
5443
- for (const root of roots) walk(root, "", "");
5444
- const label = Math.max(...lines.map((l) => l.label.length));
5445
- const type = Math.max(...lines.map((l) => l.node.type.length));
5446
- return lines.map(({ label: text, node }) => {
5447
- const usage = node.inputTokens + node.outputTokens === 0 ? "" : style("dim", ` ${count(node.inputTokens)} in / ${count(node.outputTokens)} out · ${cost(node.costUsd)}`);
5448
- return `${text.padEnd(label)} ${style("dim", node.type.padEnd(type))} ${STATE_MARK[node.state]} ${stateText(node)}${usage}`;
5449
- });
5450
- };
5451
3223
  // src/runs/feedback.ts
5452
3224
  /**
5453
3225
  * @file `ork feedback`'s helpers (specs/feedback.md): finding an item by a
@@ -5599,8 +3371,9 @@ const ps = defineCommand({
5599
3371
  limit: Number.isInteger(limit) && limit > 0 ? limit : 20,
5600
3372
  ...args.all ? {} : { project: placeFor(process.cwd(), store.projects()).root }
5601
3373
  });
3374
+ const statusOf = (run) => runStatus(run, run.status === "running" ? store.events(run.id).map((s) => s.event) : []);
5602
3375
  if (args.json) write(JSON.stringify(runs, void 0, 2));
5603
- else for (const line of formatRuns(runs, args.all)) write(line);
3376
+ else for (const line of formatRuns(runs, args.all, Date.now(), statusOf)) write(line);
5604
3377
  })
5605
3378
  });
5606
3379
  const runArg = {
@@ -5671,12 +3444,13 @@ const tree = defineCommand({
5671
3444
  return;
5672
3445
  }
5673
3446
  const { run } = found;
5674
- const roots = runTree(store.events(run.id).map((s) => s.event));
3447
+ const events = store.events(run.id).map((s) => s.event);
3448
+ const roots = runTree(events);
5675
3449
  if (args.json) {
5676
3450
  write(JSON.stringify(roots, void 0, 2));
5677
3451
  return;
5678
3452
  }
5679
- write(`${style("bold", `run ${run.id}`)} ${style("dim", `of ${run.workflow} · ${liveStatus(run)} · ${cost(run.costUsd)}`)}`);
3453
+ write(`${style("bold", `run ${run.id}`)} ${style("dim", `of ${run.workflow} · ${runStatus(run, events)} · ${cost(run.costUsd)}`)}`);
5680
3454
  for (const line of formatTree(roots)) write(line);
5681
3455
  })
5682
3456
  });
@@ -5868,6 +3642,35 @@ const addAgent = (options) => {
5868
3642
  if (options.model !== void 0) agent.model = options.model;
5869
3643
  return createAgent(options.name, agent, options);
5870
3644
  };
3645
+ // src/project/skills-step.ts
3646
+ /**
3647
+ * @file The skills step of `ork init` (RFC-012): with `--skills` it writes
3648
+ * Ork's skills for Claude Code into the project, with `--no-skills` it
3649
+ * doesn't, and otherwise it asks on a terminal, once, saying the plugin has
3650
+ * them too, or says how to add them later.
3651
+ */
3652
+ const QUESTION = "Add Ork's skills for Claude Code to .ork/skills/, so it writes workflows Ork accepts? Ork's Claude Code plugin has them too. [y/N] ";
3653
+ const HINT = "Ork's skills for Claude Code: add them here with `ork init --skills`, or install Ork's plugin in Claude Code (/plugin marketplace add mimshins/ork, then /plugin install ork@ork).";
3654
+ /** Whether to write the skills: as asked, or the person's answer. */
3655
+ const wanted = (root, asked) => {
3656
+ if (asked !== void 0) return Promise.resolve(asked);
3657
+ if (hasSkills(root)) return Promise.resolve(false);
3658
+ return process.stdin.isTTY && process.stdout.isTTY ? confirm(QUESTION) : Promise.resolve(null);
3659
+ };
3660
+ const skillsStep = async (root, options) => {
3661
+ const choice = await wanted(root, options.skills);
3662
+ if (choice === null) {
3663
+ write(style("dim", HINT));
3664
+ return;
3665
+ }
3666
+ if (!choice) return;
3667
+ const result = writeSkills(root, ORK_VERSION, { link: options.link });
3668
+ for (const item of result.created) write(`${style("green", "created")} ${item}`);
3669
+ for (const item of result.updated) write(`${style("green", "updated")} ${item}`);
3670
+ for (const item of result.skipped) write(`${style("dim", "exists")} ${item}`);
3671
+ for (const item of result.newer) write(`${style("yellow", "kept")} ${item}, from a newer Ork than ${ORK_VERSION}`);
3672
+ for (const line of formatLinks(result.links.filter((l) => l.state !== "linked"))) write(line);
3673
+ };
5871
3674
  // src/commands/setup.ts
5872
3675
  /**
5873
3676
  * @file Setting up: `ork init` scaffolds `.ork/` in a project, records it, and
@@ -5881,12 +3684,18 @@ const init = defineCommand({
5881
3684
  name: "init",
5882
3685
  description: "Set up .ork/ in this project with a starter workflow, and link the tool folders it uses"
5883
3686
  },
5884
- args: { link: {
5885
- type: "boolean",
5886
- description: "Link .claude/agents and the like to .ork/ (--no-link: don't)",
5887
- default: true
5888
- } },
5889
- run: ({ args }) => {
3687
+ args: {
3688
+ link: {
3689
+ type: "boolean",
3690
+ description: "Link .claude/agents and the like to .ork/ (--no-link: don't)",
3691
+ default: true
3692
+ },
3693
+ skills: {
3694
+ type: "boolean",
3695
+ description: "Write Ork's skills for Claude Code into .ork/skills/ (--no-skills: don't; asked otherwise)"
3696
+ }
3697
+ },
3698
+ run: async ({ args }) => {
5890
3699
  const store = openStore();
5891
3700
  let result;
5892
3701
  try {
@@ -5903,6 +3712,10 @@ const init = defineCommand({
5903
3712
  for (const item of created) write(`${style("green", "created")} ${item}`);
5904
3713
  for (const item of skipped) write(`${style("dim", "exists")} ${item}`);
5905
3714
  for (const line of formatLinks(links.filter((l) => l.state !== "linked"))) write(line);
3715
+ await skillsStep(process.cwd(), {
3716
+ skills: args.skills,
3717
+ link: args.link
3718
+ });
5906
3719
  write(`\nNext: edit ${WORKFLOW_DIR}/, then run ${style("bold", "ork validate")}.`);
5907
3720
  }
5908
3721
  });
@@ -6091,14 +3904,23 @@ const main = defineCommand({
6091
3904
  ps,
6092
3905
  logs,
6093
3906
  tree,
3907
+ usage: () => import("./usage-BIjf9Xdt.mjs").then((module) => module.usage),
3908
+ prune: () => import("./usage-BIjf9Xdt.mjs").then((module) => module.prune),
6094
3909
  decide,
3910
+ relay: () => import("./relay-CbK-Wme9.mjs").then((module) => module.relay),
3911
+ pause: () => import("./pause-DKACDfV1.mjs").then((module) => module.pause),
3912
+ continue: () => import("./pause-DKACDfV1.mjs").then((module) => module.continueRun),
6095
3913
  stop,
6096
3914
  chat,
3915
+ send: () => import("./send-C6yr04Fe.mjs").then((module) => module.send),
6097
3916
  resume,
6098
3917
  feedback,
6099
3918
  context,
6100
3919
  worktrees,
6101
3920
  wt: worktrees,
3921
+ watch: () => import("./watch-B8o4Szd_.mjs").then((module) => module.watch),
3922
+ mcp: () => import("./mcp-BWsdQyri.mjs").then((module) => module.mcp),
3923
+ ui: () => import("./ui-CZS1XKla.mjs").then((module) => module.ui),
6102
3924
  doctor,
6103
3925
  upgrade
6104
3926
  }