@krapht/ork 0.3.0 → 1.0.0-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/README.md +16 -12
  2. package/dist/__build_info__-DdTVd2p5.mjs +615 -0
  3. package/dist/bin.mjs +1572 -909
  4. package/dist/clean-NnboOuz6.mjs +88 -0
  5. package/dist/completion-snnYGjgK.mjs +405 -0
  6. package/dist/{context-BFGC_C02.mjs → context-DO3sBy9v.mjs} +30 -191
  7. package/dist/decide-B81wCZQg.mjs +113 -0
  8. package/dist/{find-BcvhT6UN.mjs → find-BKW-uGey.mjs} +1 -1
  9. package/dist/{format-DqpObyMl.mjs → format-CmTlTOjT.mjs} +72 -198
  10. package/dist/help-groups-BwYoAWsh.mjs +354 -0
  11. package/dist/{in-a-run-BbLhZOFW.mjs → in-a-run-BPXX09Bs.mjs} +1 -1
  12. package/dist/{limits-BKYAmqCR.mjs → limits-JCve2QpI.mjs} +1 -1
  13. package/dist/list-C7UEgmtd.mjs +110 -0
  14. package/dist/{mcp-BWsdQyri.mjs → mcp-CKPyNaKD.mjs} +7 -7
  15. package/dist/{output-Q2EdeVUt.mjs → output-B1V6ZJSx.mjs} +130 -36
  16. package/dist/{pause-DKACDfV1.mjs → pause-B_SL_yDz.mjs} +4 -4
  17. package/dist/{relay-CbK-Wme9.mjs → relay-o-Luee1c.mjs} +2 -2
  18. package/dist/remove-DAZ7ZCzt.mjs +194 -0
  19. package/dist/{send-C6yr04Fe.mjs → send-3B64fBNB.mjs} +3 -3
  20. package/dist/{src-D5ruZFgD.mjs → src-BJTegEzY.mjs} +1212 -57
  21. package/dist/{src-DtN1k-B3.mjs → src-C38BPzit.mjs} +251 -18
  22. package/dist/{src-Cz1yOUTg.mjs → src-DHvWU4tX.mjs} +2742 -1659
  23. package/dist/{src-CZOVF2yU.mjs → src-Dyz_Gy1q.mjs} +35 -8
  24. package/dist/ui/THIRD_PARTY_LICENSES.txt +2 -2
  25. package/dist/ui/assets/app-C8nJ6NpT.js +67 -0
  26. package/dist/ui/assets/app-CH7YC3in.css +1 -0
  27. package/dist/ui/assets/{index-BiO54L90.css → index-BPIbqRRb.css} +1 -1
  28. package/dist/ui/assets/{index-BoG6O-Lp.js → index-D83F2OKZ.js} +2 -2
  29. package/dist/ui/index.html +2 -3
  30. package/dist/ui-CY4xxJUu.mjs +472 -0
  31. package/dist/{usage-BIjf9Xdt.mjs → usage-CJnytoe4.mjs} +6 -5
  32. package/dist/{watch-B8o4Szd_.mjs → watch-BJB82ZJ7.mjs} +7 -7
  33. package/dist/workflows-CiM7S3l0.mjs +88 -0
  34. package/dist/worktrees-86ySXxR6.mjs +212 -0
  35. package/package.json +6 -6
  36. package/dist/__build_info__-CXXMLvjt.mjs +0 -104
  37. package/dist/ui/assets/app-BlDdP396.js +0 -61
  38. package/dist/ui/assets/app-C722xNZ3.css +0 -1
  39. package/dist/ui-CZS1XKla.mjs +0 -160
package/dist/bin.mjs CHANGED
@@ -1,23 +1,58 @@
1
1
  #!/usr/bin/env node
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";
11
- import { defineCommand, runMain } from "citty";
12
- import { createInterface } from "node:readline/promises";
2
+ import { Bn as serializeWorkflow, Bt as FEEDBACK_TARGETS, Cn as runDirOf, D as inspectProject, Dn as projectRootOf, Dt as userMessageText, En as inside, Ft as PROJECT_FILE, H as tellNode, Ht as planPost, I as createCalls, It as gitRulesOf, J as tellLiveRun, Jn as beforeOf, Jt as normalizeTitle, Kn as settingsOf, Kt as createFeedback, L as addressOf, Lt as loadProject, M as clearGitLocalEnv, N as parseInputPairs, P as resolveInputs, R as endLink, Rt as FEEDBACK_KINDS, Sn as projectsHome, Tt as startRun, Un as WorkflowSchema, V as peerOf, W as flattenTree, Wt as sendPost, Xt as maskReplacer, Yn as isClaudeNode, _ as lateTriggers, _n as hasInRepoFiles, _t as runStatus, an as wakeRun, ar as CLAUDE_DRIVERS, bn as placeFiles, cn as planMoveInto, d as runningRunIds, dn as toolsInUse, dt as namingNoDriver, er as INTEGRATIONS, et as adoptedCalls, f as runningScript, fn as createAgent, ft as sessionDriver, h as isNewer, hn as loadAgents, ht as liveStatus, i as NOT_STARTED, in as socketRoom, ir as ORK_LAYOUT, it as replayLoopAnswers, l as planUpgrade, ln as linkTools, mn as catalogAgent, n as resolveBase, nn as serveRun, nt as resumable, on as planAgentMigration, or as firstLine, ot as detachRun, pn as deleteAgent, q as requestStop, qt as fingerprint, rn as socketPath, rr as AGENT_TOOL_LAYOUTS, rt as createReplay, sn as applySteps, st as followEvents, t as createWorkspace, tt as detachResume, u as releaseNotes, un as toolLinks, ut as planNodeSession, v as triggersReport, vn as inRepoPlace, vt as runTree, wn as shownPath, wt as walkSummary, xn as placeFor, yn as newProjectId, z as endLinksOf, zt as FEEDBACK_SEVERITIES } from "./src-DHvWU4tX.mjs";
3
+ import { C as feedbackActions, S as placeOf, _ as previewPost, b as acceptItem, c as checkDue, d as pendingNotice, f as readUpdateCache, g as nodeSession, h as writeUpdateCache, i as registryOf, l as fetchLatest, m as updateCachePath, n as ORK_VERSION, o as updateSwitchOff, p as refreshUpdateCache, r as isSwitchedOn, s as runInstall, t as ORK_NAME, u as newerVersion$1, v as checkNote, w as openFixRun, y as recordNote } from "./__build_info__-DdTVd2p5.mjs";
4
+ import { i as builtinWorkflowFile, n as pendingDecisions, r as builtinProblem, t as answerDecision } from "./decide-B81wCZQg.mjs";
5
+ import { n as newRunId, t as findRun } from "./find-BKW-uGey.mjs";
6
+ 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-B1V6ZJSx.mjs";
7
+ import { n as listOrkWorktrees, t as createdWorktrees } from "./list-C7UEgmtd.mjs";
8
+ import { a as planClean, t as applyClean } from "./clean-NnboOuz6.mjs";
9
+ import { c as subcommandsOf, i as argsOf, n as groupedEntries, o as publicArgs, s as resolved, t as groupOf } from "./help-groups-BwYoAWsh.mjs";
10
+ import { a as formatRunEvent, i as formatEvent, n as formatRuns, o as createTerminalHuman, r as formatTree, t as ago } from "./format-CmTlTOjT.mjs";
11
+ import { c as registeredProjects, i as refusedBuiltin, n as WORKFLOW_SUFFIX, o as workflowArg, s as projectAt, t as WORKFLOW_DIR } from "./workflows-CiM7S3l0.mjs";
12
+ import { a as validate, c as warningLines, i as loadWorkflow, o as validateFile, r as formatReport, s as walkLoaded, t as context } from "./context-DO3sBy9v.mjs";
13
+ import { i as runLimits, r as limitsRefusal, t as chatNotice } from "./limits-JCve2QpI.mjs";
14
+ 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-C38BPzit.mjs";
15
+ import { i as decideRefusal, n as chatRefusal } from "./in-a-run-BPXX09Bs.mjs";
13
16
  import path from "node:path";
14
17
  import { stringify } from "yaml";
15
- import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, statSync, writeFileSync } from "node:fs";
18
+ import { z } from "zod";
19
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, statSync, writeFileSync } from "node:fs";
16
20
  import { createHash } from "node:crypto";
17
21
  import { homedir } from "node:os";
18
- import { setTimeout } from "node:timers/promises";
19
22
  import { execFile, spawn } from "node:child_process";
23
+ import { setTimeout } from "node:timers/promises";
24
+ import { defineCommand, renderUsage, runMain } from "citty";
25
+ import { createInterface } from "node:readline/promises";
20
26
  import { styleText } from "node:util";
27
+ // ../integrations/src/ready.ts
28
+ /**
29
+ * @file Which Claude driver is ready on this machine: each driver's own check
30
+ * (what `ork doctor` shows), and the one a run takes for the nodes that name
31
+ * no driver, so a committed workflow never carries one person's login.
32
+ */
33
+ /** Each registered Claude driver's own check, in registry order. */
34
+ const driverChecks = (run, env) => Promise.all(CLAUDE_DRIVERS.map(async (driver) => ({
35
+ id: driver.id,
36
+ result: await driver.doctor(run, env)
37
+ })));
38
+ /**
39
+ * The driver ready here: the one whose check passes; with more than one,
40
+ * {@link PREFERRED_DRIVER}; with none, each driver's hint.
41
+ */
42
+ const readyDriver = (checks) => {
43
+ const ready = checks.filter(({ result }) => result.status === "ok");
44
+ const pick = ready.find(({ id }) => id === "cli") ?? ready.at(0);
45
+ if (pick === void 0) return {
46
+ ok: false,
47
+ hints: checks.flatMap(({ result }) => result.hint === void 0 ? [] : [result.hint])
48
+ };
49
+ return {
50
+ ok: true,
51
+ driver: pick.id,
52
+ detail: pick.result.detail,
53
+ ready: ready.length
54
+ };
55
+ };
21
56
  // ../core/src/control/queue.ts
