@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.
- package/README.md +16 -12
- package/dist/__build_info__-DdTVd2p5.mjs +615 -0
- package/dist/bin.mjs +1572 -909
- package/dist/clean-NnboOuz6.mjs +88 -0
- package/dist/completion-snnYGjgK.mjs +405 -0
- package/dist/{context-BFGC_C02.mjs → context-DO3sBy9v.mjs} +30 -191
- package/dist/decide-B81wCZQg.mjs +113 -0
- package/dist/{find-BcvhT6UN.mjs → find-BKW-uGey.mjs} +1 -1
- package/dist/{format-DqpObyMl.mjs → format-CmTlTOjT.mjs} +72 -198
- package/dist/help-groups-BwYoAWsh.mjs +354 -0
- package/dist/{in-a-run-BbLhZOFW.mjs → in-a-run-BPXX09Bs.mjs} +1 -1
- package/dist/{limits-BKYAmqCR.mjs → limits-JCve2QpI.mjs} +1 -1
- package/dist/list-C7UEgmtd.mjs +110 -0
- package/dist/{mcp-BWsdQyri.mjs → mcp-CKPyNaKD.mjs} +7 -7
- package/dist/{output-Q2EdeVUt.mjs → output-B1V6ZJSx.mjs} +130 -36
- package/dist/{pause-DKACDfV1.mjs → pause-B_SL_yDz.mjs} +4 -4
- package/dist/{relay-CbK-Wme9.mjs → relay-o-Luee1c.mjs} +2 -2
- package/dist/remove-DAZ7ZCzt.mjs +194 -0
- package/dist/{send-C6yr04Fe.mjs → send-3B64fBNB.mjs} +3 -3
- package/dist/{src-D5ruZFgD.mjs → src-BJTegEzY.mjs} +1212 -57
- package/dist/{src-DtN1k-B3.mjs → src-C38BPzit.mjs} +251 -18
- package/dist/{src-Cz1yOUTg.mjs → src-DHvWU4tX.mjs} +2742 -1659
- package/dist/{src-CZOVF2yU.mjs → src-Dyz_Gy1q.mjs} +35 -8
- package/dist/ui/THIRD_PARTY_LICENSES.txt +2 -2
- package/dist/ui/assets/app-C8nJ6NpT.js +67 -0
- package/dist/ui/assets/app-CH7YC3in.css +1 -0
- package/dist/ui/assets/{index-BiO54L90.css → index-BPIbqRRb.css} +1 -1
- package/dist/ui/assets/{index-BoG6O-Lp.js → index-D83F2OKZ.js} +2 -2
- package/dist/ui/index.html +2 -3
- package/dist/ui-CY4xxJUu.mjs +472 -0
- package/dist/{usage-BIjf9Xdt.mjs → usage-CJnytoe4.mjs} +6 -5
- package/dist/{watch-B8o4Szd_.mjs → watch-BJB82ZJ7.mjs} +7 -7
- package/dist/workflows-CiM7S3l0.mjs +88 -0
- package/dist/worktrees-86ySXxR6.mjs +212 -0
- package/package.json +6 -6
- package/dist/__build_info__-CXXMLvjt.mjs +0 -104
- package/dist/ui/assets/app-BlDdP396.js +0 -61
- package/dist/ui/assets/app-C722xNZ3.css +0 -1
- package/dist/ui-CZS1XKla.mjs +0 -160
package/dist/bin.mjs
CHANGED
|
@@ -1,23 +1,58 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import { n as
|
|
5
|
-
import {
|
|
6
|
-
import { a as
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import { i as
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) =>
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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) =>
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
530
|
-
|
|
531
|
-
const
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
}
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
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
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
const
|
|
554
|
-
|
|
555
|
-
const
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
...
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
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
|
-
*
|
|
594
|
-
*
|
|
595
|
-
*
|
|
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
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
...
|
|
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
|
-
|
|
606
|
-
|
|
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
|
-
/**
|
|
609
|
-
const
|
|
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/
|
|
702
|
+
// src/update/notify.ts
|
|
621
703
|
/**
|
|
622
|
-
* @file
|
|
623
|
-
*
|
|
624
|
-
*
|
|
625
|
-
*
|
|
626
|
-
*
|
|
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
|
-
/**
|
|
629
|
-
const
|
|
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
|
-
*
|
|
636
|
-
*
|
|
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
|
|
639
|
-
const
|
|
640
|
-
|
|
641
|
-
if (!
|
|
642
|
-
if (
|
|
643
|
-
if (
|
|
644
|
-
|
|
645
|
-
|
|
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
|
-
|
|
727
|
+
/** The notice's text. */
|
|
728
|
+
const updateNotice = (latest, current) => `Ork ${latest} is out (you have ${current}). Upgrade with: ork upgrade`;
|
|
696
729
|
/**
|
|
697
|
-
*
|
|
698
|
-
*
|
|
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
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
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
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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/
|
|
778
|
+
// src/update/upgrade.ts
|
|
746
779
|
/**
|
|
747
|
-
* @file `ork
|
|
748
|
-
*
|
|
749
|
-
*
|
|
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
|
-
|
|
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(
|
|
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-
|
|
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,
|
|
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/
|
|
1994
|
+
// src/commands/decide.ts
|
|
1595
1995
|
/**
|
|
1596
|
-
* @file `ork decide
|
|
1597
|
-
*
|
|
1598
|
-
*
|
|
1599
|
-
*
|
|
1600
|
-
*
|
|
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)
|
|
1663
|
-
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
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
|
-
|
|
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,
|
|
2801
|
-
created.push(
|
|
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`
|
|
3010
|
-
|
|
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
|
-
|
|
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:
|
|
3525
|
+
error: allowed.error
|
|
3301
3526
|
};
|
|
3302
|
-
const found =
|
|
3527
|
+
const found = placeOf(store, item, void 0, options.registered);
|
|
3303
3528
|
if (!found.ok) return found;
|
|
3304
|
-
const {
|
|
3305
|
-
const
|
|
3306
|
-
|
|
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:
|
|
3309
|
-
};
|
|
3310
|
-
const
|
|
3311
|
-
|
|
3312
|
-
const
|
|
3313
|
-
|
|
3314
|
-
|
|
3315
|
-
|
|
3316
|
-
|
|
3317
|
-
|
|
3318
|
-
|
|
3319
|
-
|
|
3320
|
-
|
|
3321
|
-
|
|
3322
|
-
|
|
3323
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3558
|
+
outcome,
|
|
3559
|
+
lines: await settle(options, place.root, result.runId, outcome)
|
|
3330
3560
|
};
|
|
3331
3561
|
};
|
|
3332
|
-
// src/
|
|
3562
|
+
// src/runs/triage.ts
|
|
3333
3563
|
/**
|
|
3334
|
-
* @file `ork
|
|
3335
|
-
*
|
|
3336
|
-
*
|
|
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
|
-
|
|
3339
|
-
|
|
3340
|
-
|
|
3341
|
-
|
|
3342
|
-
|
|
3343
|
-
|
|
3344
|
-
|
|
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
|
-
|
|
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: "
|
|
3350
|
-
description: "
|
|
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
|
-
|
|
3354
|
-
|
|
3355
|
-
description: "Every project's runs",
|
|
3356
|
-
default: false
|
|
3357
|
-
},
|
|
3358
|
-
limit: {
|
|
3616
|
+
item: itemArg$1,
|
|
3617
|
+
driver: {
|
|
3359
3618
|
type: "string",
|
|
3360
|
-
description: "
|
|
3619
|
+
description: "sdk (API key or cloud provider) or cli (your claude login)"
|
|
3361
3620
|
},
|
|
3362
|
-
|
|
3363
|
-
type: "
|
|
3364
|
-
description: "
|
|
3365
|
-
|
|
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
|
|
3370
|
-
|
|
3371
|
-
|
|
3372
|
-
|
|
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
|
-
|
|
3375
|
-
|
|
3376
|
-
|
|
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
|
-
|
|
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: "
|
|
3387
|
-
description: "
|
|
3828
|
+
name: "publish",
|
|
3829
|
+
description: "Share an item about Ork with its maintainers, after showing exactly what goes"
|
|
3388
3830
|
},
|
|
3389
3831
|
args: {
|
|
3390
|
-
|
|
3391
|
-
|
|
3392
|
-
|
|
3393
|
-
|
|
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
|
-
|
|
3837
|
+
"hide-names": {
|
|
3402
3838
|
type: "boolean",
|
|
3403
|
-
description: "
|
|
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
|
-
|
|
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
|
-
|
|
3411
|
-
process.exitCode = 1;
|
|
3850
|
+
failed(found.error);
|
|
3412
3851
|
return;
|
|
3413
3852
|
}
|
|
3414
|
-
const
|
|
3415
|
-
|
|
3416
|
-
|
|
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
|
|
3882
|
+
const exportFeedback = defineCommand({
|
|
3427
3883
|
meta: {
|
|
3428
|
-
name: "
|
|
3429
|
-
description: "
|
|
3884
|
+
name: "export",
|
|
3885
|
+
description: "Write feedback records as JSON lines, to share them"
|
|
3430
3886
|
},
|
|
3431
3887
|
args: {
|
|
3432
|
-
|
|
3433
|
-
json: {
|
|
3888
|
+
all: {
|
|
3434
3889
|
type: "boolean",
|
|
3435
|
-
description: "
|
|
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
|
|
3441
|
-
|
|
3442
|
-
|
|
3443
|
-
process.
|
|
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
|
-
|
|
3447
|
-
|
|
3448
|
-
|
|
3449
|
-
|
|
3450
|
-
|
|
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
|
-
|
|
3454
|
-
|
|
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 }) =>
|
|
3502
|
-
const
|
|
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
|
-
...
|
|
4002
|
+
...item,
|
|
3512
4003
|
records
|
|
3513
4004
|
}, void 0, 2));
|
|
3514
4005
|
return;
|
|
3515
4006
|
}
|
|
3516
|
-
for (const line of formatFeedbackItem(
|
|
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 }) =>
|
|
3574
|
-
|
|
3575
|
-
|
|
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 }) =>
|
|
3591
|
-
|
|
3592
|
-
|
|
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,
|
|
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,
|
|
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(
|
|
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: {
|
|
3728
|
-
|
|
3729
|
-
|
|
3730
|
-
|
|
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
|
-
/**
|
|
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-
|
|
3908
|
-
prune: () => import("./usage-
|
|
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-
|
|
3911
|
-
pause: () => import("./pause-
|
|
3912
|
-
continue: () => import("./pause-
|
|
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-
|
|
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-
|
|
3922
|
-
mcp: () => import("./mcp-
|
|
3923
|
-
ui: () => import("./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
|
-
|
|
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 {};
|