22
57
  const PERMISSION_OPTIONS = [{
23
58
  id: "allow",
@@ -27,6 +62,8 @@ const PERMISSION_OPTIONS = [{
27
62
  label: "Deny"
28
63
  }];
29
64
  const DENIED = "The user did not allow this tool.";
65
+ /** The deny message a session gets, with the person's words when given. */
66
+ const deniedMessage = (note) => note === void 0 ? DENIED : `${DENIED} They said: ${note}`;
30
67
  /** At most `max` characters of a tool's input, for the decision's context. */
31
68
  const inputPreview = (input, max = 400) => {
32
69
  const json = JSON.stringify(input);
@@ -53,22 +90,30 @@ const createDecisionQueue = (options) => {
53
90
  options: record.options
54
91
  };
55
92
  if (record.recommended !== void 0) event.recommended = record.recommended;
93
+ if (record.owner !== void 0) event.owner = record.owner;
56
94
  emit(event);
57
95
  const { promise, resolve, reject } = Promise.withResolvers();
58
96
  const settled = new AbortController();
59
- const finish = (answer, by) => {
97
+ /** `note` is the stored one: masked, and absent when blank. */
98
+ const finish = (answer, by, note) => {
60
99
  if (settled.signal.aborted) return;
61
100
  settled.abort();
62
- emit({
101
+ const answered = {
63
102
  type: "decision.answered",
64
103
  node: open.node,
65
104
  decision: id,
66
105
  answer,
67
106
  by
68
- });
69
- resolve({
107
+ };
108
+ if (note !== void 0) answered.note = note;
109
+ emit(answered);
110
+ resolve(note === void 0 ? {
70
111
  answer,
71
112
  by
113
+ } : {
114
+ answer,
115
+ by,
116
+ note
72
117
  });
73
118
  };
74
119
  const withdraw = () => {
@@ -87,13 +132,13 @@ const createDecisionQueue = (options) => {
87
132
  const fromStore = async () => {
88
133
  try {
89
134
  const closed = await options.waitFor(id, settled.signal);
90
- if (closed.status === "answered" && closed.answer !== void 0) finish(closed.answer, closed.answeredBy ?? "decide");
135
+ if (closed.status === "answered" && closed.answer !== void 0) finish(closed.answer, closed.answeredBy ?? "decide", closed.note);
91
136
  } catch {}
92
137
  };
93
138
  const fromTerminal = async (ask) => {
94
139
  try {
95
- const answer = await ask(settled.signal);
96
- if (store.answerDecision(id, answer, "terminal")) finish(answer, "terminal");
140
+ const { answer, note } = await ask(settled.signal);
141
+ if (store.answerDecision(id, answer, "terminal", note)) finish(answer, "terminal", store.decision(id)?.note);
97
142
  } catch {}
98
143
  };
99
144
  fromStore();
@@ -110,31 +155,92 @@ const createDecisionQueue = (options) => {
110
155
  };
111
156
  if (question.recommended !== void 0) open.recommended = question.recommended;
112
157
  if (question.context !== void 0) open.context = question.context;
158
+ if (question.owner !== void 0) open.owner = question.owner;
113
159
  const { local } = options;
114
- return decide(open, local === void 0 ? void 0 : (signal) => local.ask({
115
- ...question,
116
- signal
117
- }), question.signal).then(({ answer }) => answer);
160
+ return decide(open, local === void 0 ? void 0 : (signal) => {
161
+ let note;
162
+ return local.ask({
163
+ ...question,
164
+ signal,
165
+ onNote: (given) => {
166
+ note = given;
167
+ }
168
+ }).then((answer) => note === void 0 ? { answer } : {
169
+ answer,
170
+ note
171
+ });
172
+ }, question.signal).then(({ answer, note }) => {
173
+ if (note !== void 0) question.onNote?.(note);
174
+ return answer;
175
+ });
176
+ },
177
+ settled: (question) => {
178
+ count += 1;
179
+ const id = `${runId}-${String(count)}`;
180
+ const { owner } = question;
181
+ const record = store.openDecision({
182
+ id,
183
+ runId,
184
+ node: question.nodeId,
185
+ kind: "question",
186
+ question: question.question,
187
+ options: question.options,
188
+ owner,
189
+ ...question.recommended === void 0 ? {} : { recommended: question.recommended },
190
+ ...question.context === void 0 ? {} : { context: question.context }
191
+ });
192
+ emit({
193
+ type: "decision.opened",
194
+ node: record.node,
195
+ decision: id,
196
+ kind: "question",
197
+ question: record.question,
198
+ options: record.options,
199
+ owner,
200
+ ...record.recommended === void 0 ? {} : { recommended: record.recommended }
201
+ });
202
+ store.answerDecision(id, question.answer, "owner", question.note);
203
+ const note = store.decision(id)?.note;
204
+ emit({
205
+ type: "decision.answered",
206
+ node: record.node,
207
+ decision: id,
208
+ answer: question.answer,
209
+ by: "owner",
210
+ owner,
211
+ ...note === void 0 ? {} : { note }
212
+ });
118
213
  },
119
214
  permit: async (request) => {
120
215
  const { local } = options;
121
216
  let localMessage = DENIED;
122
- const { answer } = await decide({
217
+ const { answer, note } = await decide({
123
218
  node: request.nodeId,
124
219
  kind: "permission",
125
220
  question: `Allow ${request.toolName}?`,
126
221
  options: PERMISSION_OPTIONS,
127
222
  context: inputPreview(request.input)
128
- }, local === void 0 ? void 0 : (signal) => local.permit({
129
- ...request,
130
- signal
131
- }).then((result) => {
132
- if (!result.allow) localMessage = result.message;
133
- return result.allow ? "allow" : "deny";
134
- }));
223
+ }, local === void 0 ? void 0 : (signal) => {
224
+ let said;
225
+ return local.permit({
226
+ ...request,
227
+ signal,
228
+ onNote: (given) => {
229
+ said = given;
230
+ }
231
+ }).then((result) => {
232
+ if (!result.allow) localMessage = result.message;
233
+ const choice = result.allow ? "allow" : "deny";
234
+ return said === void 0 ? { answer: choice } : {
235
+ answer: choice,
236
+ note: said
237
+ };
238
+ });
239
+ });
240
+ if (note !== void 0) request.onNote?.(note);
135
241
  return answer === "allow" ? { allow: true } : {
136
242
  allow: false,
137
- message: localMessage
243
+ message: note === void 0 ? localMessage : deniedMessage(note)
138
244
  };
139
245
  }
140
246
  };
@@ -208,41 +314,6 @@ const startControl = async (options) => {
208
314
  }
209
315
  };
210
316
  };
211
- // ../core/src/feedback/human.ts
212
- const RATINGS = ["good", "bad"];
213
- const MAX_TITLE = 120;
214
- /** The run event a note becomes; its title is the note's first line. */
215
- const humanNoteEvent = (note) => {
216
- const text = maskSecrets(note.text.trim());
217
- const [first = ""] = text.split("\n");
218
- const title = first.slice(0, MAX_TITLE);
219
- const kind = note.kind ?? "friction";
220
- const target = targetOf({
221
- kind,
222
- title,
223
- ...note.target === void 0 ? {} : { target: note.target }
224
- });
225
- const feedback = {
226
- source: "human",
227
- kind,
228
- target,
229
- severity: note.severity ?? (note.rating === "bad" ? "high" : "medium"),
230
- title,
231
- body: text,
232
- evidence: note.rating === void 0 ? [] : [`rating: ${note.rating}`],
233
- fingerprint: fingerprint({
234
- target,
235
- kind,
236
- title
237
- }),
238
- origin: { workflow: note.workflow }
239
- };
240
- return {
241
- type: "feedback.recorded",
242
- ...note.node === void 0 ? {} : { node: note.node },
243
- feedback
244
- };
245
- };
246
317
  // ../core/src/feedback/signals.ts
247
318
  const ORK_TOOL = "mcp__ork__";
248
319
  /**
@@ -375,7 +446,7 @@ const createSignals = (options = {}) => {
375
446
  }
376
447
  if (event.type !== "node.event") return;
377
448
  const inner = event.event;
378
- if (inner.type === "session.started") sessions.set(event.id, {
449
+ if (inner.type === "session.started" && inner.fork !== true) sessions.set(event.id, {
379
450
  model: inner.model,
380
451
  ...inner.driver === void 0 ? {} : { driver: inner.driver }
381
452
  });
@@ -402,6 +473,47 @@ const createSignals = (options = {}) => {
402
473
  return signal === null ? [event] : [event, ...record(...signal)];
403
474
  };
404
475
  };
476
+ /** A title's words, as fingerprints compare them. */
477
+ const titleWords = (title) => new Set(normalizeTitle(title).split(" ").filter((word) => word !== ""));
478
+ /** The share of two titles' words they have in common (Jaccard). */
479
+ const titleOverlap = (a, b) => {
480
+ const left = titleWords(a);
481
+ const right = titleWords(b);
482
+ const union = /* @__PURE__ */ new Set([...left, ...right]);
483
+ if (union.size === 0) return 0;
484
+ return [...left].filter((word) => right.has(word)).length / union.size;
485
+ };
486
+ const unsettled = (item) => item.status === "new" || item.status === "triaged";
487
+ const firstSeen = (a, b) => a.firstSeen.localeCompare(b.firstSeen) || a.id.localeCompare(b.id);
488
+ /**
489
+ * One kind of item's groups, oldest first: an item joins the first group whose
490
+ * first-seen item it is near enough, so every member is within the rule of the
491
+ * item the others go under, never only of one another.
492
+ */
493
+ const groupsOf = (items) => {
494
+ const groups = [];
495
+ for (const item of items.toSorted(firstSeen)) {
496
+ const group = groups.find(([root]) => root !== void 0 && titleOverlap(root.title, item.title) >= .8);
497
+ if (group === void 0) groups.push([item]);
498
+ else group.push(item);
499
+ }
500
+ return groups;
501
+ };
502
+ /** The merges triage would make among `items`, oldest group first. */
503
+ const proposeMerges = (items) => {
504
+ const byKind = /* @__PURE__ */ new Map();
505
+ for (const item of items.filter((i) => unsettled(i))) {
506
+ const key = `${item.target}/${item.kind}`;
507
+ byKind.set(key, [...byKind.get(key) ?? [], item]);
508
+ }
509
+ return [...byKind.values()].flatMap((sameKind) => groupsOf(sameKind)).filter((group) => group.length > 1 && group.some((item) => item.status === "new")).map((group) => {
510
+ const [into, ...from] = group;
511
+ return into === void 0 ? void 0 : {
512
+ into,
513
+ from
514
+ };
515
+ }).filter((merge) => merge !== void 0).toSorted((a, b) => firstSeen(a.into, b.into));
516
+ };
405
517
  // ../core/src/remote/relay.ts
406
518
  /**
407
519
  * @file A run's side of the relay (RFC-003): its nodes' links, read from the
@@ -526,229 +638,491 @@ const createRecorder = (store, run, runDir) => {
526
638
  store.begin(run);
527
639
  return appendRecorder(store, run.id, runDir);
528
640
  };
529
- const updateCachePath = (home = homedir()) => path.join(home, ".ork", "update-check.json");
530
- const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
531
- const text = (record, key) => {
532
- const value = record[key];
533
- return typeof value === "string" ? value : void 0;
534
- };
535
- const notifiedOf = (value) => {
536
- if (!isRecord(value)) return void 0;
537
- const version = text(value, "version");
538
- const at = text(value, "at");
539
- return version === void 0 || at === void 0 ? void 0 : {
540
- version,
541
- at
641
+ // src/help-root.ts
642
+ /** Where the user docs start. */
643
+ const DOCS_URL = "https://github.com/mimshins/ork/blob/main/docs/README.md";
644
+ const rowOf = (entry) => {
645
+ const short = groupOf(entry.name)?.short ?? entry.description;
646
+ const alias = entry.aliases.length === 0 ? "" : ` (alias: ${entry.aliases.join(", ")})`;
647
+ return {
648
+ name: entry.name,
649
+ line: `${short}${alias}`
542
650
  };
543
651
  };
544
- /** The cache, or `null` when there is none or it cannot be read. */
545
- const readUpdateCache = (file) => {
546
- let parsed;
547
- try {
548
- parsed = JSON.parse(readFileSync(file, "utf8"));
549
- } catch {
550
- return null;
551
- }
552
- if (!isRecord(parsed)) return null;
553
- const checkedAt = text(parsed, "checkedAt");
554
- if (checkedAt === void 0) return null;
555
- const latest = text(parsed, "latest");
556
- const notified = notifiedOf(parsed["notified"]);
557
- return {
558
- checkedAt,
559
- ...latest === void 0 ? {} : { latest },
560
- ...notified === void 0 ? {} : { notified }
561
- };
562
- };
563
- /** Writes the cache whole (a temporary file, then a rename). */
564
- const writeUpdateCache = (file, cache) => {
565
- mkdirSync(path.dirname(file), { recursive: true });
566
- const temporary = `${file}.${process.pid}.tmp`;
567
- writeFileSync(temporary, `${JSON.stringify(cache, null, 2)}\n`);
568
- renameSync(temporary, file);
569
- };
570
- const age = (iso, now) => {
571
- const time = Date.parse(iso);
572
- return Number.isNaN(time) ? Infinity : now.getTime() - time;
573
- };
574
- /** Whether it is time to ask the registry again. */
575
- const checkDue = (cache, now) => cache === null || age(cache.checkedAt, now) >= 864e5;
576
- /** The package's `latest` version on the registry, or `null` on any failure. */
577
- const fetchLatest = async (name, options = {}) => {
578
- const registry = (options.registry ?? "https://registry.npmjs.org").replace(/\/+$/u, "");
579
- const get = options.fetch ?? globalThis.fetch;
580
- try {
581
- const response = await get(`${registry}/-/package/${name}/dist-tags`, {
582
- headers: { accept: "application/json" },
583
- signal: AbortSignal.timeout(options.timeoutMs ?? 5e3)
584
- });
585
- if (!response.ok) return null;
586
- const tags = await response.json();
587
- return (isRecord(tags) ? text(tags, "latest") : void 0) ?? null;
588
- } catch {
589
- return null;
590
- }
652
+ const groupedRows = (entries) => groupedEntries(entries).map((group) => ({
653
+ title: group.title,
654
+ rows: group.entries.map((entry) => rowOf(entry))
655
+ }));
656
+ /** A group's title, its rows with names `width` wide, and a blank line. */
657
+ const groupLines = (group, width) => [style("bold", `${group.title}:`)].concat(group.rows.map((row) => ` ${style("cyan", row.name.padEnd(width))} ${row.line}`), [""]);
658
+ /** The root command's help text. */
659
+ const renderRootHelp = async (cmd) => {
660
+ const meta = cmd.meta === void 0 ? {} : await resolved(cmd.meta);
661
+ const name = meta.name ?? "ork";
662
+ const groups = groupedRows(await subcommandsOf(cmd));
663
+ const width = Math.max(...groups.flatMap((g) => g.rows.map((r) => r.name.length)));
664
+ return [
665
+ `${meta.description ?? ""}.`,
666
+ `Docs: ${DOCS_URL}`,
667
+ "",
668
+ ...groups.flatMap((group) => groupLines(group, width)),
669
+ `Usage: ${name} <command> [flags]`,
670
+ "",
671
+ `Use "${name} <command> --help" for more about a command.`,
672
+ `${name} v${meta.version ?? ""}`
673
+ ].join("\n");
591
674
  };
675
+ // src/help.ts
592
676
  /**
593
- * Asks the registry and records the answer. A failed check is recorded too, so
594
- * an offline machine is not asked again until the interval passes; the last
595
- * known `latest` is kept.
677
+ * @file `ork <command> --help`: citty's usage, without the flags Ork passes to
678
+ * itself (the command tree's filter), and with `<command> [flags]` in place
679
+ * of citty's `a|b|c` list for a command with subcommands. The root command's
680
+ * help is grouped (`help-root.ts`).
596
681
  */
597
- const refreshUpdateCache = async (file, name, now, options = {}) => {
598
- const before = readUpdateCache(file);
599
- const latest = await fetchLatest(name, options) ?? before?.latest;
600
- const cache = {
601
- ...before,
602
- checkedAt: now.toISOString(),
603
- ...latest === void 0 ? {} : { latest }
682
+ /** Citty's `a|b|c` usage list of a command's shown subcommands. */
683
+ const choicesOf = async (cmd) => (await subcommandsOf(cmd)).flatMap((entry) => entry.hidden ? [] : [entry.name].concat(entry.aliases)).join("|");
684
+ /** The usage of `cmd` (under `parent`), as people see it. */
685
+ const renderHelp = async (cmd, parent) => {
686
+ const shown = {
687
+ args: publicArgs(await argsOf(cmd)),
688
+ ...cmd.meta === void 0 ? {} : { meta: cmd.meta },
689
+ ...cmd.subCommands === void 0 ? {} : { subCommands: cmd.subCommands }
604
690
  };
605
- writeUpdateCache(file, cache);
606
- return cache;
691
+ if (cmd.subCommands !== void 0 && parent === void 0) return renderRootHelp(shown);
692
+ const above = parent?.meta === void 0 ? void 0 : { meta: parent.meta };
693
+ const usage = await renderUsage(shown, above);
694
+ if (cmd.subCommands === void 0) return usage;
695
+ const choices = await choicesOf(shown);
696
+ return usage.replace(` [OPTIONS] ${choices}`, ` <command> [flags]`).replace(` ${choices}`, ` <command> [flags]`);
607
697
  };
608
- /** The newer version the cache knows of, if any. */
609
- const newerVersion = (cache, current) => cache?.latest !== void 0 && isNewer(cache.latest, current) ? cache.latest : null;
610
- /**
611
- * The version to tell the user about now: newer than theirs, and not already
612
- * told today. `null` when there is nothing to say.
613
- */
614
- const pendingNotice = (cache, current, now) => {
615
- const newer = newerVersion(cache, current);
616
- if (newer === null) return null;
617
- const told = cache?.notified;
618
- return told?.version === newer && age(told.at, now) < 864e5 ? null : newer;
698
+ /** Prints {@link renderHelp}'s usage, as citty prints its own. */
699
+ const showHelp = async (cmd, parent) => {
700
+ process.stdout.write(`${await renderHelp(cmd, parent)}\n\n`);
619
701
  };
620
- // src/runs/chat.ts
702
+ // src/update/notify.ts
621
703
  /**
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.
704
+ * @file The new-version notice (ADR-038). At most once a day a command starts a
705
+ * detached copy of `ork` that asks the npm registry for the latest version
706
+ * and records it in `~/.ork/update-check.json`, so no command waits on the
707
+ * network; a later command, when a newer version is known, ends with one line
708
+ * on stderr (once a day). Off in CI, without a terminal, with `--json`, and
709
+ * with `ORK_NO_UPDATE_CHECK=1` or `NO_UPDATE_NOTIFIER=1`.
627
710
  */
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
- };
711
+ /** Set in the background copy of `ork` that only runs the check. */
712
+ const UPDATE_CHILD_ENV = "ORK_UPDATE_CHECK_CHILD";
634
713
  /**
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.
714
+ * Why the automatic check is off for this command, or `null` when it is on: the
715
+ * environment's switches (core's, which `ork ui` reads too), then where a
716
+ * notice on stderr would go.
637
717
  */
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
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("; ");
692
- }
693
- return failure;
718
+ const updateCheckOff = (env, argv, terminal) => {
719
+ const switched = updateSwitchOff(env);
720
+ if (switched !== null) return switched === "CI" ? "in CI" : "turned off";
721
+ if (isSwitchedOn(env["ORK_DETACHED"]) || !terminal) return "no terminal";
722
+ if (argv.includes("--json")) return "JSON output";
723
+ if (argv[0] === "upgrade") return "ork upgrade checks for itself";
724
+ if (argv[0] === "ui" && argv.includes("--serve")) return "ork ui's server checks for itself";
725
+ return null;
694
726
  };
695
- // src/runs/logs.ts
727
+ /** The notice's text. */
728
+ const updateNotice = (latest, current) => `Ork ${latest} is out (you have ${current}). Upgrade with: ork upgrade`;
696
729
  /**
697
- * @file `ork logs`: replays a stored run's events as the transcript `ork run`
698
- * printed, for the whole run or one node, and tails a live run until it
699
- * ends.
730
+ * Starts the background check when one is due. Records the attempt first, so
731
+ * commands started in the next moments do not start one too.
700
732
  */
701
- /** Turns stored events into lines, keeping the state a transcript needs. */
702
- const createLogPrinter = (options) => {
703
- const tools = /* @__PURE__ */ new Map();
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);
711
- };
733
+ const startUpdateCheck = (file = updateCachePath(), now = /* @__PURE__ */ new Date()) => {
734
+ try {
735
+ const cache = readUpdateCache(file);
736
+ if (!checkDue(cache, now)) return;
737
+ writeUpdateCache(file, {
738
+ ...cache,
739
+ checkedAt: now.toISOString()
740
+ });
741
+ const child = spawn(process.execPath, [...process.execArgv, process.argv[1] ?? ""], {
742
+ detached: true,
743
+ stdio: "ignore",
744
+ env: {
745
+ ...process.env,
746
+ [UPDATE_CHILD_ENV]: "1"
747
+ }
748
+ });
749
+ child.on("error", () => {});
750
+ child.unref();
751
+ } catch {}
712
752
  };
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)`));
753
+ /** The background copy's whole job: ask the registry, record the answer. */
754
+ const runUpdateChild = async () => {
755
+ const registry = registryOf(process.env);
756
+ await refreshUpdateCache(updateCachePath(), ORK_NAME, /* @__PURE__ */ new Date(), {
757
+ current: ORK_VERSION,
758
+ ...registry === void 0 ? {} : { registry }
759
+ });
730
760
  };
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 };
761
+ /** Prints the notice when a newer version is known and not told today. */
762
+ const showUpdateNotice = (file = updateCachePath(), now = /* @__PURE__ */ new Date()) => {
740
763
  try {
741
- origin.workflowHash = createHash("sha256").update(readFileSync(workflowFile)).digest("hex").slice(0, 12);
764
+ const cache = readUpdateCache(file);
765
+ const latest = pendingNotice(cache, ORK_VERSION, now);
766
+ if (cache === null || latest === null) return;
767
+ const text = updateNotice(latest, ORK_VERSION);
768
+ process.stderr.write(`\n${styleText("yellow", text, { stream: process.stderr })}\n`);
769
+ writeUpdateCache(file, {
770
+ ...cache,
771
+ notified: {
772
+ version: latest,
773
+ at: now.toISOString()
774
+ }
775
+ });
742
776
  } catch {}
743
- return origin;
744
777
  };
745
- // src/run/node.ts
778
+ // src/update/upgrade.ts
746
779
  /**
747
- * @file `ork run --node`: checks a workflow and its inputs, plans one Claude
748
- * node's session, streams it to the terminal, and records it as a run of one
749
- * node.
780
+ * @file `ork doctor`'s line for Ork itself (ADR-038): the running version
781
+ * against the last update check, read from the record without asking the
782
+ * network. What `ork upgrade` does is core's (`planUpgrade`, `runInstall`),
783
+ * shared with the server behind `ork ui`.
750
784
  */
751
- const succeeded = (outcome) => outcome?.report !== null && outcome?.report !== void 0 && outcome.report.status !== "fail";
785
+ /** `ork doctor`'s line for Ork itself, from the last check. */
786
+ const versionCheck = (current, cache, checksOff) => {
787
+ const newer = newerVersion$1(cache, current);
788
+ if (newer !== null) return {
789
+ name: "ork",
790
+ status: "warn",
791
+ detail: `${current}; ${newer} is out`,
792
+ hint: "Run ork upgrade."
793
+ };
794
+ return {
795
+ name: "ork",
796
+ status: "ok",
797
+ detail: `${current} (${cache?.latest === void 0 ? checksOff ? "update check off" : "not checked for updates yet" : `the latest as of ${cache.checkedAt.slice(0, 10)}`})`
798
+ };
799
+ };
800
+ // src/doctor/checks.ts
801
+ /**
802
+ * @file The checks behind `ork doctor`: Ork's own version against the last
803
+ * update check, each tool's presence and login status (the drivers' own
804
+ * checks, which `ork run` also reads to pick the one ready), and whether a
805
+ * run's socket path fits, through an injectable `exec` so tests need no real
806
+ * tools. Never prints a token or an account.
807
+ */
808
+ const TIMEOUT_MS = 15e3;
809
+ const exec = (program, args) => new Promise((resolve) => {
810
+ execFile(program, args, {
811
+ timeout: TIMEOUT_MS,
812
+ encoding: "utf8"
813
+ }, (error, stdout) => {
814
+ resolve({
815
+ ok: error === null,
816
+ stdout
817
+ });
818
+ });
819
+ });
820
+ /** Ork's own version, against the last update check (no network). */
821
+ const orkCheck = () => versionCheck(ORK_VERSION, readUpdateCache(updateCachePath()), updateCheckOff(process.env, [], true) !== null);
822
+ const MIN_NODE = [24, 20];
823
+ const checkNode = (version) => {
824
+ const [major = 0, minor = 0] = version.split(".").map(Number);
825
+ const [minMajor, minMinor] = MIN_NODE;
826
+ return major > minMajor || major === minMajor && minor >= minMinor ? {
827
+ name: "node",
828
+ status: "ok",
829
+ detail: `v${version}`
830
+ } : {
831
+ name: "node",
832
+ status: "fail",
833
+ detail: `v${version}`,
834
+ hint: `Ork needs Node ${MIN_NODE.join(".")} or later.`
835
+ };
836
+ };
837
+ const checkGit = async (run) => {
838
+ const version = await run("git", ["--version"]);
839
+ if (!version.ok) return [{
840
+ name: "git",
841
+ status: "fail",
842
+ detail: "not found",
843
+ hint: "Install git; Ork gives each writing node its own worktree."
844
+ }];
845
+ const inside = await run("git", ["rev-parse", "--is-inside-work-tree"]);
846
+ return [{
847
+ name: "git",
848
+ status: "ok",
849
+ detail: firstLine(version.stdout)
850
+ }, inside.ok ? {
851
+ name: "git repo",
852
+ status: "ok",
853
+ detail: "inside a work tree"
854
+ } : {
855
+ name: "git repo",
856
+ status: "warn",
857
+ detail: "not a git repository",
858
+ hint: "Run Ork inside a git repository so nodes can use worktrees."
859
+ }];
860
+ };
861
+ /**
862
+ * Runs every check: Node and git, then every registered Claude driver and
863
+ * integration (ADR-022). Node, git, and at least one driver are required;
864
+ * integrations are optional.
865
+ */
866
+ const runChecks = async (run, env, nodeVersion) => {
867
+ const [git, checked, tools] = await Promise.all([
868
+ checkGit(run),
869
+ driverChecks(run, env),
870
+ Promise.all(INTEGRATIONS.flatMap((i) => i.doctor === void 0 ? [] : [i.doctor(run, env)]))
871
+ ]);
872
+ const drivers = checked.map(({ result }) => result);
873
+ const results = [
874
+ checkNode(nodeVersion),
875
+ ...git,
876
+ ...drivers,
877
+ ...tools
878
+ ];
879
+ return {
880
+ results,
881
+ ok: drivers.some((d) => d.status === "ok") && !results.some((r) => r.status === "fail")
882
+ };
883
+ };
884
+ /**
885
+ * Whether a run's socket fits under `~` (L38). Past the limit a run can't
886
+ * listen, and nothing else says so: answers, commands and followers fall back
887
+ * to the store, a second at a time. `home` is the user's `~`.
888
+ */
889
+ const socketCheck = (home, platform) => {
890
+ const { bytes, max } = socketRoom(home, platform);
891
+ if (max === void 0 || bytes <= max) return {
892
+ name: "sockets",
893
+ status: "ok",
894
+ detail: max === void 0 ? "not Unix sockets here" : `${String(bytes)} of ${String(max)} bytes`
895
+ };
896
+ return {
897
+ name: "sockets",
898
+ status: "warn",
899
+ detail: `a run's socket path is ${String(bytes)} bytes, past the ${String(max)} this system allows`,
900
+ hint: "Runs still work, but answers, commands and `ork logs --follow` wait up to a second for each, as they read the store instead. A shorter home folder path fixes it."
901
+ };
902
+ };
903
+ /**
904
+ * What it says of triggers (RFC-011): a long-lived process can die quietly, so
905
+ * a live trigger that hasn't looked when it should have is worth saying. `home`
906
+ * is the user's `~` (tests pass a folder).
907
+ */
908
+ const triggerChecks = (home) => {
909
+ const store = openStore(storePath(home));
910
+ try {
911
+ const report = triggersReport(store, store.projects(), home);
912
+ const broken = report.broken.map(({ file, message }) => ({
913
+ name: "triggers",
914
+ status: "warn",
915
+ detail: `${file}: ${message}`,
916
+ hint: "Its triggers are off until it can be read."
917
+ }));
918
+ const live = report.triggers.filter(({ standing }) => standing === "live");
919
+ if (live.length === 0) return broken;
920
+ const late = lateTriggers(report, /* @__PURE__ */ new Date());
921
+ if (late.length === 0) return [...broken, {
922
+ name: "ork watch",
923
+ status: "ok",
924
+ detail: `${String(live.length)} trigger(s) looking`
925
+ }];
926
+ return [...broken, {
927
+ name: "ork watch",
928
+ status: "warn",
929
+ detail: `not looking: ${late.map(({ trigger }) => trigger).join(", ")}`,
930
+ hint: report.pid === void 0 ? "Start it with `ork watch --detach`." : "See `ork watch --status`."
931
+ }];
932
+ } finally {
933
+ store.close();
934
+ }
935
+ };
936
+ // src/run/ready-driver.ts
937
+ /**
938
+ * @file The driver a run takes for the session nodes that name none (core's
939
+ * `driver.ts` has the whole rule): the one `ork doctor`'s own checks find
940
+ * ready on this machine, said in one line. With none ready, the run stops
941
+ * before any session starts, with the hints `ork doctor` gives. A resumed run
942
+ * keeps the driver it recorded.
943
+ */
944
+ /** The real checks: the tools on this machine and its environment. */
945
+ const checksHere = () => driverChecks(exec, process.env);
946
+ const subject = (names, all) => {
947
+ if (all) return "No driver is named, so the run uses";
948
+ const [only] = names;
949
+ return names.length === 1 && only !== void 0 ? `${only} names no driver, so it uses` : `${names.join(", ")} name no driver, so they use`;
950
+ };
951
+ /**
952
+ * The driver for `names`, the session nodes that name none, from `checks`.
953
+ * `all` says whether they are every session node of the run.
954
+ */
955
+ const chooseReadyDriver = (names, all, checks) => {
956
+ const ready = readyDriver(checks);
957
+ if (!ready.ok) return {
958
+ ok: false,
959
+ lines: [
960
+ `No Claude driver is ready here, and ${names.join(", ")} ${names.length === 1 ? "names" : "name"} none.`,
961
+ ...ready.hints,
962
+ "See `ork doctor`."
963
+ ]
964
+ };
965
+ const why = ready.ready === 1 ? `the one ready here (${ready.detail})` : `more than one is ready, and a run takes ${ready.driver} first`;
966
+ return {
967
+ ok: true,
968
+ driver: ready.driver,
969
+ line: `${subject(names, all)} the ${ready.driver} driver: ${why}.`
970
+ };
971
+ };
972
+ /**
973
+ * What a run takes for its session nodes that name no driver: nothing when
974
+ * `--driver` overrides them all or each names one; the driver a resumed run
975
+ * recorded; otherwise the one ready here.
976
+ */
977
+ const runReadyDriver = async (workflow, options) => {
978
+ if (options.override !== void 0) return { ok: true };
979
+ const names = namingNoDriver(workflow);
980
+ if (names.length === 0) return { ok: true };
981
+ if (options.recorded !== void 0) return {
982
+ ok: true,
983
+ driver: options.recorded
984
+ };
985
+ const sessions = Object.values(workflow.nodes).filter((node) => node.type === "claude.leader" || node.type === "claude.worker").length;
986
+ return chooseReadyDriver(names, names.length === sessions, await (options.checks ?? checksHere)());
987
+ };
988
+ // src/runs/chat.ts
989
+ /**
990
+ * @file `ork chat`'s two paths. A node of a live run gets the message through
991
+ * the store and the run's socket, and the run's bus pushes it into the node's
992
+ * session (specs/messaging.md#chat; `tellLiveRun` and `nodeSession` are in
993
+ * core). A node of a finished run gets it by resuming its last Claude session
994
+ * where it ran, and the exchange is added to the run's record.
995
+ */
996
+ /** The declared node a spawned worker (`<template>-<n>`) is an instance of. */
997
+ const templateOf = (nodes, id) => {
998
+ if (id in nodes) return void 0;
999
+ const template = id.replace(/-\d+$/u, "");
1000
+ return template in nodes ? template : void 0;
1001
+ };
1002
+ /**
1003
+ * Resumes a finished node's last session with the user's message, prints the
1004
+ * session, and adds it to the run's record. Returns why it could not.
1005
+ */
1006
+ const resumeChat = async (options) => {
1007
+ const { store, run, node } = options;
1008
+ const { session, outputs } = nodeSession(store.events(run.id, 0), node);
1009
+ if (!session.known) return `${node} is not a node of run ${run.id}.`;
1010
+ if (!session.chattable) return `${node} is not a leader or worker; only they have sessions to talk to.`;
1011
+ if (session.sessionId === void 0) return `${node} has no Claude session to resume in run ${run.id}.`;
1012
+ const cwd = session.cwd ?? session.checkout?.path;
1013
+ 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.`;
1014
+ const { workflow, place, workflowDir } = loadWorkflow(run.file, path.dirname(run.file), void 0, store.projects());
1015
+ if (workflow === null) return `The workflow ${run.file} is gone or no longer valid.`;
1016
+ const notice = chatNotice(store, place, options.home);
1017
+ if (notice !== null) options.out(notice);
1018
+ const runDir = runDirOf(place, run.id);
1019
+ const template = templateOf(workflow.nodes, node);
1020
+ const plan = planNodeSession(workflow, node, {
1021
+ cwd,
1022
+ inputs: {},
1023
+ outputs,
1024
+ inPlace: session.checkout === void 0,
1025
+ workflowDir,
1026
+ projectRoot: run.project,
1027
+ orkDir: place.dir,
1028
+ pluginDir: path.join(runDir, "plugins", node),
1029
+ ...options.budgetLeftUsd === void 0 ? {} : { budgetLeftUsd: options.budgetLeftUsd },
1030
+ ...session.checkout === void 0 ? {} : { checkout: session.checkout },
1031
+ ...template === void 0 ? {} : { template }
1032
+ });
1033
+ if (!plan.ok) return plan.error;
1034
+ let driver = options.driver ?? session.driver ?? plan.driver;
1035
+ if (driver === void 0) {
1036
+ const ready = readyDriver(await (options.driverChecks ?? checksHere)());
1037
+ if (!ready.ok) return ["No Claude driver is ready here.", ...ready.hints].join(" ");
1038
+ driver = ready.driver;
1039
+ }
1040
+ const record = appendRecorder(store, run.id, runDir);
1041
+ record({
1042
+ type: "user.message",
1043
+ node,
1044
+ body: options.body,
1045
+ delivered: "resumed"
1046
+ });
1047
+ const started = (await options.runtime(driver)).start({
1048
+ ...plan.spec,
1049
+ resume: session.sessionId,
1050
+ prompt: options.body
1051
+ }, { human: options.human });
1052
+ if (options.signal?.aborted === true) started.interrupt();
1053
+ options.signal?.addEventListener("abort", () => {
1054
+ started.interrupt();
1055
+ }, { once: true });
1056
+ const tools = /* @__PURE__ */ new Map();
1057
+ let failure = null;
1058
+ for await (const event of started.events) {
1059
+ record({
1060
+ type: "node.event",
1061
+ id: node,
1062
+ event
1063
+ });
1064
+ for (const line of formatEvent(node, event, tools)) options.out(line);
1065
+ if (event.type === "session.finished" && event.outcome.subtype === "error_driver") failure = event.outcome.errors.join("; ");
1066
+ }
1067
+ return failure;
1068
+ };
1069
+ // src/runs/logs.ts
1070
+ /**
1071
+ * @file `ork logs`: replays a stored run's events as the transcript `ork run`
1072
+ * printed, for the whole run or one node, and tails a live run until it
1073
+ * ends.
1074
+ */
1075
+ /** Turns stored events into lines, keeping the state a transcript needs. */
1076
+ const createLogPrinter = (options) => {
1077
+ const tools = /* @__PURE__ */ new Map();
1078
+ let width = 8;
1079
+ return (stored) => {
1080
+ const { event } = stored;
1081
+ if (event.type === "run.started") width = Math.max(...event.nodes.map((n) => n.id.length), 1);
1082
+ if (options.node !== void 0 && nodeOf(event) !== options.node) return [];
1083
+ if (options.json) return [JSON.stringify(stored)];
1084
+ return formatRunEvent(event, width, tools);
1085
+ };
1086
+ };
1087
+ /**
1088
+ * Prints a run's events; with `follow`, then each new one as it is stored:
1089
+ * pushed over the run's socket, or polled from the store when there is none.
1090
+ * Returns when `run.finished` is stored, the run's process is gone, or `signal`
1091
+ * aborts.
1092
+ */
1093
+ const followRun = async (store, run, out, options) => {
1094
+ const print = createLogPrinter(options);
1095
+ if (await followEvents(store, run.id, (stored) => {
1096
+ for (const line of print(stored)) out(line);
1097
+ }, {
1098
+ follow: options.follow,
1099
+ ...options.after === void 0 ? {} : { after: options.after },
1100
+ ...options.intervalMs === void 0 ? {} : { intervalMs: options.intervalMs },
1101
+ ...options.home === void 0 ? {} : { home: options.home },
1102
+ ...options.signal === void 0 ? {} : { signal: options.signal }
1103
+ }) === "crashed") out(style("red", `run ${run.id} stopped without finishing (its process is gone)`));
1104
+ };
1105
+ // src/run/origin.ts
1106
+ /**
1107
+ * @file Where a run's feedback comes from, beyond what its events say: Ork's
1108
+ * version (bound from the CLI's `package.json`) and the workflow file's
1109
+ * content hash.
1110
+ */
1111
+ /** Ork's version and the workflow's hash, for the run's feedback records. */
1112
+ const feedbackOrigin = (workflowFile) => {
1113
+ const origin = { version: ORK_VERSION };
1114
+ try {
1115
+ origin.workflowHash = createHash("sha256").update(readFileSync(workflowFile)).digest("hex").slice(0, 12);
1116
+ } catch {}
1117
+ return origin;
1118
+ };
1119
+ // src/run/node.ts
1120
+ /**
1121
+ * @file `ork run --node`: checks a workflow and its inputs, plans one Claude
1122
+ * node's session, streams it to the terminal, and records it as a run of one
1123
+ * node.
1124
+ */
1125
+ const succeeded = (outcome) => outcome?.report !== null && outcome?.report !== void 0 && outcome.report.status !== "fail";
752
1126
  /**
753
1127
  * Runs one Claude node of a workflow on its own: checks the workflow and the
754
1128
  * inputs, plans the session, and streams it to the terminal.
@@ -787,6 +1161,15 @@ const runNode = async (options) => {
787
1161
  pluginDir: path.join(runDir, "plugins", options.nodeId)
788
1162
  });
789
1163
  if (!plan.ok) return failed([plan.error]);
1164
+ const named = sessionDriver({
1165
+ override: options.driver,
1166
+ named: plan.driver
1167
+ });
1168
+ const ready = named === void 0 ? chooseReadyDriver([options.nodeId], true, await (options.driverChecks ?? checksHere)()) : null;
1169
+ if (ready?.ok === false) return failed(ready.lines);
1170
+ if (ready?.line !== void 0) options.err(ready.line);
1171
+ const driver = named ?? ready?.driver;
1172
+ if (driver === void 0) return failed(["No Claude driver is ready here."]);
790
1173
  const planned = workflow.nodes[options.nodeId];
791
1174
  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
1175
  const skipped = planned === void 0 ? [] : [...beforeOf(planned, workflow.defaults) === void 0 ? [] : ["before"], ..."verify" in planned && planned.verify !== void 0 ? ["verify"] : []];
@@ -848,7 +1231,7 @@ const runNode = async (options) => {
848
1231
  nodeType,
849
1232
  round: 1
850
1233
  });
851
- const session = (await options.runtime(options.driver ?? plan.driver)).start(plan.spec, {
1234
+ const session = (await options.runtime(driver)).start(plan.spec, {
852
1235
  human: control.human,
853
1236
  ...graph === null ? {} : { feedback: createFeedback({
854
1237
  graph,
@@ -895,7 +1278,9 @@ const runNode = async (options) => {
895
1278
  ...result.error === void 0 ? {} : { error: result.error }
896
1279
  }],
897
1280
  reasons: result.error === void 0 ? [] : [`${id}: ${result.error}`],
898
- worktrees: []
1281
+ worktrees: [],
1282
+ skipped: 0,
1283
+ failuresHandled: 0
899
1284
  }
900
1285
  });
901
1286
  await control.close();
@@ -1091,6 +1476,16 @@ const preflight = async (options, given) => {
1091
1476
  ok: false,
1092
1477
  lines: refused
1093
1478
  };
1479
+ const driver = await runReadyDriver(given.effective, {
1480
+ override: options.driver,
1481
+ recorded: options.resume?.settings.readyDriver,
1482
+ checks: options.driverChecks
1483
+ });
1484
+ if (!driver.ok) return {
1485
+ ok: false,
1486
+ lines: driver.lines
1487
+ };
1488
+ if (driver.line !== void 0) options.err(driver.line);
1094
1489
  const checkout = await prepareCheckout(workflow, {
1095
1490
  ...options,
1096
1491
  cwd: place.root
@@ -1101,7 +1496,8 @@ const preflight = async (options, given) => {
1101
1496
  };
1102
1497
  return {
1103
1498
  ...checkout,
1104
- limits: limits.now
1499
+ limits: limits.now,
1500
+ ...driver.driver === void 0 ? {} : { readyDriver: driver.driver }
1105
1501
  };
1106
1502
  };
1107
1503
  // src/run/workflow.ts
@@ -1141,6 +1537,7 @@ const runWorkflowFile = async (options) => {
1141
1537
  const projects = options.store.projects();
1142
1538
  const ready = await preflight(options, {
1143
1539
  workflow,
1540
+ effective: graph.workflow,
1144
1541
  place,
1145
1542
  projects,
1146
1543
  inputs: inputs.inputs
@@ -1201,6 +1598,7 @@ const runWorkflowFile = async (options) => {
1201
1598
  maskedNames
1202
1599
  };
1203
1600
  if (options.driver !== void 0) executorOptions.driver = options.driver;
1601
+ if (ready.readyDriver !== void 0) executorOptions.readyDriver = ready.readyDriver;
1204
1602
  if (options.tools !== void 0) executorOptions.tools = options.tools;
1205
1603
  const script = process.argv[1];
1206
1604
  if (script !== void 0 && onPath(script)) executorOptions.orkBin = script;
@@ -1231,6 +1629,7 @@ const runWorkflowFile = async (options) => {
1231
1629
  settings: {
1232
1630
  inPlace: options.inPlace,
1233
1631
  ...options.driver === void 0 ? {} : { driver: options.driver },
1632
+ ...ready.readyDriver === void 0 ? {} : { readyDriver: ready.readyDriver },
1234
1633
  ...ports.settings
1235
1634
  },
1236
1635
  ...ports.relay === void 0 ? {} : { relay: ports.relay },
@@ -1273,7 +1672,7 @@ const flagValues = (rawArgs, flag) => {
1273
1672
  };
1274
1673
  const isDriver = (value) => CLAUDE_DRIVERS.some((d) => d.id === value);
1275
1674
  /** Loads the drivers only when a run starts, so other commands stay fast. */
1276
- const loadRuntime = async (driver) => (await import("./src-CZOVF2yU.mjs")).createRuntime(driver, sessionEnv(process.env));
1675
+ const loadRuntime = async (driver) => (await import("./src-Dyz_Gy1q.mjs")).createRuntime(driver, sessionEnv(process.env));
1277
1676
  /**
1278
1677
  * Ctrl-C asks the run to stop, and so does a terminal closing or a polite kill,
1279
1678
  * so that the run ends tidily and leaves no result unscrubbed.
@@ -1304,7 +1703,7 @@ const run = defineCommand({
1304
1703
  args: {
1305
1704
  workflow: {
1306
1705
  type: "positional",
1307
- description: "A workflow file, or the name of one in the project's workflows (.ork/workflows/)",
1706
+ description: "A workflow file, the name of one in the project's workflows (.ork/workflows/), or one of Ork's own (ork:feedback-apply)",
1308
1707
  required: true
1309
1708
  },
1310
1709
  node: {
@@ -1375,6 +1774,7 @@ const run = defineCommand({
1375
1774
  return;
1376
1775
  }
1377
1776
  const cwd = process.cwd();
1777
+ if (refusedBuiltin([args.workflow], cwd)) return;
1378
1778
  const target = workflowArg(args.workflow, cwd);
1379
1779
  const store = openStore();
1380
1780
  if (args.detach) {
@@ -1591,20 +1991,52 @@ const chat = defineCommand({
1591
1991
  }
1592
1992
  }
1593
1993
  });
1594
- // src/commands/control.ts
1994
+ // src/commands/decide.ts
1595
1995
  /**
1596
- * @file `ork decide`, `ork stop` and `ork resume`: answer a live run's open
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).
1996
+ * @file `ork decide`: answer a live run's open decisions from any terminal (it
1997
+ * writes to the store, then wakes the run on its socket; specs/messaging.md).
1998
+ * `--say` gives the answer a note, which goes to the run with it. With `--id`
1999
+ * and `--choose`, `--note` and `--rating` instead note how the decision's
2000
+ * node went, as the run's feedback (specs/feedback.md).
1601
2001
  */
1602
- const failed = (message) => {
2002
+ const failed$2 = (message) => {
1603
2003
  writeErr(message);
1604
2004
  process.exitCode = 1;
1605
2005
  };
2006
+ /**
2007
+ * Notes the answered decision's node, when there's a note or a rating; a rating
2008
+ * alone is its note's words.
2009
+ */
2010
+ const noteDecision = (store, id, note, rating) => {
2011
+ if (note === void 0 && rating === void 0) return void 0;
2012
+ const decision = store.decision(id);
2013
+ const [run] = decision === void 0 ? [] : store.lookup(decision.runId);
2014
+ if (decision === void 0 || run === void 0) return {
2015
+ ok: false,
2016
+ error: `No decision ${id} to note.`
2017
+ };
2018
+ return recordNote(store, run, {
2019
+ text: note ?? `Rated ${rating ?? ""} at ${decision.question}`,
2020
+ node: decision.node,
2021
+ rating
2022
+ }, "--");
2023
+ };
2024
+ /** Why a note given with an answer can't be recorded, before answering. */
2025
+ const noteProblem = (note, rating) => {
2026
+ if (note === void 0 && rating === void 0) return void 0;
2027
+ const checked = checkNote({
2028
+ text: note ?? "rated",
2029
+ rating
2030
+ }, "--");
2031
+ return checked.ok ? void 0 : checked.error;
2032
+ };
2033
+ /** What the answer said, as stored (masked), after the answer's line. */
2034
+ const saidLine = (store, id) => {
2035
+ const note = store.decision(id)?.note;
2036
+ return note === void 0 ? "" : style("dim", ` · said: ${note}`);
2037
+ };
1606
2038
  /** One line per open decision, for `--list`. */
1607
- const formatDecisions = (decisions) => decisions.length === 0 ? ["No open decisions."] : decisions.map((d) => `${style("bold", d.id)} ${style("cyan", d.node)} ${d.question} ${style("dim", `[${d.options.map((o) => o.id === d.recommended ? `${o.id}*` : o.id).join(", ")}]`)}`);
2039
+ const formatDecisions = (decisions) => decisions.length === 0 ? ["No open decisions."] : decisions.map((d) => `${style("bold", d.id)} ${style("cyan", d.node)}${d.owner === void 0 ? "" : style("dim", ` via ${d.owner}`)} ${d.question} ${style("dim", `[${d.options.map((o) => o.id === d.recommended ? `${o.id}*` : o.id).join(", ")}]`)}`);
1608
2040
  const decide = defineCommand({
1609
2041
  meta: {
1610
2042
  name: "decide",
@@ -1638,12 +2070,24 @@ const decide = defineCommand({
1638
2070
  choose: {
1639
2071
  type: "string",
1640
2072
  description: "…with this option"
2073
+ },
2074
+ say: {
2075
+ type: "string",
2076
+ description: "…and say this with it: the answer's note, which goes to the run"
2077
+ },
2078
+ note: {
2079
+ type: "string",
2080
+ description: "…and note how its node went; the note joins the run's feedback"
2081
+ },
2082
+ rating: {
2083
+ type: "string",
2084
+ description: "…rating it good or bad"
1641
2085
  }
1642
2086
  },
1643
2087
  run: async ({ args }) => {
1644
2088
  const refusal = args.id !== void 0 || args.choose !== void 0 || process.stdin.isTTY && !args.list && !args.json ? decideRefusal() : null;
1645
2089
  if (refusal !== null) {
1646
- failed(refusal);
2090
+ failed$2(refusal);
1647
2091
  return;
1648
2092
  }
1649
2093
  const store = openStore();
@@ -1655,19 +2099,39 @@ const decide = defineCommand({
1655
2099
  try {
1656
2100
  if (args.id !== void 0 || args.choose !== void 0) {
1657
2101
  if (args.id === void 0 || args.choose === void 0) {
1658
- failed("--id and --choose go together.");
2102
+ failed$2("--id and --choose go together.");
2103
+ return;
2104
+ }
2105
+ const problem = noteProblem(args.note, args.rating);
2106
+ if (problem !== void 0) {
2107
+ failed$2(problem);
1659
2108
  return;
1660
2109
  }
1661
- const answered = await answerDecision(store, args.id, args.choose);
1662
- if (answered.ok) write(`${style("green", "✓")} ${args.id}: ${args.choose}`);
1663
- else failed(answered.error);
2110
+ const answered = await answerDecision(store, args.id, args.choose, void 0, "decide", args.say);
2111
+ if (!answered.ok) {
2112
+ failed$2(answered.error);
2113
+ return;
2114
+ }
2115
+ write(`${style("green", "✓")} ${args.id}: ${args.choose}${saidLine(store, args.id)}`);
2116
+ const noted = noteDecision(store, args.id, args.note, args.rating);
2117
+ if (noted === void 0) return;
2118
+ if (noted.ok) write(`${style("green", "✓")} noted in item ${noted.item}`);
2119
+ else failed$2(noted.error);
2120
+ return;
2121
+ }
2122
+ if (args.note !== void 0 || args.rating !== void 0) {
2123
+ failed$2("--note and --rating go with --id and --choose.");
2124
+ return;
2125
+ }
2126
+ if (args.say !== void 0) {
2127
+ failed$2("--say goes with --id and --choose; answering here asks for a note after each choice.");
1664
2128
  return;
1665
2129
  }
1666
2130
  const filter = {};
1667
2131
  if (args.run !== void 0 && args.run !== "") {
1668
2132
  const found = findRun(store, args.run, process.cwd());
1669
2133
  if (!found.ok) {
1670
- failed(found.error);
2134
+ failed$2(found.error);
1671
2135
  return;
1672
2136
  }
1673
2137
  filter.runId = found.run.id;
@@ -1690,9 +2154,13 @@ const decide = defineCommand({
1690
2154
  };
1691
2155
  if (d.recommended !== void 0) question.recommended = d.recommended;
1692
2156
  if (d.context !== void 0) question.context = d.context;
2157
+ let note;
2158
+ question.onNote = (given) => {
2159
+ note = given;
2160
+ };
1693
2161
  const choice = await human.ask(question);
1694
- const answered = await answerDecision(store, d.id, choice);
1695
- write(answered.ok ? `${style("green", "✓")} ${d.id}: ${choice}\n` : `${style("yellow", "!")} ${answered.error}\n`);
2162
+ const answered = await answerDecision(store, d.id, choice, void 0, "decide", note);
2163
+ write(answered.ok ? `${style("green", "✓")} ${d.id}: ${choice}${saidLine(store, d.id)}\n` : `${style("yellow", "!")} ${answered.error}\n`);
1696
2164
  }
1697
2165
  } finally {
1698
2166
  human?.close();
@@ -1700,6 +2168,17 @@ const decide = defineCommand({
1700
2168
  }
1701
2169
  }
1702
2170
  });
2171
+ // src/commands/control.ts
2172
+ /**
2173
+ * @file `ork stop` and `ork resume`: stop a live run, or one of its nodes
2174
+ * (ADR-041), from any terminal (it writes to the store, then wakes the run on
2175
+ * its socket; specs/messaging.md), and continue a stopped or crashed run with
2176
+ * its recorded state (ADR-026). `ork decide` is in `decide.ts`.
2177
+ */
2178
+ const failed$1 = (message) => {
2179
+ writeErr(message);
2180
+ process.exitCode = 1;
2181
+ };
1703
2182
  const stop = defineCommand({
1704
2183
  meta: {
1705
2184
  name: "stop",
@@ -1721,13 +2200,13 @@ const stop = defineCommand({
1721
2200
  try {
1722
2201
  const found = findRun(store, args.run, process.cwd());
1723
2202
  if (!found.ok) {
1724
- failed(found.error);
2203
+ failed$1(found.error);
1725
2204
  return;
1726
2205
  }
1727
2206
  const { run } = found;
1728
2207
  const asked = await requestStop(store, run, void 0, args.node);
1729
2208
  if (!asked.ok) {
1730
- failed(asked.error);
2209
+ failed$1(asked.error);
1731
2210
  return;
1732
2211
  }
1733
2212
  if (args.node !== void 0) {
@@ -1752,7 +2231,7 @@ const stop = defineCommand({
1752
2231
  const resume = defineCommand({
1753
2232
  meta: {
1754
2233
  name: "resume",
1755
- description: "Continue a stopped or crashed run: finished nodes keep their results"
2234
+ description: "Continue a run that didn't succeed (stopped, failed or crashed): finished nodes keep their results"
1756
2235
  },
1757
2236
  args: {
1758
2237
  run: {
@@ -1790,7 +2269,7 @@ const resume = defineCommand({
1790
2269
  },
1791
2270
  run: async ({ args, rawArgs }) => {
1792
2271
  if (args.driver !== void 0 && !isDriver(args.driver)) {
1793
- failed(`--driver must be sdk or cli, got \`${args.driver}\``);
2272
+ failed$1(`--driver must be sdk or cli, got \`${args.driver}\``);
1794
2273
  return;
1795
2274
  }
1796
2275
  const store = openStore();
@@ -1802,300 +2281,45 @@ const resume = defineCommand({
1802
2281
  ...args.driver === void 0 ? {} : { driver: args.driver }
1803
2282
  }) : found;
1804
2283
  if (!found.ok || !ready.ok) {
1805
- failed(ready.ok ? "" : ready.error);
2284
+ failed$1(ready.ok ? "" : ready.error);
1806
2285
  return;
1807
2286
  }
1808
2287
  if (args.detach) {
1809
2288
  const flags = rawArgs.filter((a) => a !== "--detach" && a !== args.run);
1810
2289
  const detached = await detachResume(store, found.run, flags);
1811
2290
  if (detached.ok) write(`▸ run ${found.run.id} resumed in the background`);
1812
- else failed(`${detached.error}\n(output: ${detached.log})`);
2291
+ else failed$1(`${detached.error}\n(output: ${detached.log})`);
1813
2292
  return;
1814
- }
1815
- const { exitCode } = await runWorkflowFile({
1816
- file: found.run.file,
1817
- cwd: found.run.project,
1818
- inputs: flagValues(rawArgs, "--input"),
1819
- inPlace: ready.inPlace,
1820
- allowDirty: args["allow-dirty"],
1821
- json: args.json,
1822
- runtime: loadRuntime,
1823
- store,
1824
- ...human === void 0 ? {} : { human },
1825
- runId: found.run.id,
1826
- resume: ready.state,
1827
- out: write,
1828
- err: writeErr,
1829
- ...ready.driver === void 0 ? {} : { driver: ready.driver },
1830
- onRun: (handle) => {
1831
- process.once("SIGINT", () => {
1832
- writeErr(style("dim", "\ninterrupting…"));
1833
- handle.interrupt();
1834
- });
1835
- }
1836
- });
1837
- process.exitCode = exitCode;
1838
- } finally {
1839
- human?.close();
1840
- store.close();
1841
- }
1842
- }
1843
- });
1844
- // src/update/notify.ts
1845
- /**
1846
- * @file The new-version notice (ADR-038). At most once a day a command starts a
1847
- * detached copy of `ork` that asks the npm registry for the latest version
1848
- * and records it in `~/.ork/update-check.json`, so no command waits on the
1849
- * network; a later command, when a newer version is known, ends with one line
1850
- * on stderr (once a day). Off in CI, without a terminal, with `--json`, and
1851
- * with `ORK_NO_UPDATE_CHECK=1` or `NO_UPDATE_NOTIFIER=1`.
1852
- */
1853
- /** Set in the background copy of `ork` that only runs the check. */
1854
- const UPDATE_CHILD_ENV = "ORK_UPDATE_CHECK_CHILD";
1855
- /** A registry to ask instead of npm's, e.g. a company mirror. */
1856
- const UPDATE_REGISTRY_ENV = "ORK_UPDATE_REGISTRY";
1857
- const on = (value) => value !== void 0 && value !== "" && value !== "0" && value.toLowerCase() !== "false";
1858
- /** Why the automatic check is off for this command, or `null` when it is on. */
1859
- const updateCheckOff = (env, argv, terminal) => {
1860
- if (on(env["ORK_NO_UPDATE_CHECK"]) || on(env["NO_UPDATE_NOTIFIER"])) return "turned off";
1861
- if (on(env["CI"])) return "in CI";
1862
- if (on(env["ORK_DETACHED"]) || !terminal) return "no terminal";
1863
- if (argv.includes("--json")) return "JSON output";
1864
- if (argv[0] === "upgrade") return "ork upgrade checks for itself";
1865
- return null;
1866
- };
1867
- const registryOf = (env) => {
1868
- const registry = env[UPDATE_REGISTRY_ENV];
1869
- return registry === void 0 || registry === "" ? void 0 : registry;
1870
- };
1871
- /** The notice's text. */
1872
- const updateNotice = (latest, current) => `Ork ${latest} is out (you have ${current}). Upgrade with: ork upgrade`;
1873
- /**
1874
- * Starts the background check when one is due. Records the attempt first, so
1875
- * commands started in the next moments do not start one too.
1876
- */
1877
- const startUpdateCheck = (file = updateCachePath(), now = /* @__PURE__ */ new Date()) => {
1878
- try {
1879
- const cache = readUpdateCache(file);
1880
- if (!checkDue(cache, now)) return;
1881
- writeUpdateCache(file, {
1882
- ...cache,
1883
- checkedAt: now.toISOString()
1884
- });
1885
- const child = spawn(process.execPath, [...process.execArgv, process.argv[1] ?? ""], {
1886
- detached: true,
1887
- stdio: "ignore",
1888
- env: {
1889
- ...process.env,
1890
- [UPDATE_CHILD_ENV]: "1"
1891
- }
1892
- });
1893
- child.on("error", () => {});
1894
- child.unref();
1895
- } catch {}
1896
- };
1897
- /** The background copy's whole job: ask the registry, record the answer. */
1898
- const runUpdateChild = async () => {
1899
- const registry = registryOf(process.env);
1900
- await refreshUpdateCache(updateCachePath(), ORK_NAME, /* @__PURE__ */ new Date(), registry === void 0 ? {} : { registry });
1901
- };
1902
- /** Prints the notice when a newer version is known and not told today. */
1903
- const showUpdateNotice = (file = updateCachePath(), now = /* @__PURE__ */ new Date()) => {
1904
- try {
1905
- const cache = readUpdateCache(file);
1906
- const latest = pendingNotice(cache, ORK_VERSION, now);
1907
- if (cache === null || latest === null) return;
1908
- const text = updateNotice(latest, ORK_VERSION);
1909
- process.stderr.write(`\n${styleText("yellow", text, { stream: process.stderr })}\n`);
1910
- writeUpdateCache(file, {
1911
- ...cache,
1912
- notified: {
1913
- version: latest,
1914
- at: now.toISOString()
1915
- }
1916
- });
1917
- } catch {}
1918
- };
1919
- // src/update/upgrade.ts
1920
- /**
1921
- * @file What `ork upgrade` does (ADR-038): compares the registry's latest
1922
- * version with this one, works out how Ork was installed, and gives the
1923
- * command to run (a global npm, pnpm, yarn, bun or Volta install) or advice
1924
- * (a clone, `npx`); runs the command when the user agrees. Also the version
1925
- * line of `ork doctor`, read from the cache without asking the network.
1926
- */
1927
- const REPOSITORY = "https://github.com/mimshins/ork";
1928
- /** The GitHub release page of a version, with its changes. */
1929
- const releaseNotes = (name, version) => `${REPOSITORY}/releases/tag/${encodeURIComponent(`${name}@${version}`)}`;
1930
- /** The running script's real path, which tells how Ork was installed. */
1931
- const runningScript = () => {
1932
- const script = process.argv[1] ?? "";
1933
- try {
1934
- return realpathSync(script);
1935
- } catch {
1936
- return script;
1937
- }
1938
- };
1939
- const planUpgrade = (options) => {
1940
- const { latest, name } = options;
1941
- if (latest === null) return { kind: "unreachable" };
1942
- if (!isNewer(latest, options.current)) return { kind: "latest" };
1943
- const spec = `${name}@${latest}`;
1944
- const installer = detectInstaller(options.script, options.installers);
1945
- if (installer?.command !== void 0) return {
1946
- kind: "command",
1947
- latest,
1948
- installer,
1949
- argv: installer.command(spec)
1950
- };
1951
- return {
1952
- kind: "advice",
1953
- latest,
1954
- text: installer?.advice?.(spec, options.script.replaceAll("\\", "/")) ?? `Ork could not tell how it was installed. With npm: npm install --global ${spec}`
1955
- };
1956
- };
1957
- /** Runs the installer's command on this terminal; resolves its exit code. */
1958
- const runInstall = (argv) => new Promise((resolve) => {
1959
- const [program = "", ...args] = argv;
1960
- const child = spawn(program, args, { stdio: "inherit" });
1961
- child.on("error", (error) => {
1962
- process.stderr.write(`Could not run ${program}: ${error.message}\n`);
1963
- resolve(127);
1964
- });
1965
- child.on("close", (code) => {
1966
- resolve(code ?? 1);
1967
- });
1968
- });
1969
- /** `ork doctor`'s line for Ork itself, from the last check. */
1970
- const versionCheck = (current, cache, checksOff) => {
1971
- const newer = newerVersion(cache, current);
1972
- if (newer !== null) return {
1973
- name: "ork",
1974
- status: "warn",
1975
- detail: `${current}; ${newer} is out`,
1976
- hint: "Run ork upgrade."
1977
- };
1978
- return {
1979
- name: "ork",
1980
- status: "ok",
1981
- detail: `${current} (${cache?.latest === void 0 ? checksOff ? "update check off" : "not checked for updates yet" : `the latest as of ${cache.checkedAt.slice(0, 10)}`})`
1982
- };
1983
- };
1984
- // src/doctor/checks.ts
1985
- /**
1986
- * @file The checks behind `ork doctor`: Ork's own version against the last
1987
- * update check, and each tool's presence and login status, through an
1988
- * injectable `exec` so tests need no real tools. Never prints a token or an
1989
- * account.
1990
- */
1991
- const TIMEOUT_MS = 15e3;
1992
- const exec = (program, args) => new Promise((resolve) => {
1993
- execFile(program, args, {
1994
- timeout: TIMEOUT_MS,
1995
- encoding: "utf8"
1996
- }, (error, stdout) => {
1997
- resolve({
1998
- ok: error === null,
1999
- stdout
2000
- });
2001
- });
2002
- });
2003
- /** Ork's own version, against the last update check (no network). */
2004
- const orkCheck = () => versionCheck(ORK_VERSION, readUpdateCache(updateCachePath()), updateCheckOff(process.env, [], true) !== null);
2005
- const MIN_NODE = [24, 20];
2006
- const checkNode = (version) => {
2007
- const [major = 0, minor = 0] = version.split(".").map(Number);
2008
- const [minMajor, minMinor] = MIN_NODE;
2009
- return major > minMajor || major === minMajor && minor >= minMinor ? {
2010
- name: "node",
2011
- status: "ok",
2012
- detail: `v${version}`
2013
- } : {
2014
- name: "node",
2015
- status: "fail",
2016
- detail: `v${version}`,
2017
- hint: `Ork needs Node ${MIN_NODE.join(".")} or later.`
2018
- };
2019
- };
2020
- const checkGit = async (run) => {
2021
- const version = await run("git", ["--version"]);
2022
- if (!version.ok) return [{
2023
- name: "git",
2024
- status: "fail",
2025
- detail: "not found",
2026
- hint: "Install git; Ork gives each writing node its own worktree."
2027
- }];
2028
- const inside = await run("git", ["rev-parse", "--is-inside-work-tree"]);
2029
- return [{
2030
- name: "git",
2031
- status: "ok",
2032
- detail: firstLine(version.stdout)
2033
- }, inside.ok ? {
2034
- name: "git repo",
2035
- status: "ok",
2036
- detail: "inside a work tree"
2037
- } : {
2038
- name: "git repo",
2039
- status: "warn",
2040
- detail: "not a git repository",
2041
- hint: "Run Ork inside a git repository so nodes can use worktrees."
2042
- }];
2043
- };
2044
- /**
2045
- * Runs every check: Node and git, then every registered Claude driver and
2046
- * integration (ADR-022). Node, git, and at least one driver are required;
2047
- * integrations are optional.
2048
- */
2049
- const runChecks = async (run, env, nodeVersion) => {
2050
- const [git, drivers, tools] = await Promise.all([
2051
- checkGit(run),
2052
- Promise.all(CLAUDE_DRIVERS.map((d) => d.doctor(run, env))),
2053
- Promise.all(INTEGRATIONS.flatMap((i) => i.doctor === void 0 ? [] : [i.doctor(run, env)]))
2054
- ]);
2055
- const results = [
2056
- checkNode(nodeVersion),
2057
- ...git,
2058
- ...drivers,
2059
- ...tools
2060
- ];
2061
- return {
2062
- results,
2063
- ok: drivers.some((d) => d.status === "ok") && !results.some((r) => r.status === "fail")
2064
- };
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();
2293
+ }
2294
+ const { exitCode } = await runWorkflowFile({
2295
+ file: found.run.file,
2296
+ cwd: found.run.project,
2297
+ inputs: flagValues(rawArgs, "--input"),
2298
+ inPlace: ready.inPlace,
2299
+ allowDirty: args["allow-dirty"],
2300
+ json: args.json,
2301
+ runtime: loadRuntime,
2302
+ store,
2303
+ ...human === void 0 ? {} : { human },
2304
+ runId: found.run.id,
2305
+ resume: ready.state,
2306
+ out: write,
2307
+ err: writeErr,
2308
+ ...ready.driver === void 0 ? {} : { driver: ready.driver },
2309
+ onRun: (handle) => {
2310
+ process.once("SIGINT", () => {
2311
+ writeErr(style("dim", "\ninterrupting…"));
2312
+ handle.interrupt();
2313
+ });
2314
+ }
2315
+ });
2316
+ process.exitCode = exitCode;
2317
+ } finally {
2318
+ human?.close();
2319
+ store.close();
2320
+ }
2097
2321
  }
2098
- };
2322
+ });
2099
2323
  // src/project/ork-skills.ts
2100
2324
  /**
2101
2325
  * @file The Claude Code plugin's skills (`plugins/ork/skills/`) as text, by
@@ -2106,6 +2330,7 @@ const triggerChecks = (home) => {
2106
2330
  const ORK_SKILLS = {
2107
2331
  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
2332
  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",
2333
+ setup: "---\nname: setup\ndescription: Use when the person asks you to set up Ork, install it, or get started with it in a repository - checking it's installed, setting up the project, making Ork's tools available to you, and a first workflow.\n---\n\n# Setting up Ork\n\nOrk runs workflows on the person's machine with their own accounts. Setting it\nup changes their machine and their repository, so say what each step does and\nask before it. Work through these in order, and stop where they say no.\n\n## 1. Check\n\n```sh\nork --version\nork doctor\n```\n\n- **No `ork`:** it needs Node 24.20 or later (`node --version`). The install is\n `npm install --global @krapht/ork`; show it and run it only when they say\n yes. The guide:\n https://github.com/mimshins/ork/blob/main/docs/getting-started/installation.md\n- **Read `ork doctor` to them.** It needs git, a git repository with a commit,\n and one Claude driver. Don't run `git init` or a commit without asking.\n- **A driver not ready:** they log in themselves, with `claude` (the CLI\n driver), or by setting `ANTHROPIC_API_KEY` or a cloud provider in their own\n shell (the SDK driver). Never ask for a key, never take one pasted to you, and\n never write one into a file or a command. `gh` and `aws` are optional, and\n their logins are their own too (`gh auth login`, `aws sso login`).\n\n## 2. The project\n\n- **`ork init`** keeps Ork's files in the repository, in `.ork/`, committed with\n the code. It adds `.ork/runs/` to `.gitignore`, links `.claude/` and similar\n folders to `.ork/`, and writes a starter workflow, `hello`.\n- **`ork register`** keeps them beside it, in `~/.ork/projects/<id>/`, and\n writes nothing into the repository. For a repository whose process isn't\n theirs to change, or to try Ork first.\n\nExplain both and ask which. Skip the question when the repository decides it: a\n`.ork/` folder means `ork init` has run (`ork projects` lists what's\nregistered). Neither overwrites a file.\n\n## 3. Make Ork available to you\n\nIf Ork's tools (`workflows`, `check`) already answer you, skip this. Otherwise,\nby where you run, and asking before each change to a config:\n\n- **Claude Code:** Ork's plugin brings the skills and the tools. The person\n types these themselves:\n\n ```\n /plugin marketplace add mimshins/ork\n /plugin install ork@ork\n ```\n\n Or, in a project set up with `ork init`, the skills in the repository for\n everyone who clones it, then the tools:\n\n ```sh\n ork init --skills\n claude mcp add ork -- ork mcp\n ```\n\n New tools start when they restart Claude Code or reconnect `ork` in `/mcp`;\n so does the plugin's, once `ork` is installed.\n\n- **Another MCP client:** add a server named `ork` with command `ork` and args\n `[\"mcp\"]` to its MCP config, where its own docs say. Its tools find the\n project by its id, from `ork projects`.\n- **A shell only:** use the CLI. `ork --help` and each command's `--help` say\n what it takes.\n\n## 4. A first workflow\n\nOffer to write one for a task they name. Follow `/ork:write`, check each draft\n(the `check` tool, or `ork validate <file>`) until it has no error, then save\nit. Don't start it: a run spends their money. Start it only when they say so,\nin the background with its inputs (`start_run`, or\n`ork run <name> --detach --input name=value`), and follow it with `/ork:run`.\n\n## 5. Show them `ork ui`\n\nTell them to run `ork ui` in their own terminal: their runs, graphs and logs in\nthe browser, and where they answer what a run asks.\n\n## Never\n\n- Ask for, store or pass on a password, key or token.\n- Answer a run's decision: they answer in `ork ui` or with `ork decide` in\n their own terminal.\n- Install, edit a config, or start a run without a yes.\n",
2109
2334
  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
2335
  };
2111
2336
  // src/project/init-skills.ts
@@ -2289,6 +2514,7 @@ const doctor = defineCommand({
2289
2514
  orkCheck(),
2290
2515
  ...results,
2291
2516
  ...place.mode === "attached" ? [] : projectChecks(place.root),
2517
+ socketCheck(),
2292
2518
  ...triggerChecks()
2293
2519
  ]) for (const line of formatCheck(result)) write(line);
2294
2520
  if (!ok) {
@@ -2301,7 +2527,7 @@ const doctor = defineCommand({
2301
2527
  const runningRuns = () => {
2302
2528
  const store = openStore();
2303
2529
  try {
2304
- return store.runs({ limit: 500 }).filter((run) => liveStatus(run) === "running").map((run) => run.id.slice(0, 8));
2530
+ return runningRunIds(store).map((id) => id.slice(0, 8));
2305
2531
  } finally {
2306
2532
  store.close();
2307
2533
  }
@@ -2352,7 +2578,8 @@ const upgrade = defineCommand({
2352
2578
  const registry = registryOf(process.env);
2353
2579
  const latest = await fetchLatest(ORK_NAME, {
2354
2580
  ...registry === void 0 ? {} : { registry },
2355
- timeoutMs: 15e3
2581
+ timeoutMs: 15e3,
2582
+ current: ORK_VERSION
2356
2583
  });
2357
2584
  const plan = planUpgrade({
2358
2585
  name: ORK_NAME,
@@ -2728,13 +2955,22 @@ const formatRegistered = (project, created) => {
2728
2955
  /**
2729
2956
  * @file Scaffolding a project: `.ork/workflows/` with a starter workflow and
2730
2957
  * the `.gitignore` entry for run state, never overwriting a file; then the
2731
- * tool folders the project already uses are linked to `.ork/`.
2958
+ * tool folders the project already uses are linked to `.ork/`. The starter
2959
+ * names no driver, as it is committed: a run takes the one ready on the
2960
+ * machine running it, and `ork init` says which that is here.
2732
2961
  */
2733
2962
  /** Run state that never belongs in git. */
2734
2963
  const GITIGNORE_ENTRIES = [".ork/runs/"];
2735
- const STARTER = {
2964
+ const STARTER_NAME = "hello";
2965
+ /** Where `ork init` writes the starter, relative to the project root. */
2966
+ const STARTER_FILE = path.join(WORKFLOW_DIR, `${STARTER_NAME}${WORKFLOW_SUFFIX}`);
2967
+ /**
2968
+ * The starter workflow. It names no driver: it is committed, and one person's
2969
+ * login doesn't belong in a shared file.
2970
+ */
2971
+ const starterWorkflow = () => ({
2736
2972
  schemaVersion: 1,
2737
- name: "hello",
2973
+ name: STARTER_NAME,
2738
2974
  description: "A leader plans the task, a worker does it, and you approve the result.",
2739
2975
  inputs: { task: {
2740
2976
  type: "string",
@@ -2742,7 +2978,6 @@ const STARTER = {
2742
2978
  description: "What to do."
2743
2979
  } },
2744
2980
  defaults: {
2745
- driver: "sdk",
2746
2981
  model: "sonnet",
2747
2982
  isolation: "worktree"
2748
2983
  },
@@ -2767,7 +3002,34 @@ const STARTER = {
2767
3002
  }
2768
3003
  },
2769
3004
  edges: ["lead -> approve"]
3005
+ });
3006
+ /**
3007
+ * What `ork init` says of drivers, in one line: the project's `defaults.driver`
3008
+ * when it names one, else the driver a run would take here, the one ready
3009
+ * (`cli` when both are), or how to get one ready.
3010
+ */
3011
+ const driverLine = (checks, projectDefault) => {
3012
+ if (projectDefault !== void 0) return `Runs use the ${projectDefault} driver, ${PROJECT_FILE}'s default, unless a workflow names another.`;
3013
+ const ready = readyDriver(checks);
3014
+ if (ready.ok) {
3015
+ const why = ready.ready === 1 ? ready.detail : `more than one is ready, and ${ready.driver} comes first`;
3016
+ return `Runs use the driver that's ready when they start: ${ready.driver} here (${why}).`;
3017
+ }
3018
+ return `Runs use the driver that's ready when they start, and none is ready here yet: ${checks.find(({ id }) => id === "cli")?.result.hint ?? "see `ork doctor`."}`;
2770
3019
  };
3020
+ /**
3021
+ * The line for the project at `root`: `ork doctor`'s driver checks through
3022
+ * `run` and `env`, and the project's own default.
3023
+ */
3024
+ const initDriverLine = async (run, env, root) => {
3025
+ const projectDefault = loadProject(root).project?.defaults?.driver;
3026
+ return driverLine(projectDefault === void 0 ? await driverChecks(run, env) : [], projectDefault);
3027
+ };
3028
+ /**
3029
+ * The line for a starter about to be written in `root`, with the real tools and
3030
+ * environment; `null` when the starter is already there.
3031
+ */
3032
+ const initDriverLineHere = (root) => existsSync(path.join(root, STARTER_FILE)) ? Promise.resolve(null) : initDriverLine(exec, process.env, root);
2771
3033
  /** The run-state entries `root`'s `.gitignore` lacks. */
2772
3034
  const unignored = (root) => {
2773
3035
  const gitignore = path.join(root, ".gitignore");
@@ -2794,11 +3056,10 @@ const initProject = (cwd, options = {}) => {
2794
3056
  const created = [];
2795
3057
  const skipped = [];
2796
3058
  mkdirSync(path.join(cwd, WORKFLOW_DIR), { recursive: true });
2797
- const starter = path.join(WORKFLOW_DIR, `${STARTER.name}${WORKFLOW_SUFFIX}`);
2798
- if (existsSync(path.join(cwd, starter))) skipped.push(starter);
3059
+ if (existsSync(path.join(cwd, STARTER_FILE))) skipped.push(STARTER_FILE);
2799
3060
  else {
2800
- writeFileSync(path.join(cwd, starter), serializeWorkflow(STARTER));
2801
- created.push(starter);
3061
+ writeFileSync(path.join(cwd, STARTER_FILE), serializeWorkflow(starterWorkflow()));
3062
+ created.push(STARTER_FILE);
2802
3063
  }
2803
3064
  const missing = ignoreRunState(cwd);
2804
3065
  if (missing.length > 0) created.push(`.gitignore (${missing.join(", ")})`);
@@ -2914,33 +3175,6 @@ const migrateHere = async (cwd, options) => {
2914
3175
  store.close();
2915
3176
  }
2916
3177
  };
2917
- // src/project/worktrees.ts
2918
- /** One line of state: where it is and what clean would care about. */
2919
- const worktreeState = (item) => {
2920
- const parts = [];
2921
- if (item.path === null) parts.push("no worktree");
2922
- else if (item.missing) parts.push("missing (prunable)");
2923
- if (item.dirty) parts.push(style("yellow", "uncommitted changes"));
2924
- const commits = item.merged ? style("green", "merged") : `${String(item.ahead)} commit(s) not merged`;
2925
- if (item.checkedOut !== void 0) parts.push(`on the workflow's branch ${item.checkedOut}`, commits);
2926
- else if (item.branch === void 0) parts.push(item.ahead > 0 ? style("yellow", `detached, ${String(item.ahead)} commit(s) on no branch`) : "detached, nothing committed");
2927
- else parts.push(commits);
2928
- if (item.inMainCheckout) parts.push("checked out here");
2929
- return parts.join(" · ");
2930
- };
2931
- /** A worktree's name: its branch, or its run and node when created detached. */
2932
- const labelOf = (item) => item.branch ?? `${item.runId}/${item.nodeId} (detached)`;
2933
- const formatWorktrees = (items) => {
2934
- if (items.length === 0) return ["No Ork worktrees or branches."];
2935
- const width = Math.max(...items.map((i) => labelOf(i).length));
2936
- return items.flatMap((item) => [`${style("bold", labelOf(item).padEnd(width))} ${worktreeState(item)}`, ...item.path === null ? [] : [` ${style("dim", item.path)}`]]);
2937
- };
2938
- const formatPlan = (steps) => steps.map((step) => {
2939
- const actions = [...step.removeWorktree ? ["remove worktree"] : [], ...step.deleteBranch ? ["delete branch"] : []];
2940
- const what = actions.length > 0 ? actions.join(" + ") : "keep";
2941
- const why = step.skip === void 0 ? "" : style("dim", ` (${step.skip})`);
2942
- return ` ${labelOf(step.item)}: ${what}${why}`;
2943
- });
2944
3178
  // src/commands/project-records.ts
2945
3179
  /**
2946
3180
  * @file `ork projects`' subcommands on one project's record (ADR-035): `forget`
@@ -3006,124 +3240,8 @@ const move = defineCommand({
3006
3240
  * follows its moved clone (ADR-035); none writes into a repository. `ork
3007
3241
  * migrate` moves an attached project into its repository, brings its agents
3008
3242
  * and skills under `.ork/`, or drafts a workflow from its agents (ADR-036),
3009
- * and commits nothing. `ork worktrees` lists the worktrees and branches runs
3010
- * left behind (those the runs recorded creating, whatever the project's git
3011
- * rules named them, and any `ork/` branch), with their state read from git,
3012
- * and cleans them up safely after showing the plan and asking.
3013
- */
3014
- const projectRoot = async () => (await inspectProject(process.cwd()))?.root ?? null;
3015
- /** What Ork created in the repo at `root`, from its runs' records and git. */
3016
- const itemsIn = async (root) => {
3017
- const store = openStore();
3018
- try {
3019
- return await listOrkWorktrees(root, createdWorktrees(store, root));
3020
- } finally {
3021
- store.close();
3022
- }
3023
- };
3024
- const notGit = () => {
3025
- process.stderr.write("Not in a git repository with a commit.\n");
3026
- process.exitCode = 1;
3027
- };
3028
- const show = async (json) => {
3029
- const root = await projectRoot();
3030
- if (root === null) {
3031
- notGit();
3032
- return;
3033
- }
3034
- const items = await itemsIn(root);
3035
- if (json) write(JSON.stringify(items, void 0, 2));
3036
- else for (const line of formatWorktrees(items)) write(line);
3037
- };
3038
- const list = defineCommand({
3039
- meta: {
3040
- name: "list",
3041
- description: "List Ork's worktrees and branches (the default)"
3042
- },
3043
- args: { json: {
3044
- type: "boolean",
3045
- description: "Print JSON",
3046
- default: false
3047
- } },
3048
- run: ({ args }) => show(args.json)
3049
- });
3050
- const clean = defineCommand({
3051
- meta: {
3052
- name: "clean",
3053
- description: "Remove Ork's worktrees (merged ones by default); never one with uncommitted changes unless --force"
3054
- },
3055
- args: {
3056
- run: {
3057
- type: "string",
3058
- description: "Only this run's worktrees"
3059
- },
3060
- all: {
3061
- type: "boolean",
3062
- description: "Include worktrees whose branch is not merged",
3063
- default: false
3064
- },
3065
- branches: {
3066
- type: "boolean",
3067
- description: "Also delete the branches",
3068
- default: false
3069
- },
3070
- force: {
3071
- type: "boolean",
3072
- description: "Remove uncommitted changes and unmerged branches too",
3073
- default: false
3074
- },
3075
- yes: {
3076
- type: "boolean",
3077
- description: "Do not ask",
3078
- default: false
3079
- }
3080
- },
3081
- run: async ({ args }) => {
3082
- const root = await projectRoot();
3083
- if (root === null) {
3084
- notGit();
3085
- return;
3086
- }
3087
- const filter = { merged: !args.all };
3088
- if (args.run !== void 0) filter.runId = args.run;
3089
- const steps = planClean(await itemsIn(root), filter, {
3090
- branches: args.branches,
3091
- force: args.force
3092
- });
3093
- const acting = steps.filter((s) => s.removeWorktree || s.deleteBranch);
3094
- if (steps.length === 0 || acting.length === 0) {
3095
- for (const line of formatPlan(steps)) write(line);
3096
- write("Nothing to clean.");
3097
- return;
3098
- }
3099
- write("Plan:");
3100
- for (const line of formatPlan(steps)) write(line);
3101
- if (!args.yes) {
3102
- if (!process.stdin.isTTY) {
3103
- process.stderr.write("Pass --yes to clean without a terminal.\n");
3104
- process.exitCode = 1;
3105
- return;
3106
- }
3107
- if (!await confirm("Go ahead? [y/N] ")) return;
3108
- }
3109
- for (const line of await applyClean(root, acting, args.force)) write(`${style("green", "✓")} ${line}`);
3110
- }
3111
- });
3112
- const worktrees = defineCommand({
3113
- meta: {
3114
- name: "worktrees",
3115
- description: "Track and clean up the git worktrees Ork creates for runs"
3116
- },
3117
- subCommands: {
3118
- list,
3119
- clean
3120
- },
3121
- run: async ({ rawArgs }) => {
3122
- const [first] = rawArgs;
3123
- if (first === "list" || first === "clean") return;
3124
- await show(rawArgs.includes("--json"));
3125
- }
3126
- });
3243
+ * and commits nothing. `ork worktrees` is worktrees.ts.
3244
+ */
3127
3245
  const json$2 = {
3128
3246
  type: "boolean",
3129
3247
  description: "Print JSON",
@@ -3220,6 +3338,20 @@ const migrate = defineCommand({
3220
3338
  }, (file) => formatReport(validateFile(file, process.cwd())));
3221
3339
  }
3222
3340
  });
3341
+ // src/store.ts
3342
+ /**
3343
+ * @file Opening the user's store (`~/.ork/state.db`) for the length of one
3344
+ * command, and closing it after, whatever happens.
3345
+ */
3346
+ /** Runs `body` with the user's store open, and closes it after. */
3347
+ const withStore = async (body) => {
3348
+ const store = openStore();
3349
+ try {
3350
+ await body(store);
3351
+ } finally {
3352
+ store.close();
3353
+ }
3354
+ };
3223
3355
  // src/runs/feedback.ts
3224
3356
  /**
3225
3357
  * @file `ork feedback`'s helpers (specs/feedback.md): finding an item by a
@@ -3269,6 +3401,7 @@ const formatFeedbackItem = (item, records, now = Date.now()) => {
3269
3401
  ];
3270
3402
  if (item.workflows.length > 0) lines.push(`Workflows: ${item.workflows.join(", ")}`);
3271
3403
  if (item.models.length > 0) lines.push(`Models: ${item.models.join(", ")}`);
3404
+ if (item.merged.length > 0) lines.push(`Merged into it: ${item.merged.map((id) => id.slice(0, 12)).join(", ")} (ork feedback split ${item.id.slice(0, 12)} takes them apart)`);
3272
3405
  for (const record of records) {
3273
3406
  const { feedback } = record;
3274
3407
  const where = record.node === void 0 ? "" : ` · ${record.node}`;
@@ -3279,181 +3412,545 @@ const formatFeedbackItem = (item, records, now = Date.now()) => {
3279
3412
  }
3280
3413
  return lines;
3281
3414
  };
3282
- const oneOf = (name, value, allowed) => {
3283
- if (value === void 0) return { ok: true };
3284
- const found = allowed.find((a) => a === value);
3285
- return found === void 0 ? {
3286
- ok: false,
3287
- error: `--${name} is one of ${allowed.join(", ")}`
3288
- } : {
3289
- ok: true,
3290
- value: found
3291
- };
3292
- };
3293
3415
  /**
3294
3416
  * `ork feedback note`: records a person's note on a run, or one of its nodes,
3295
3417
  * as that run's feedback; returns the item it joins, or why it could not.
3296
3418
  */
3297
3419
  const noteRun = (store, cwd, args) => {
3298
- if (args.text.trim() === "") return {
3420
+ const found = findRun(store, args.run, cwd);
3421
+ if (!found.ok) return found;
3422
+ return recordNote(store, found.run, args, "--");
3423
+ };
3424
+ // src/runs/apply.ts
3425
+ /**
3426
+ * @file `ork feedback apply` (ADR-059): runs Ork's own `ork:feedback-apply` in
3427
+ * the project an item came from, with the item as its untrusted input. Its
3428
+ * worker proposes a fix on a branch and its gate asks to keep or discard it;
3429
+ * Ork never merges. Kept, the item is `resolved`, unless its branch holds no
3430
+ * new commits: then there is nothing to keep, and, as when discarded, the
3431
+ * run's worktree and branch go and the item stays `accepted`.
3432
+ */
3433
+ /** The most of an item's records the worker reads, newest first. */
3434
+ const RECORDS = 10;
3435
+ /** The most of a record's body the worker reads. */
3436
+ const BODY_CHARS = 1500;
3437
+ const BUILTIN = "ork:feedback-apply";
3438
+ /** An item and its newest records, as the worker reads them. */
3439
+ const aboutItem = (item, records) => {
3440
+ const newest = records.toReversed().slice(0, RECORDS);
3441
+ const lines = [`Item ${item.id.slice(0, 12)}: ${item.title}`, `About: ${item.target} (${item.kind}), severity ${item.severity}; seen ${String(item.count)} time(s) in ${String(item.runs.length)} run(s).`];
3442
+ if (item.workflows.length > 0) lines.push(`Workflows: ${item.workflows.join(", ")}`);
3443
+ for (const record of newest) {
3444
+ const { feedback } = record;
3445
+ const where = record.node === void 0 ? "" : `, node ${record.node}`;
3446
+ lines.push("", `Record from run ${record.runId}${where} (${feedback.source}):`);
3447
+ if (feedback.title !== item.title) lines.push(feedback.title);
3448
+ if (feedback.body !== "") lines.push(feedback.body.slice(0, BODY_CHARS));
3449
+ if (feedback.evidence.length > 0) lines.push(`Evidence: ${feedback.evidence.join(", ")}`);
3450
+ }
3451
+ if (records.length > newest.length) lines.push("", `(${String(records.length - newest.length)} older record(s) left out.)`);
3452
+ return lines.join("\n");
3453
+ };
3454
+ const text = (value) => typeof value === "string" ? value : "";
3455
+ /** What a run of `ork:feedback-apply` ended with. */
3456
+ const outcomeOf = (result) => {
3457
+ const node = (id) => result?.nodes.find((n) => n.id === id);
3458
+ const fix = node("fix");
3459
+ const choice = text(node("keep")?.output?.["choice"]);
3460
+ const branch = text(fix?.output?.["branch"]);
3461
+ const summary = text(fix?.output?.["summary"]);
3462
+ if (choice === "keep") return {
3463
+ kind: "kept",
3464
+ branch,
3465
+ summary
3466
+ };
3467
+ if (choice === "discard") return {
3468
+ kind: "discarded",
3469
+ branch
3470
+ };
3471
+ if (fix?.ok === false && result?.status === "failed") return {
3472
+ kind: "nothing",
3473
+ summary: summary || (fix.error ?? "")
3474
+ };
3475
+ return {
3476
+ kind: "unfinished",
3477
+ reasons: result?.reasons ?? []
3478
+ };
3479
+ };
3480
+ /**
3481
+ * Removes what a run left in the repository at `root`: its worktrees and
3482
+ * branches.
3483
+ */
3484
+ const discardRun = async (store, root, runId) => {
3485
+ const items = await listOrkWorktrees(root, createdWorktrees(store, root));
3486
+ const steps = planClean(items, {
3487
+ merged: false,
3488
+ runId
3489
+ }, {
3490
+ branches: true,
3491
+ force: true
3492
+ });
3493
+ return applyClean(root, steps.filter((s) => s.removeWorktree || s.deleteBranch), true);
3494
+ };
3495
+ /** How many commits the run's worktrees hold that the current branch hasn't. */
3496
+ const commitsAhead = async (store, root, runId) => (await listOrkWorktrees(root, createdWorktrees(store, root))).filter((item) => item.runId === runId).reduce((sum, item) => sum + item.ahead, 0);
3497
+ /** What to say, and the item's status, after the run ends as `outcome`. */
3498
+ const settle = async (options, root, runId, outcome) => {
3499
+ const { store, item } = options;
3500
+ const short = item.id.slice(0, 12);
3501
+ switch (outcome.kind) {
3502
+ case "kept":
3503
+ if (await (options.ahead ?? commitsAhead)(store, root, runId) === 0) {
3504
+ const done = await discardRun(store, root, runId);
3505
+ return [`the fix's branch holds no commits, so there's nothing to keep; ${short} stays accepted`, ...done];
3506
+ }
3507
+ store.setFeedbackStatus(item.id, "resolved");
3508
+ return [`kept the fix on ${outcome.branch}; ${short} is resolved`, "Review and merge it like any other change; Ork never merges it."];
3509
+ case "discarded": {
3510
+ const done = await discardRun(store, root, runId);
3511
+ return [`discarded the fix; ${short} stays accepted`, ...done];
3512
+ }
3513
+ case "nothing": return [`no fix proposed; ${short} stays accepted`, ...outcome.summary === "" ? [] : [outcome.summary]];
3514
+ case "unfinished": return [`run ${runId} didn't finish, so ${short} stays as it was`, ...outcome.reasons];
3515
+ default: return outcome;
3516
+ }
3517
+ };
3518
+ /** Proposes a fix for an item, in the project it came from. */
3519
+ const applyItem = async (options) => {
3520
+ const { store, item } = options;
3521
+ const short = item.id.slice(0, 12);
3522
+ const allowed = feedbackActions(item, openFixRun(store, item)).apply;
3523
+ if (!allowed.ok) return {
3299
3524
  ok: false,
3300
- error: "The note is empty."
3525
+ error: allowed.error
3301
3526
  };
3302
- const found = findRun(store, args.run, cwd);
3527
+ const found = placeOf(store, item, void 0, options.registered);
3303
3528
  if (!found.ok) return found;
3304
- const { run } = found;
3305
- const ran = store.events(run.id).some(({ event }) => "id" in event && event.id === args.node);
3306
- if (args.node !== void 0 && !ran) return {
3529
+ const { place, project } = found;
3530
+ const problem = builtinProblem(place, BUILTIN);
3531
+ const file = builtinWorkflowFile(place, BUILTIN);
3532
+ if (problem !== void 0 || file === void 0) return {
3307
3533
  ok: false,
3308
- error: `No node ${args.node} ran in run ${run.id}.`
3309
- };
3310
- const kind = oneOf("kind", args.kind, FEEDBACK_KINDS);
3311
- const severity = oneOf("severity", args.severity, FEEDBACK_SEVERITIES);
3312
- const rating = oneOf("rating", args.rating, RATINGS);
3313
- for (const checked of [
3314
- kind,
3315
- severity,
3316
- rating
3317
- ]) if (!checked.ok) return checked;
3318
- const event = humanNoteEvent({
3319
- text: args.text,
3320
- workflow: run.workflow,
3321
- ...args.node === void 0 ? {} : { node: args.node },
3322
- ...kind.ok && kind.value !== void 0 ? { kind: kind.value } : {},
3323
- ...severity.ok && severity.value !== void 0 ? { severity: severity.value } : {},
3324
- ...rating.ok && rating.value !== void 0 ? { rating: rating.value } : {}
3534
+ error: problem ?? `${BUILTIN} couldn't be written.`
3535
+ };
3536
+ const records = store.feedbackRecords(item.id).filter((r) => store.lookup(r.runId)[0]?.project === project);
3537
+ options.out(`▸ proposing a fix for ${short} in ${place.root}`);
3538
+ const { result } = await runWorkflowFile({
3539
+ ...options,
3540
+ file,
3541
+ cwd: place.root,
3542
+ inputs: [`item=${short}`, `about=${aboutItem(item, records)}`],
3543
+ untrusted: ["about"],
3544
+ inPlace: false,
3545
+ json: false,
3546
+ onRun: (handle) => {
3547
+ store.setFeedbackStatus(item.id, "accepted");
3548
+ options.onRun?.(handle);
3549
+ }
3325
3550
  });
3326
- store.append(run.id, event);
3551
+ if (result === null) return {
3552
+ ok: false,
3553
+ error: `${BUILTIN} didn't start.`
3554
+ };
3555
+ const outcome = outcomeOf(result);
3327
3556
  return {
3328
3557
  ok: true,
3329
- item: event.type === "feedback.recorded" ? event.feedback.fingerprint : ""
3558
+ outcome,
3559
+ lines: await settle(options, place.root, result.runId, outcome)
3330
3560
  };
3331
3561
  };
3332
- // src/commands/runs.ts
3562
+ // src/runs/triage.ts
3333
3563
  /**
3334
- * @file `ork ps`, `ork logs`, `ork tree` and `ork feedback`: read what every
3335
- * project's runs recorded in `~/.ork/state.db` (specs/event-store.md,
3336
- * specs/feedback.md).
3564
+ * @file `ork feedback triage` (ADR-059): the merges Ork's rules propose among
3565
+ * unsettled items, each made only when the person says yes, then every new
3566
+ * item marked `triaged`, so a declined merge isn't asked again until a new
3567
+ * item joins it. `ork feedback split` takes a merge apart.
3337
3568
  */
3338
- /** Runs `body` with the user's store open, and closes it after. */
3339
- const withStore = async (body) => {
3340
- const store = openStore();
3341
- try {
3342
- await body(store);
3343
- } finally {
3344
- store.close();
3345
- }
3569
+ const lineOf = (item) => `${item.id.slice(0, 12)} ${`${String(item.count)}×`.padStart(4)} ${item.title}`;
3570
+ /** A proposed merge as the person reads it before saying yes. */
3571
+ const formatMerge = (merge) => [`Merge into ${lineOf(merge.into)}`, ...merge.from.map((item) => ` ${lineOf(item)}`)];
3572
+ /**
3573
+ * Proposes each merge, makes those `ask` says yes to, and marks every item that
3574
+ * was new `triaged`.
3575
+ */
3576
+ const triage$1 = async (store, ask) => {
3577
+ const items = store.feedbackItems();
3578
+ const fresh = items.filter((item) => item.status === "new");
3579
+ let merged = 0;
3580
+ let declined = 0;
3581
+ for (const merge of proposeMerges(items)) if (await ask(merge)) merged += store.mergeFeedback(merge.into.id, merge.from.map((item) => item.id));
3582
+ else declined += 1;
3583
+ for (const item of fresh) store.setFeedbackStatus(item.id, "triaged");
3584
+ return {
3585
+ merged,
3586
+ declined,
3587
+ triaged: fresh.length
3588
+ };
3346
3589
  };
3347
- const ps = defineCommand({
3590
+ /** What triage did, in a line. */
3591
+ const triageSummary = ({ merged, declined, triaged }) => {
3592
+ return [
3593
+ merged === 0 ? "nothing merged" : `merged ${String(merged)} item(s)`,
3594
+ ...declined === 0 ? [] : [`${String(declined)} merge(s) declined`],
3595
+ `${String(triaged)} new item(s) triaged`
3596
+ ].join("; ");
3597
+ };
3598
+ // src/commands/feedback-loop.ts
3599
+ /**
3600
+ * @file The `ork feedback` subcommands that close the loop (ADR-059): `apply`
3601
+ * runs Ork's own workflow to propose a fix for an item, `triage` merges items
3602
+ * about the same problem by rules after asking, and `split` takes a merge
3603
+ * apart.
3604
+ */
3605
+ const itemArg$1 = {
3606
+ type: "positional",
3607
+ description: "A feedback item's id, or a prefix of one",
3608
+ required: true
3609
+ };
3610
+ const apply = defineCommand({
3348
3611
  meta: {
3349
- name: "ps",
3350
- description: "List this project's runs, newest first"
3612
+ name: "apply",
3613
+ description: "Have Ork propose a fix for an item on a branch, then keep or discard it"
3351
3614
  },
3352
3615
  args: {
3353
- all: {
3354
- type: "boolean",
3355
- description: "Every project's runs",
3356
- default: false
3357
- },
3358
- limit: {
3616
+ item: itemArg$1,
3617
+ driver: {
3359
3618
  type: "string",
3360
- description: "How many (default 20)"
3619
+ description: "sdk (API key or cloud provider) or cli (your claude login)"
3361
3620
  },
3362
- json: {
3363
- type: "boolean",
3364
- description: "Print JSON",
3365
- default: false
3621
+ "run-id": {
3622
+ type: "string",
3623
+ description: "Internal: the id `ork ui` picked for the run"
3624
+ }
3625
+ },
3626
+ run: async ({ args }) => {
3627
+ if (args.driver !== void 0 && !isDriver(args.driver)) {
3628
+ writeErr(`--driver must be sdk or cli, got \`${args.driver}\``);
3629
+ process.exitCode = 1;
3630
+ return;
3631
+ }
3632
+ const driver = args.driver;
3633
+ const store = openStore();
3634
+ const human = terminalHuman();
3635
+ try {
3636
+ const found = findFeedbackItem(store, args.item);
3637
+ if (!found.ok) {
3638
+ writeErr(found.error);
3639
+ process.exitCode = 1;
3640
+ return;
3641
+ }
3642
+ const applied = await applyItem({
3643
+ store,
3644
+ item: found.item,
3645
+ registered: registeredProjects(),
3646
+ runtime: loadRuntime,
3647
+ ...human === void 0 ? {} : { human },
3648
+ ...driver === void 0 ? {} : { driver },
3649
+ ...args["run-id"] === void 0 ? {} : { runId: args["run-id"] },
3650
+ out: write,
3651
+ err: writeErr,
3652
+ onRun: (handle) => {
3653
+ onInterrupt(handle.interrupt);
3654
+ }
3655
+ });
3656
+ if (!applied.ok) {
3657
+ writeErr(applied.error);
3658
+ process.exitCode = 1;
3659
+ return;
3660
+ }
3661
+ const [first, ...rest] = applied.lines;
3662
+ const mark = applied.outcome.kind === "kept" ? style("green", "✓") : style("yellow", "·");
3663
+ write(`${mark} ${first ?? ""}`);
3664
+ for (const line of rest) write(` ${line}`);
3665
+ if (applied.outcome.kind === "unfinished") process.exitCode = 1;
3666
+ } finally {
3667
+ human?.close();
3668
+ store.close();
3669
+ }
3670
+ }
3671
+ });
3672
+ const triage = defineCommand({
3673
+ meta: {
3674
+ name: "triage",
3675
+ description: "Merge new items about the same problem as others, asking about each"
3676
+ },
3677
+ args: { yes: {
3678
+ type: "boolean",
3679
+ description: "Make every merge without asking",
3680
+ default: false
3681
+ } },
3682
+ run: ({ args }) => withStore(async (store) => {
3683
+ if (!args.yes && !process.stdin.isTTY) {
3684
+ writeErr("Pass --yes to triage without a terminal.");
3685
+ process.exitCode = 1;
3686
+ return;
3366
3687
  }
3688
+ const done = await triage$1(store, async (merge) => {
3689
+ for (const line of formatMerge(merge)) write(line);
3690
+ return args.yes || await confirm("Merge them? [y/N] ");
3691
+ });
3692
+ write(`${style("green", "✓")} ${triageSummary(done)}`);
3693
+ })
3694
+ });
3695
+ const split = defineCommand({
3696
+ meta: {
3697
+ name: "split",
3698
+ description: "Take apart the items triage merged into one"
3367
3699
  },
3700
+ args: { item: itemArg$1 },
3368
3701
  run: ({ args }) => withStore((store) => {
3369
- const limit = Number(args.limit ?? 20);
3370
- const runs = store.runs({
3371
- limit: Number.isInteger(limit) && limit > 0 ? limit : 20,
3372
- ...args.all ? {} : { project: placeFor(process.cwd(), store.projects()).root }
3702
+ const found = findFeedbackItem(store, args.item);
3703
+ if (!found.ok) {
3704
+ writeErr(found.error);
3705
+ process.exitCode = 1;
3706
+ return;
3707
+ }
3708
+ const moved = store.splitFeedback(found.item.id);
3709
+ write(moved === 0 ? `Nothing was merged into ${found.item.id.slice(0, 12)}.` : `${style("green", "✓")} split ${String(moved)} item(s) back out of ${found.item.id.slice(0, 12)}`);
3710
+ })
3711
+ });
3712
+ // src/runs/jsonl.ts
3713
+ /**
3714
+ * @file `ork feedback export` and `import` (specs/feedback.md): feedback
3715
+ * records as JSON lines, one record a line, so a team can move feedback
3716
+ * between machines without GitHub. A line is checked before it's added (one
3717
+ * from a newer Ork says so), and a record already here (its run and event) is
3718
+ * skipped.
3719
+ */
3720
+ const Text = z.string();
3721
+ const LineSchema = z.strictObject({
3722
+ feedbackVersion: z.literal(1),
3723
+ runId: z.string().min(1),
3724
+ seq: z.number().int().nonnegative(),
3725
+ at: z.string().min(1),
3726
+ node: z.string().min(1).optional(),
3727
+ feedback: z.strictObject({
3728
+ source: z.enum([
3729
+ "system",
3730
+ "agent",
3731
+ "human"
3732
+ ]),
3733
+ kind: z.enum(FEEDBACK_KINDS),
3734
+ target: z.enum(FEEDBACK_TARGETS),
3735
+ severity: z.enum(FEEDBACK_SEVERITIES),
3736
+ title: Text.min(1),
3737
+ body: Text,
3738
+ evidence: z.array(Text),
3739
+ code: Text.optional(),
3740
+ fingerprint: z.string().regex(/^[0-9a-f]{8,64}$/u),
3741
+ origin: z.strictObject({
3742
+ workflow: z.string().regex(/^[a-z0-9][a-z0-9._-]*$/u).optional(),
3743
+ workflowHash: Text.optional(),
3744
+ nodeType: Text.optional(),
3745
+ model: Text.optional(),
3746
+ driver: Text.optional(),
3747
+ version: Text.optional()
3748
+ })
3749
+ })
3750
+ });
3751
+ /** Records as JSON lines, with the format's version on each. */
3752
+ const exportLines = (records) => records.map(({ id: _id, ...record }) => JSON.stringify({
3753
+ feedbackVersion: 1,
3754
+ ...record
3755
+ })).map((line) => `${line}\n`).join("");
3756
+ /** Why a line from a newer Ork is left out, or `null` when it isn't one. */
3757
+ const newerVersion = (value) => {
3758
+ const version = value !== null && typeof value === "object" ? Reflect.get(value, "feedbackVersion") : void 0;
3759
+ return typeof version === "number" && version > 1 ? `made by a newer Ork (\`feedbackVersion: ${String(version)}\`; this one reads ${String(1)}). Upgrade with \`ork upgrade\`.` : null;
3760
+ };
3761
+ /** The records in a JSONL file's text, and what was wrong with the rest. */
3762
+ const parseLines = (text) => {
3763
+ const records = [];
3764
+ const problems = [];
3765
+ for (const [index, line] of text.split("\n").entries()) {
3766
+ if (line.trim() === "") continue;
3767
+ let value;
3768
+ try {
3769
+ value = JSON.parse(line);
3770
+ } catch {
3771
+ problems.push(`line ${String(index + 1)}: not JSON`);
3772
+ continue;
3773
+ }
3774
+ const newer = newerVersion(value);
3775
+ if (newer !== null) {
3776
+ problems.push(`line ${String(index + 1)}: ${newer}`);
3777
+ continue;
3778
+ }
3779
+ const parsed = LineSchema.safeParse(value);
3780
+ if (!parsed.success) {
3781
+ const [issue] = parsed.error.issues;
3782
+ problems.push(`line ${String(index + 1)}: ${issue?.path.join(".") ?? ""} ${issue?.message ?? "isn't a feedback record"}`.trim());
3783
+ continue;
3784
+ }
3785
+ const { feedbackVersion: _version, node, feedback, ...rest } = parsed.data;
3786
+ const { code, origin, ...report } = feedback;
3787
+ records.push({
3788
+ ...rest,
3789
+ ...node === void 0 ? {} : { node },
3790
+ feedback: {
3791
+ ...report,
3792
+ ...code === void 0 ? {} : { code },
3793
+ origin: Object.fromEntries(Object.entries(origin).filter(([, v]) => v !== void 0))
3794
+ }
3373
3795
  });
3374
- const statusOf = (run) => runStatus(run, run.status === "running" ? store.events(run.id).map((s) => s.event) : []);
3375
- if (args.json) write(JSON.stringify(runs, void 0, 2));
3376
- else for (const line of formatRuns(runs, args.all, Date.now(), statusOf)) write(line);
3377
- })
3378
- });
3379
- const runArg = {
3380
- type: "positional",
3381
- description: "A run id or a prefix of one (default: this project's newest)",
3382
- required: false
3796
+ }
3797
+ return {
3798
+ records,
3799
+ problems
3800
+ };
3383
3801
  };
3384
- const logs = defineCommand({
3802
+ // src/runs/publish.ts
3803
+ /** What will be sent, as the person reads it before saying yes. */
3804
+ const formatPreview = (post, plan) => plan.kind === "comment" ? [
3805
+ `A thread for this exists (${plan.url}), so this comment goes on it:`,
3806
+ "",
3807
+ post.comment
3808
+ ] : [
3809
+ "A new thread in the Agent feedback category of mimshins/ork:",
3810
+ "",
3811
+ `Title: ${post.title}`,
3812
+ "",
3813
+ post.body
3814
+ ];
3815
+ // src/commands/feedback-share.ts
3816
+ /**
3817
+ * @file The `ork feedback` subcommands that move feedback off this machine,
3818
+ * only when the person says so (ADR-059): `publish` shares an item about Ork
3819
+ * with its maintainers, through the person's own `gh`, after showing exactly
3820
+ * what will be posted; `export` and `import` move records as JSON lines.
3821
+ */
3822
+ const failed = (message) => {
3823
+ writeErr(message);
3824
+ process.exitCode = 1;
3825
+ };
3826
+ const publish = defineCommand({
3385
3827
  meta: {
3386
- name: "logs",
3387
- description: "Print a run's transcript"
3828
+ name: "publish",
3829
+ description: "Share an item about Ork with its maintainers, after showing exactly what goes"
3388
3830
  },
3389
3831
  args: {
3390
- run: runArg,
3391
- node: {
3392
- type: "string",
3393
- description: "Only this node's events"
3394
- },
3395
- follow: {
3396
- type: "boolean",
3397
- alias: "f",
3398
- description: "Keep printing a live run's events until it ends",
3399
- default: false
3832
+ item: {
3833
+ type: "positional",
3834
+ description: "A feedback item's id, or a prefix of one",
3835
+ required: true
3400
3836
  },
3401
- json: {
3837
+ "hide-names": {
3402
3838
  type: "boolean",
3403
- description: "Print the stored events as JSON lines",
3839
+ description: "Also hide the repositories' and workflows' names",
3404
3840
  default: false
3405
3841
  }
3406
3842
  },
3407
3843
  run: ({ args }) => withStore(async (store) => {
3408
- const found = findRun(store, args.run, process.cwd());
3844
+ if (!process.stdin.isTTY) {
3845
+ failed("publish shows what it will post and asks you first: run it in a terminal.");
3846
+ return;
3847
+ }
3848
+ const found = findFeedbackItem(store, args.item);
3409
3849
  if (!found.ok) {
3410
- writeErr(found.error);
3411
- process.exitCode = 1;
3850
+ failed(found.error);
3412
3851
  return;
3413
3852
  }
3414
- const abort = new AbortController();
3415
- process.once("SIGINT", () => {
3416
- abort.abort();
3417
- });
3418
- await followRun(store, found.run, write, {
3419
- follow: args.follow,
3420
- json: args.json,
3421
- signal: abort.signal,
3422
- ...args.node === void 0 ? {} : { node: args.node }
3853
+ const preview = previewPost(store, found.item, {
3854
+ home: homedir(),
3855
+ registered: registeredProjects(),
3856
+ hideNames: args["hide-names"]
3423
3857
  });
3858
+ if (!preview.ok) {
3859
+ failed(preview.error);
3860
+ return;
3861
+ }
3862
+ const planned = await planPost(preview.post);
3863
+ if (!planned.ok) {
3864
+ failed(planned.error);
3865
+ return;
3866
+ }
3867
+ for (const line of formatPreview(preview.post, planned.plan)) write(line);
3868
+ write("");
3869
+ if (!await confirm("Post it with your own gh login? [y/N] ")) {
3870
+ write("Nothing was sent.");
3871
+ return;
3872
+ }
3873
+ const sent = await sendPost(preview.post, planned.plan);
3874
+ if (!sent.ok) {
3875
+ failed(sent.error);
3876
+ return;
3877
+ }
3878
+ store.setFeedbackStatus(found.item.id, "published");
3879
+ write(`${style("green", "✓")} ${sent.kind === "created" ? "posted" : "added a +1 to"} ${sent.url}`);
3424
3880
  })
3425
3881
  });
3426
- const tree = defineCommand({
3882
+ const exportFeedback = defineCommand({
3427
3883
  meta: {
3428
- name: "tree",
3429
- description: "Show a run's ownership tree with status, tokens and cost"
3884
+ name: "export",
3885
+ description: "Write feedback records as JSON lines, to share them"
3430
3886
  },
3431
3887
  args: {
3432
- run: runArg,
3433
- json: {
3888
+ all: {
3434
3889
  type: "boolean",
3435
- description: "Print JSON",
3890
+ description: "Dismissed items' records too",
3436
3891
  default: false
3892
+ },
3893
+ output: {
3894
+ type: "string",
3895
+ alias: "o",
3896
+ description: "A file to write (default: the terminal)"
3437
3897
  }
3438
3898
  },
3439
3899
  run: ({ args }) => withStore((store) => {
3440
- const found = findRun(store, args.run, process.cwd());
3441
- if (!found.ok) {
3442
- writeErr(found.error);
3443
- process.exitCode = 1;
3900
+ const records = store.exportFeedback({ all: args.all });
3901
+ const text = exportLines(records);
3902
+ if (args.output === void 0) {
3903
+ process.stdout.write(text);
3444
3904
  return;
3445
3905
  }
3446
- const { run } = found;
3447
- const events = store.events(run.id).map((s) => s.event);
3448
- const roots = runTree(events);
3449
- if (args.json) {
3450
- write(JSON.stringify(roots, void 0, 2));
3906
+ writeFileSync(args.output, text);
3907
+ write(`${style("green", "✓")} wrote ${String(records.length)} record(s) to ${args.output}`);
3908
+ })
3909
+ });
3910
+ const importFeedback = defineCommand({
3911
+ meta: {
3912
+ name: "import",
3913
+ description: "Add feedback records from a JSON-lines file"
3914
+ },
3915
+ args: { file: {
3916
+ type: "positional",
3917
+ description: "What `ork feedback export` wrote",
3918
+ required: true
3919
+ } },
3920
+ run: ({ args }) => withStore((store) => {
3921
+ let text;
3922
+ try {
3923
+ text = readFileSync(args.file, "utf8");
3924
+ } catch {
3925
+ failed(`Can't read ${args.file}.`);
3451
3926
  return;
3452
3927
  }
3453
- write(`${style("bold", `run ${run.id}`)} ${style("dim", `of ${run.workflow} · ${runStatus(run, events)} · ${cost(run.costUsd)}`)}`);
3454
- for (const line of formatTree(roots)) write(line);
3928
+ const { records, problems } = parseLines(text);
3929
+ const added = records.filter((r) => store.importFeedback(r)).length;
3930
+ for (const problem of problems) writeErr(problem);
3931
+ write(`${style("green", "✓")} added ${String(added)} record(s); ${String(records.length - added)} were here already${problems.length === 0 ? "" : `; ${String(problems.length)} line(s) left out`}`);
3932
+ if (problems.length > 0) process.exitCode = 1;
3455
3933
  })
3456
3934
  });
3935
+ // src/commands/feedback.ts
3936
+ /**
3937
+ * @file `ork feedback`: the feedback every run recorded, grouped into items
3938
+ * (specs/feedback.md): list and show them, note a run, dismiss and restore an
3939
+ * item, and accept one, as a lesson its workflow reads from then on. What
3940
+ * closes the loop further (apply, triage, split) is in `feedback-loop.ts`,
3941
+ * and what moves feedback off the machine (publish, export, import) in
3942
+ * `feedback-share.ts`.
3943
+ */
3944
+ /** Runs `body` on the item `prefix` names, or says why there's none. */
3945
+ const withItem = (prefix, body) => withStore((store) => {
3946
+ const found = findFeedbackItem(store, prefix);
3947
+ if (!found.ok) {
3948
+ writeErr(found.error);
3949
+ process.exitCode = 1;
3950
+ return;
3951
+ }
3952
+ body(store, found.item);
3953
+ });
3457
3954
  const itemArg = {
3458
3955
  type: "positional",
3459
3956
  description: "A feedback item's id, or a prefix of one",
@@ -3467,7 +3964,7 @@ const json$1 = {
3467
3964
  const feedback = defineCommand({
3468
3965
  meta: {
3469
3966
  name: "feedback",
3470
- description: "What got in the way in your runs, grouped: list, show, note, dismiss, restore"
3967
+ description: "What got in the way in your runs, grouped: list, show, note, dismiss, restore, accept, apply, triage, split, publish, export, import"
3471
3968
  },
3472
3969
  subCommands: {
3473
3970
  list: defineCommand({
@@ -3498,22 +3995,16 @@ const feedback = defineCommand({
3498
3995
  item: itemArg,
3499
3996
  json: json$1
3500
3997
  },
3501
- run: ({ args }) => withStore((store) => {
3502
- const found = findFeedbackItem(store, args.item);
3503
- if (!found.ok) {
3504
- writeErr(found.error);
3505
- process.exitCode = 1;
3506
- return;
3507
- }
3508
- const records = store.feedbackRecords(found.item.id);
3998
+ run: ({ args }) => withItem(args.item, (store, item) => {
3999
+ const records = store.feedbackRecords(item.id);
3509
4000
  if (args.json) {
3510
4001
  write(JSON.stringify({
3511
- ...found.item,
4002
+ ...item,
3512
4003
  records
3513
4004
  }, void 0, 2));
3514
4005
  return;
3515
4006
  }
3516
- for (const line of formatFeedbackItem(found.item, records)) write(line);
4007
+ for (const line of formatFeedbackItem(item, records)) write(line);
3517
4008
  })
3518
4009
  }),
3519
4010
  note: defineCommand({
@@ -3564,21 +4055,59 @@ const feedback = defineCommand({
3564
4055
  process.exitCode = 1;
3565
4056
  })
3566
4057
  }),
4058
+ accept: defineCommand({
4059
+ meta: {
4060
+ name: "accept",
4061
+ description: "Accept an item; with --lesson, every later session of its workflow reads it"
4062
+ },
4063
+ args: {
4064
+ item: itemArg,
4065
+ lesson: {
4066
+ type: "boolean",
4067
+ description: "Add it to the workflow's lessons (.ork/lessons/<workflow>.md)",
4068
+ default: false
4069
+ },
4070
+ message: {
4071
+ type: "string",
4072
+ alias: "m",
4073
+ description: "The lesson's words (default: the item's title)"
4074
+ },
4075
+ workflow: {
4076
+ type: "string",
4077
+ description: "Whose lessons, when it was seen in more than one"
4078
+ }
4079
+ },
4080
+ run: ({ args }) => withItem(args.item, (store, item) => {
4081
+ const accepted = acceptItem(store, item, {
4082
+ lesson: args.lesson,
4083
+ text: args.message,
4084
+ workflow: args.workflow
4085
+ }, registeredProjects());
4086
+ if (!accepted.ok) {
4087
+ writeErr(accepted.error);
4088
+ process.exitCode = 1;
4089
+ return;
4090
+ }
4091
+ const [first, ...rest] = accepted.lines;
4092
+ write(`${style("green", "✓")} ${first ?? ""}`);
4093
+ for (const line of rest) write(` ${line}`);
4094
+ })
4095
+ }),
4096
+ apply,
4097
+ triage,
4098
+ split,
4099
+ publish,
4100
+ export: exportFeedback,
4101
+ import: importFeedback,
3567
4102
  dismiss: defineCommand({
3568
4103
  meta: {
3569
4104
  name: "dismiss",
3570
4105
  description: "Dismiss an item: it leaves the list, and stays dismissed"
3571
4106
  },
3572
4107
  args: { item: itemArg },
3573
- run: ({ args }) => withStore((store) => {
3574
- const found = findFeedbackItem(store, args.item);
3575
- if (!found.ok) {
3576
- writeErr(found.error);
3577
- process.exitCode = 1;
3578
- return;
3579
- }
3580
- store.setFeedbackStatus(found.item.id, "dismissed");
3581
- write(`${style("green", "✓")} dismissed ${found.item.id}: ${found.item.title}`);
4108
+ run: ({ args }) => withItem(args.item, (store, item) => {
4109
+ store.setFeedbackStatus(item.id, "dismissed");
4110
+ write(`${style("green", "✓")} dismissed ${item.id}: ${item.title}`);
3582
4111
  })
3583
4112
  }),
3584
4113
  restore: defineCommand({
@@ -3587,19 +4116,128 @@ const feedback = defineCommand({
3587
4116
  description: "Bring a dismissed item back to the list, as new"
3588
4117
  },
3589
4118
  args: { item: itemArg },
3590
- run: ({ args }) => withStore((store) => {
3591
- const found = findFeedbackItem(store, args.item);
3592
- if (!found.ok) {
3593
- writeErr(found.error);
3594
- process.exitCode = 1;
3595
- return;
3596
- }
3597
- store.setFeedbackStatus(found.item.id, "new");
3598
- write(`${style("green", "✓")} restored ${found.item.id}: ${found.item.title}`);
4119
+ run: ({ args }) => withItem(args.item, (store, item) => {
4120
+ store.setFeedbackStatus(item.id, "new");
4121
+ write(`${style("green", "✓")} restored ${item.id}: ${item.title}`);
3599
4122
  })
3600
4123
  })
3601
4124
  }
3602
4125
  });
4126
+ // src/commands/runs.ts
4127
+ /**
4128
+ * @file `ork ps`, `ork logs` and `ork tree`: read what every project's runs
4129
+ * recorded in `~/.ork/state.db` (specs/event-store.md).
4130
+ */
4131
+ const ps = defineCommand({
4132
+ meta: {
4133
+ name: "ps",
4134
+ description: "List this project's runs, newest first"
4135
+ },
4136
+ args: {
4137
+ all: {
4138
+ type: "boolean",
4139
+ description: "Every project's runs",
4140
+ default: false
4141
+ },
4142
+ limit: {
4143
+ type: "string",
4144
+ description: "How many (default 20)"
4145
+ },
4146
+ json: {
4147
+ type: "boolean",
4148
+ description: "Print JSON",
4149
+ default: false
4150
+ }
4151
+ },
4152
+ run: ({ args }) => withStore((store) => {
4153
+ const limit = Number(args.limit ?? 20);
4154
+ const runs = store.runs({
4155
+ limit: Number.isInteger(limit) && limit > 0 ? limit : 20,
4156
+ ...args.all ? {} : { project: placeFor(process.cwd(), store.projects()).root }
4157
+ });
4158
+ const statusOf = (run) => runStatus(run, run.status === "running" ? store.events(run.id).map((s) => s.event) : []);
4159
+ if (args.json) write(JSON.stringify(runs, void 0, 2));
4160
+ else for (const line of formatRuns(runs, args.all, Date.now(), statusOf)) write(line);
4161
+ })
4162
+ });
4163
+ const runArg = {
4164
+ type: "positional",
4165
+ description: "A run id or a prefix of one (default: this project's newest)",
4166
+ required: false
4167
+ };
4168
+ const logs = defineCommand({
4169
+ meta: {
4170
+ name: "logs",
4171
+ description: "Print a run's transcript"
4172
+ },
4173
+ args: {
4174
+ run: runArg,
4175
+ node: {
4176
+ type: "string",
4177
+ description: "Only this node's events"
4178
+ },
4179
+ follow: {
4180
+ type: "boolean",
4181
+ alias: "f",
4182
+ description: "Keep printing a live run's events until it ends",
4183
+ default: false
4184
+ },
4185
+ json: {
4186
+ type: "boolean",
4187
+ description: "Print the stored events as JSON lines",
4188
+ default: false
4189
+ }
4190
+ },
4191
+ run: ({ args }) => withStore(async (store) => {
4192
+ const found = findRun(store, args.run, process.cwd());
4193
+ if (!found.ok) {
4194
+ writeErr(found.error);
4195
+ process.exitCode = 1;
4196
+ return;
4197
+ }
4198
+ const abort = new AbortController();
4199
+ process.once("SIGINT", () => {
4200
+ abort.abort();
4201
+ });
4202
+ await followRun(store, found.run, write, {
4203
+ follow: args.follow,
4204
+ json: args.json,
4205
+ signal: abort.signal,
4206
+ ...args.node === void 0 ? {} : { node: args.node }
4207
+ });
4208
+ })
4209
+ });
4210
+ const tree = defineCommand({
4211
+ meta: {
4212
+ name: "tree",
4213
+ description: "Show a run's ownership tree with status, tokens and cost"
4214
+ },
4215
+ args: {
4216
+ run: runArg,
4217
+ json: {
4218
+ type: "boolean",
4219
+ description: "Print JSON",
4220
+ default: false
4221
+ }
4222
+ },
4223
+ run: ({ args }) => withStore((store) => {
4224
+ const found = findRun(store, args.run, process.cwd());
4225
+ if (!found.ok) {
4226
+ writeErr(found.error);
4227
+ process.exitCode = 1;
4228
+ return;
4229
+ }
4230
+ const { run } = found;
4231
+ const events = store.events(run.id).map((s) => s.event);
4232
+ const roots = runTree(events);
4233
+ if (args.json) {
4234
+ write(JSON.stringify(roots, void 0, 2));
4235
+ return;
4236
+ }
4237
+ write(`${style("bold", `run ${run.id}`)} ${style("dim", `of ${run.workflow} · ${runStatus(run, events)} · ${cost(run.costUsd)}`)}`);
4238
+ for (const line of formatTree(roots)) write(line);
4239
+ })
4240
+ });
3603
4241
  // src/agents/manage.ts
3604
4242
  /**
3605
4243
  * @file What `ork agents` prints and does: the catalog as a table, one agent in
@@ -3673,7 +4311,8 @@ const skillsStep = async (root, options) => {
3673
4311
  };
3674
4312
  // src/commands/setup.ts
3675
4313
  /**
3676
- * @file Setting up: `ork init` scaffolds `.ork/` in a project, records it, and
4314
+ * @file Setting up: `ork init` scaffolds `.ork/` in a project, with a starter
4315
+ * that names no driver (it says which a run takes here), records it, and
3677
4316
  * links the tool folders it uses; `ork link` points tools' agents and skills
3678
4317
  * folders at `.ork/` (never in an attached project); `ork agents` lists,
3679
4318
  * shows, adds and removes the agents nodes can take. `ork doctor` and `ork
@@ -3696,10 +4335,12 @@ const init = defineCommand({
3696
4335
  }
3697
4336
  },
3698
4337
  run: async ({ args }) => {
4338
+ const cwd = process.cwd();
4339
+ const driverLine = await initDriverLineHere(cwd);
3699
4340
  const store = openStore();
3700
4341
  let result;
3701
4342
  try {
3702
- result = initRecorded(store, process.cwd(), { link: args.link });
4343
+ result = initRecorded(store, cwd, { link: args.link });
3703
4344
  } finally {
3704
4345
  store.close();
3705
4346
  }
@@ -3711,8 +4352,9 @@ const init = defineCommand({
3711
4352
  const { created, skipped, links } = result;
3712
4353
  for (const item of created) write(`${style("green", "created")} ${item}`);
3713
4354
  for (const item of skipped) write(`${style("dim", "exists")} ${item}`);
4355
+ if (driverLine !== null && created.includes(STARTER_FILE)) write(driverLine);
3714
4356
  for (const line of formatLinks(links.filter((l) => l.state !== "linked"))) write(line);
3715
- await skillsStep(process.cwd(), {
4357
+ await skillsStep(cwd, {
3716
4358
  skills: args.skills,
3717
4359
  link: args.link
3718
4360
  });
@@ -3724,11 +4366,18 @@ const link = defineCommand({
3724
4366
  name: "link",
3725
4367
  description: "Point tools' agents and skills folders (.claude/agents, …) at .ork/"
3726
4368
  },
3727
- args: { check: {
3728
- type: "boolean",
3729
- description: "Only show each folder's state; change nothing",
3730
- default: false
3731
- } },
4369
+ args: {
4370
+ tools: {
4371
+ type: "positional",
4372
+ description: "The tools to link, e.g. claude opencode (default: the ones the project already uses)",
4373
+ required: false
4374
+ },
4375
+ check: {
4376
+ type: "boolean",
4377
+ description: "Only show each folder's state; change nothing",
4378
+ default: false
4379
+ }
4380
+ },
3732
4381
  run: ({ args }) => {
3733
4382
  const place = projectAt(process.cwd());
3734
4383
  const { root } = place;
@@ -3889,7 +4538,10 @@ const agents = defineCommand({
3889
4538
  }
3890
4539
  });
3891
4540
  // src/main.ts
3892
- /** @file The root `ork` command and its subcommands. */
4541
+ /**
4542
+ * @file The root `ork` command and its subcommands. `ork --help` lists them in
4543
+ * the groups of `help-groups.ts`.
4544
+ */
3893
4545
  const main = defineCommand({
3894
4546
  meta: ORK_META,
3895
4547
  subCommands: {
@@ -3904,25 +4556,27 @@ const main = defineCommand({
3904
4556
  ps,
3905
4557
  logs,
3906
4558
  tree,
3907
- usage: () => import("./usage-BIjf9Xdt.mjs").then((module) => module.usage),
3908
- prune: () => import("./usage-BIjf9Xdt.mjs").then((module) => module.prune),
4559
+ usage: () => import("./usage-CJnytoe4.mjs").then((module) => module.usage),
4560
+ prune: () => import("./usage-CJnytoe4.mjs").then((module) => module.prune),
3909
4561
  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),
4562
+ relay: () => import("./relay-o-Luee1c.mjs").then((module) => module.relay),
4563
+ pause: () => import("./pause-B_SL_yDz.mjs").then((module) => module.pause),
4564
+ continue: () => import("./pause-B_SL_yDz.mjs").then((module) => module.continueRun),
3913
4565
  stop,
3914
4566
  chat,
3915
- send: () => import("./send-C6yr04Fe.mjs").then((module) => module.send),
4567
+ send: () => import("./send-3B64fBNB.mjs").then((module) => module.send),
3916
4568
  resume,
3917
4569
  feedback,
3918
4570
  context,
3919
- worktrees,
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),
4571
+ worktrees: () => import("./worktrees-86ySXxR6.mjs").then((module) => module.worktrees),
4572
+ wt: () => import("./worktrees-86ySXxR6.mjs").then((module) => module.worktrees),
4573
+ watch: () => import("./watch-BJB82ZJ7.mjs").then((module) => module.watch),
4574
+ mcp: () => import("./mcp-CKPyNaKD.mjs").then((module) => module.mcp),
4575
+ ui: () => import("./ui-CY4xxJUu.mjs").then((module) => module.ui),
3924
4576
  doctor,
3925
- upgrade
4577
+ upgrade,
4578
+ completion: () => import("./completion-snnYGjgK.mjs").then((module) => module.completion),
4579
+ __complete: () => import("./completion-snnYGjgK.mjs").then((module) => module.completeCommand(main))
3926
4580
  }
3927
4581
  });
3928
4582
  // src/bin.ts
@@ -3930,17 +4584,26 @@ const main = defineCommand({
3930
4584
  * @file The `ork` executable: runs the CLI's root command. The published
3931
4585
  * package's `bin` points at its bundle. When the new-version check is on, it
3932
4586
  * starts the daily background check and ends with the notice (ADR-038); the
3933
- * background copy it starts only runs the check.
4587
+ * background copy it starts only runs the check. It first clears git's local
4588
+ * variables (a hook's `GIT_DIR`, `GIT_INDEX_FILE`, …), so nothing Ork starts
4589
+ * acts on the hook's repository (specs/worktrees.md). `--help` leaves out the
4590
+ * flags only Ork passes to itself. `ork __complete`, which tab completion
4591
+ * runs on every Tab, goes first: no update check, and no citty, so a typed
4592
+ * `--help` is a word to complete.
3934
4593
  */
3935
- if (process.env["ORK_UPDATE_CHECK_CHILD"] === "1") await runUpdateChild();
4594
+ clearGitLocalEnv();
4595
+ const argv = process.argv.slice(2);
4596
+ if (argv[0] === "__complete") {
4597
+ const { printCompletions } = await import("./completion-snnYGjgK.mjs");
4598
+ await printCompletions(main, argv.slice(1));
4599
+ } else if (process.env["ORK_UPDATE_CHECK_CHILD"] === "1") await runUpdateChild();
3936
4600
  else {
3937
- const argv = process.argv.slice(2);
3938
4601
  if (updateCheckOff(process.env, argv, process.stderr.isTTY) === null) {
3939
4602
  startUpdateCheck();
3940
4603
  process.once("exit", () => {
3941
4604
  showUpdateNotice();
3942
4605
  });
3943
4606
  }
3944
- await runMain(main);
4607
+ await runMain(main, { showUsage: showHelp });
3945
4608
  }
3946
4609
  export {};