@stringsync/splates 0.0.0-stage → 0.0.46

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/index.js ADDED
@@ -0,0 +1,1282 @@
1
+ #!/usr/bin/env bun
2
+ // @bun
3
+
4
+ // main.ts
5
+ import { basename as basename2 } from "node:path";
6
+ import {
7
+ LEGACY,
8
+ legacyWarnings,
9
+ systemPs
10
+ } from "@stringsync/splates-server/config";
11
+ import { assets } from "@stringsync/splates-server/server";
12
+ import { NodeFs } from "webappwiz/system";
13
+
14
+ // splates.ts
15
+ import { DEFAULT_PORT } from "@stringsync/splates-server";
16
+ import { agents } from "@stringsync/splates-server/agents";
17
+ import { attach } from "@stringsync/splates-server/artifacts";
18
+ import { commaList as commaList4 } from "@stringsync/splates-server/comma-list";
19
+ import { exits, fail as fail5 } from "@stringsync/splates-server/exit";
20
+ import { DEFAULT_COUNT, log as showLog } from "@stringsync/splates-server/log";
21
+ import { merge } from "@stringsync/splates-server/merge";
22
+ import { portsLs } from "@stringsync/splates-server/ports";
23
+ import { forget, init } from "@stringsync/splates-server/projects";
24
+ import { repository as repository7 } from "@stringsync/splates-server/records";
25
+ import {
26
+ newest,
27
+ rosterOf as rosterOf2,
28
+ runHeavy,
29
+ seeded as seeded2,
30
+ serveDev,
31
+ update
32
+ } from "@stringsync/splates-server/server";
33
+ import { addFactTodos } from "@stringsync/splates-server/stats";
34
+ import {
35
+ add,
36
+ checkpoint,
37
+ claim,
38
+ DEFAULT_TIMEOUT as DEFAULT_TIMEOUT3,
39
+ escalate,
40
+ follow,
41
+ handoff,
42
+ list,
43
+ path,
44
+ REMOVE_REASONS,
45
+ recordRetry,
46
+ remove,
47
+ reportToCaller,
48
+ serveProject,
49
+ show,
50
+ tell,
51
+ untilAfter as untilAfter3,
52
+ wait
53
+ } from "@stringsync/splates-server/tasks";
54
+ import { LOCAL_SLOTS } from "@stringsync/splates-server/workers";
55
+ import { cli as cli12 } from "webappwiz/cmd";
56
+ import { Duration as Duration3 } from "webappwiz/time";
57
+ import { z as z13 } from "zod";
58
+
59
+ // artifact.ts
60
+ import { basename, resolve } from "node:path";
61
+ import {
62
+ addArtifact,
63
+ listArtifacts,
64
+ removeArtifact
65
+ } from "@stringsync/splates-server/artifacts";
66
+ import { commaList as commaList2 } from "@stringsync/splates-server/comma-list";
67
+ import { conversationOf } from "@stringsync/splates-server/conversations";
68
+ import { fail as fail2 } from "@stringsync/splates-server/exit";
69
+ import { repository } from "@stringsync/splates-server/records";
70
+ import { cli } from "webappwiz/cmd";
71
+ import { z as z2 } from "zod";
72
+
73
+ // flags.ts
74
+ import { commaList } from "@stringsync/splates-server/comma-list";
75
+ import { SYSTEM_STORES } from "@stringsync/splates-server/creds";
76
+ import { fail } from "@stringsync/splates-server/exit";
77
+ import {
78
+ Offered,
79
+ pickAgent,
80
+ pickEffort,
81
+ splatesHome
82
+ } from "@stringsync/splates-server/models";
83
+ import { SLOT_POOL } from "@stringsync/splates-server/ports";
84
+ import { rosterOf, seeded } from "@stringsync/splates-server/server";
85
+ import { SavedTunnel } from "@stringsync/splates-server/tunnel";
86
+ import { z } from "zod";
87
+ var position = z.coerce.number().int().positive().optional();
88
+ async function assigneeIn(deps, agent, model, effort) {
89
+ if (agent === "") {
90
+ if (model !== "" || effort !== "") {
91
+ fail("usage", "--model and --effort go with --agent", {
92
+ model,
93
+ effort
94
+ });
95
+ }
96
+ return;
97
+ }
98
+ if (agent === "none") {
99
+ return null;
100
+ }
101
+ return pickIn(deps, await seeded(rosterOf(deps)), agent, model, effort);
102
+ }
103
+ async function pickIn(deps, agents, agent, model, effort, data = {}) {
104
+ const picked = pickAgent(agents, agent, model === "" ? undefined : model, data);
105
+ return { ...picked, ...await effortIn(deps, picked.agent, effort, data) };
106
+ }
107
+ async function effortIn(deps, agent, effort, data = {}) {
108
+ if (effort === "") {
109
+ return {};
110
+ }
111
+ const offered = (await offeredOf(deps).all())[agent];
112
+ return { effort: pickEffort(offered, agent, effort, data) };
113
+ }
114
+ function offeredOf({ ps, fs }) {
115
+ return new Offered(`${splatesHome(ps)}/offered.json`, { fs });
116
+ }
117
+ function slotTunnelsOf({
118
+ ps,
119
+ fs,
120
+ secrets = SYSTEM_STORES
121
+ }) {
122
+ return SLOT_POOL.map(({ word }) => new SavedTunnel(`${splatesHome(ps)}/slots/${word}/tunnel.json`, {
123
+ fs,
124
+ store: secrets.device
125
+ }));
126
+ }
127
+ function laneIn(raw) {
128
+ const value = raw.trim();
129
+ if (value === "") {
130
+ return;
131
+ }
132
+ if (value === "new") {
133
+ return "new";
134
+ }
135
+ if (value === "none") {
136
+ return null;
137
+ }
138
+ const id = Number(value);
139
+ if (!Number.isInteger(id) || id <= 0) {
140
+ fail("usage", `'${value}' is not a lane: pass its number, new or none`, {
141
+ lane: value
142
+ });
143
+ }
144
+ return id;
145
+ }
146
+ function todoIds(raw) {
147
+ return commaList(raw).map((value) => {
148
+ const id = Number(value);
149
+ if (!Number.isInteger(id) || id <= 0) {
150
+ fail("usage", `'${value}' is not a todo id`, { todo: value });
151
+ }
152
+ return id;
153
+ });
154
+ }
155
+ async function here({
156
+ service,
157
+ git,
158
+ ps,
159
+ project
160
+ }) {
161
+ return project === undefined ? service.taskFor(await git.currentBranch(ps.cwd())) : null;
162
+ }
163
+
164
+ // artifact.ts
165
+ var splatesArtifact = cli("artifact").description("files your conversation made as its outputs, such as a screenshot, a recording or an HTML page: kept by name for the life of the conversation, whatever task it was made in, and listed on the `splates server` page beside its thread. A file that shows a question, an assumption or a demo step is no artifact: `splates attach` it. The conversation is $SPLATES_CONVERSATION, or --conversation <id> from an agent outside `splates server`");
166
+ var conversation = [
167
+ "conversation",
168
+ z2.string(),
169
+ {
170
+ default: "",
171
+ description: "conversation id, when not run by an agent `splates server` started"
172
+ }
173
+ ];
174
+ splatesArtifact.command("add").use(repository({ needs: "own" })).description("keep files as artifacts of this conversation, each named for its basename, or by --name when there is one; adding a name the conversation holds makes its next version, earlier ones kept until cleanup takes them. Past the project's limit (artifactProjectCapacity), `splates server` first evicts the oldest bytes the project keeps: removed and replaced versions, then what archived conversations show, then what live ones do. Past the file or conversation limit (artifactFileCapacity, artifactConversationCapacity), or the project's when even that would not make room, it stops with `too_large`, changing nothing for that file").arg("files", z2.string(), { description: "files to keep, comma separated" }).option("name", z2.string(), {
175
+ default: "",
176
+ description: "the artifact's name; for one file only"
177
+ }).option(...conversation).action(async (opts, ctx) => {
178
+ const paths = commaList2(opts.files);
179
+ if (paths.length === 0) {
180
+ fail2("usage", "name the files to keep: nothing was changed");
181
+ }
182
+ if (opts.name !== "" && paths.length > 1) {
183
+ fail2("usage", "--name names one file: nothing was changed");
184
+ }
185
+ const id = await conversationOf(ctx.service, opts.conversation);
186
+ const files = await Promise.all(paths.map(async (given) => {
187
+ const path = resolve(ctx.ps.cwd(), given);
188
+ const bytes = await ctx.fs.readBytes(path).catch(() => null);
189
+ if (bytes === null) {
190
+ fail2("usage", `cannot read ${path}: nothing was changed`, {
191
+ file: path
192
+ });
193
+ }
194
+ return { name: opts.name || basename(path), bytes };
195
+ }));
196
+ const task = await here(ctx);
197
+ for (const file of files) {
198
+ await ctx.journal.record("artifact add", task, () => addArtifact(ctx, id, file), (added) => ({
199
+ name: file.name,
200
+ version: added.version,
201
+ bytes: added.bytes
202
+ }));
203
+ }
204
+ });
205
+ splatesArtifact.command("remove").use(repository({ needs: "own" })).description("stop listing the artifact of that name, every version kept until cleanup takes them; adding the name again lists it again").arg("name", z2.string(), { description: "the artifact's name" }).option(...conversation).action(async (opts, ctx) => {
206
+ const id = await conversationOf(ctx.service, opts.conversation);
207
+ await ctx.journal.record("artifact remove", await here(ctx), () => removeArtifact(ctx, id, opts.name), (version) => ({ name: opts.name, version }));
208
+ });
209
+ splatesArtifact.command("list").use(repository({ needs: "own" })).description("the artifacts this conversation lists, and their current version").option(...conversation).action(async (opts, ctx) => listArtifacts(ctx, await conversationOf(ctx.service, opts.conversation)));
210
+ splatesArtifact.command("show").use(repository({ needs: "own" })).description("every version of an artifact, and which is current").arg("name", z2.string(), { description: "the artifact's name" }).option(...conversation).action(async (opts, ctx) => listArtifacts(ctx, await conversationOf(ctx.service, opts.conversation), opts.name));
211
+
212
+ // budget.ts
213
+ import {
214
+ endBudgetRaise,
215
+ raiseBudget,
216
+ setBudget,
217
+ showBudget
218
+ } from "@stringsync/splates-server/workers";
219
+ import { cli as cli2 } from "webappwiz/cmd";
220
+ import { z as z3 } from "zod";
221
+ var DEV = {
222
+ default: "",
223
+ description: "the `splates server` to ask, a URL such as http://localhost:4310; by default the one an agent runs under, from SPLATES_DEV"
224
+ };
225
+ var JSON_OPTION = { default: false, description: "emit JSON" };
226
+ var LIMIT = z3.enum(["hour", "day", "month"]);
227
+ var LIMIT_ARG = {
228
+ description: "hour, the rate cap on what made workers running at once cost an hour; day; or month"
229
+ };
230
+ var splatesBudget = cli2("budget").description("what the workers a `workerProvider` makes may cost, in dollars: a rate cap per hour on those running at once ($0.25/h by default), and limits on a day ($5) and a month ($75). Past the rate cap, or once a day's or month's limit is spent, no worker is made and the queue says why; reaching the day's or month's stops made workers, whose agents stop at their checkpoint. Each warns at 80%, 90% and 100% by Web Push and on the page's header. Spend is an estimate at each type's list price. Agents' own API spend is not counted here");
231
+ splatesBudget.command("show").description("show each limit: what made workers spent in its window, of how much, and what is left").option("dev", z3.string(), DEV).option("json", z3.boolean(), JSON_OPTION).action((opts, ctx) => showBudget({
232
+ dev: opts.dev,
233
+ json: opts.json,
234
+ log: ctx.log,
235
+ ps: ctx.ps,
236
+ send: ctx.fetch
237
+ }));
238
+ splatesBudget.command("set").description("set a limit, in dollars (dollars an hour for hour), then show the budget").arg("limit", LIMIT, LIMIT_ARG).arg("dollars", z3.string(), {
239
+ description: "like 5 or 0.25, or `default` to give it back to its default"
240
+ }).option("dev", z3.string(), DEV).option("json", z3.boolean(), JSON_OPTION).action((opts, ctx) => setBudget({
241
+ limit: opts.limit,
242
+ dollars: opts.dollars,
243
+ dev: opts.dev,
244
+ json: opts.json,
245
+ log: ctx.log,
246
+ ps: ctx.ps,
247
+ send: ctx.fetch
248
+ }));
249
+ splatesBudget.command("raise").description("add dollars to a limit for a while, on top of what it is set to, then show the budget").arg("limit", LIMIT, LIMIT_ARG).arg("dollars", z3.string(), { description: "how much to add, like 2 or 0.1" }).option("for", z3.string(), {
250
+ default: "1d",
251
+ description: "how long it lasts, in hours, days or weeks, like 24h, 1d or 2w"
252
+ }).option("dev", z3.string(), DEV).option("json", z3.boolean(), JSON_OPTION).action((opts, ctx) => raiseBudget({
253
+ limit: opts.limit,
254
+ dollars: opts.dollars,
255
+ for: opts.for,
256
+ dev: opts.dev,
257
+ json: opts.json,
258
+ log: ctx.log,
259
+ ps: ctx.ps,
260
+ send: ctx.fetch
261
+ }));
262
+ splatesBudget.command("end").description("end a raise now, by the number `show` gives it").arg("id", z3.coerce.number().int(), { description: "the raise's number" }).option("dev", z3.string(), DEV).option("json", z3.boolean(), JSON_OPTION).action((opts, ctx) => endBudgetRaise({
263
+ id: opts.id,
264
+ dev: opts.dev,
265
+ json: opts.json,
266
+ log: ctx.log,
267
+ ps: ctx.ps,
268
+ send: ctx.fetch
269
+ }));
270
+
271
+ // coordinator.ts
272
+ import {
273
+ CoordinatorBox
274
+ } from "@stringsync/splates-server/coordinator";
275
+ import { RUNNING } from "@stringsync/splates-server/server";
276
+ import { cli as cli3 } from "webappwiz/cmd";
277
+ import { z as z4 } from "zod";
278
+ var splatesCoordinator = cli3("coordinator").description("the always-on box in your own Hetzner Cloud account that `splates server` moves onto, made through Pulumi with the token and state the Cloud workers setup readied: a CPX21 in Ashburn with Hetzner's daily backups, reached by SSH alone");
279
+ splatesCoordinator.command("up").description("make the coordinator's box, or bring it to its program, with splates of this release installed for the user splates and an SSH key splates makes under ~/.splates/coordinator; run again, it changes only what is not so already. The box serves nothing until the coordinator moves onto it").option("json", z4.boolean(), { default: false, description: "emit JSON" }).action(async (opts, ctx) => {
280
+ ctx.log.info("making the coordinator's box; its first boot takes minutes");
281
+ print(ctx, await box(ctx).up(), opts.json);
282
+ });
283
+ splatesCoordinator.command("status").description("print the coordinator's box as its Pulumi stack has it").option("json", z4.boolean(), { default: false, description: "emit JSON" }).action(async (opts, ctx) => {
284
+ const standing = await box(ctx).status();
285
+ if (standing === null) {
286
+ ctx.log.info(opts.json ? "null" : "no coordinator's box: `splates coordinator up` makes one");
287
+ return;
288
+ }
289
+ print(ctx, standing, opts.json);
290
+ });
291
+ splatesCoordinator.command("remove").description("destroy the coordinator's box and its backups, lifting the protection that keeps it from deletion first, and forget its stack; its SSH key stays. Anything only on the box is lost").action(async (_opts, ctx) => {
292
+ await box(ctx).remove();
293
+ ctx.log.info("removed the coordinator's box");
294
+ });
295
+ function box(ctx) {
296
+ return new CoordinatorBox({ version: RUNNING, ps: ctx.ps, fs: ctx.fs });
297
+ }
298
+ function print(ctx, standing, json) {
299
+ if (json) {
300
+ ctx.log.info(JSON.stringify(standing));
301
+ return;
302
+ }
303
+ ctx.log.info([
304
+ `address: ${standing.ipv4}`,
305
+ `server: ${standing.serverId} (${standing.serverType} in ${standing.location})`,
306
+ `ssh: ssh -i ${standing.key} root@${standing.ipv4}`
307
+ ].join(`
308
+ `));
309
+ }
310
+
311
+ // creds.ts
312
+ import { DEFAULT_TIMEOUT } from "@stringsync/splates-server/creds";
313
+ import { fail as fail3 } from "@stringsync/splates-server/exit";
314
+ import { repository as repository2 } from "@stringsync/splates-server/records";
315
+ import { untilAfter } from "@stringsync/splates-server/tasks";
316
+ import { cli as cli4 } from "webappwiz/cmd";
317
+ import { color } from "webappwiz/log";
318
+ import { Duration } from "webappwiz/time";
319
+ import { z as z5 } from "zod";
320
+ var splatesCreds = cli4("creds").description("creds an agent needs a person for, such as an API token: request one saying what it is for, with instructions for getting it, which the Creds page of `splates server` shows for the person to paste, then wait for it; the value goes into the system's store (the macOS Keychain, Windows Credential Manager, a Linux secret service), never to the agent, and code reads it through webappwiz/creds");
321
+ splatesCreds.command("request").use(repository2({ needs: "own" })).description("ask a person for a cred, saying why it is needed and what it will be used for, with markdown instructions for getting it; returns at once, and does nothing when the store already keeps it unless --replace; run from a worktree, it records the task asking").arg("name", z5.string(), {
322
+ description: "what it is kept as, an environment variable's name"
323
+ }).option("purpose", z5.string(), {
324
+ default: "",
325
+ description: "why it is needed and what it will be used for, in a sentence the person reads before getting it"
326
+ }).option("instructions", z5.string(), {
327
+ default: "",
328
+ description: "how to get it, in markdown: where to go, what to pick, what to paste"
329
+ }).option("device", z5.boolean(), {
330
+ default: false,
331
+ description: "keep it for every project on this device, as for a person's own account, rather than this project alone"
332
+ }).option("replace", z5.boolean(), {
333
+ default: false,
334
+ description: "ask again although one is kept, since the kept one does not work; --purpose and --instructions left off are those of the last request"
335
+ }).action(async (opts, ctx) => {
336
+ const scope = opts.device ? "device" : "project";
337
+ const request = await ctx.journal.record("creds request", null, async () => ctx.creds.request(opts.name, {
338
+ scope,
339
+ purpose: opts.purpose,
340
+ instructions: opts.instructions,
341
+ task: await here(ctx),
342
+ replace: opts.replace
343
+ }), (asked) => ({ name: opts.name, asked: asked !== null }));
344
+ if (request === null) {
345
+ ctx.log.info(`${color.green("kept")} ${opts.name} in ${ctx.creds.label(scope)}: nothing to ask`);
346
+ return;
347
+ }
348
+ ctx.log.info(`${color.yellow("requested")} ${opts.name} ${color.dim(request.purpose)}`);
349
+ ctx.log.error(`It shows on the Creds page of \`splates server\`. \`splates creds wait ${opts.name}\` until it is saved.`);
350
+ });
351
+ splatesCreds.command("wait").use(repository2({ needs: "own" })).description("block until a person saves the cred requested, or dismisses the request (`dismissed`); gives up with `timeout` rather than waiting forever").arg("name", z5.string(), { description: "the cred requested" }).option("timeout-secs", z5.coerce.number(), {
352
+ default: DEFAULT_TIMEOUT.secs,
353
+ description: "how long to wait before giving up"
354
+ }).action(async (opts, ctx) => {
355
+ const timeout = Duration.secs(opts["timeout-secs"]);
356
+ await ctx.journal.waiting("creds wait", null, { name: opts.name, until: untilAfter(timeout) }, async () => {
357
+ const ended = await ctx.creds.wait(opts.name, { timeout });
358
+ if (ended === "dismissed") {
359
+ fail3("dismissed", `${opts.name} was dismissed: carry on without it, or ask the person why`, { name: opts.name });
360
+ }
361
+ });
362
+ ctx.log.info(`${color.green("saved")} ${opts.name}`);
363
+ });
364
+ splatesCreds.command("list").use(repository2({ needs: "read" })).description("the creds requested and still open, with what each is for, and every cred the project's store and the device's keep, by name; never a value").option("json", z5.boolean(), { default: false, description: "emit JSON" }).action(async (opts, ctx) => {
365
+ const open = (await ctx.creds.requests()).filter((each) => each.status === "open");
366
+ const kept = await ctx.creds.kept();
367
+ if (opts.json) {
368
+ ctx.log.info(JSON.stringify({ requested: open, kept }, null, 2));
369
+ return;
370
+ }
371
+ for (const each of open) {
372
+ ctx.log.info(`${color.yellow("requested")} ${each.name} ${color.dim(each.scope)}${each.task === null ? "" : color.dim(` by ${each.task}`)}`);
373
+ if (each.purpose !== "") {
374
+ ctx.log.info(` ${each.purpose}`);
375
+ }
376
+ }
377
+ for (const each of kept) {
378
+ ctx.log.info(`${color.green("kept")} ${each.name} ${color.dim(each.scope)}${each.savedBy === null ? "" : color.dim(` saved by ${each.savedBy}`)}`);
379
+ }
380
+ if (open.length === 0 && kept.length === 0) {
381
+ ctx.log.info(color.dim("no creds requested or kept"));
382
+ }
383
+ });
384
+
385
+ // lane.ts
386
+ import {
387
+ laneAdd,
388
+ laneList,
389
+ laneRemove,
390
+ laneShow,
391
+ laneUpdate,
392
+ laneUpdated,
393
+ recordLaneRemove
394
+ } from "@stringsync/splates-server/board";
395
+ import { UNTRIAGED } from "@stringsync/splates-server/plans";
396
+ import { repository as repository3 } from "@stringsync/splates-server/records";
397
+ import { cli as cli5 } from "webappwiz/cmd";
398
+ import { z as z6 } from "zod";
399
+ var splatesLane = cli5("lane").description("lanes: named runs of todos, landed top first; `[SPLATES LANE <n>]` from `splates server` starts an orchestrator on one, handing out each todo nothing holds back to an agent on the model it names, or doing it itself. A todo in no lane is untriaged, and `[SPLATES LANE untriaged]` starts one that places those in lanes first");
400
+ splatesLane.command("list").use(repository3({ needs: "read" })).description("every lane: its name, its todos in order, the tasks on it, its next todo, and what blocks that in other lanes").option("json", z6.boolean(), { default: false, description: "emit JSON" }).action((opts, ctx) => laneList(ctx, { json: opts.json }));
401
+ splatesLane.command("show").use(repository3({ needs: "read" })).description("one lane step by step, and what its agent does next: start the next todo, or wait for the one blocking it in another lane to land first; `untriaged` lists the todos in no lane, to place in lanes").arg("lane", z6.union([
402
+ z6.literal(UNTRIAGED).transform(() => null),
403
+ z6.coerce.number().int().positive()
404
+ ], { error: "pass a lane's number, or untriaged" }), { description: "lane number, or `untriaged`" }).option("json", z6.boolean(), { default: false, description: "emit JSON" }).action((opts, ctx) => laneShow(ctx, opts.lane, { json: opts.json }));
405
+ splatesLane.command("add").use(repository3({ needs: "tasks" })).description("start an empty lane, after the others").arg("name", z6.string(), {
406
+ description: "what its todos are for, like `Licensing`"
407
+ }).action(async (opts, ctx) => {
408
+ await ctx.journal.record("lane add", null, () => laneAdd(ctx, opts.name), (lane) => ({ lane: lane.id, name: lane.name }));
409
+ });
410
+ splatesLane.command("update").use(repository3({ needs: "tasks" })).description("rename a lane, or move it among the others").arg("lane", z6.coerce.number().int().positive(), {
411
+ description: "lane number"
412
+ }).option("name", z6.string().optional(), {
413
+ description: "what it is called from now on"
414
+ }).option("position", position, {
415
+ description: "where to move it among the lanes, 1 the first column on the board; the others close up around it"
416
+ }).action(async (opts, ctx) => {
417
+ const change = { name: opts.name, position: opts.position };
418
+ await ctx.journal.record("lane update", null, () => laneUpdate(ctx, opts.lane, change), (record) => laneUpdated(opts.lane, record, change));
419
+ });
420
+ splatesLane.command("remove").use(repository3({ needs: "tasks" })).description("remove a lane, putting its todos back with the untriaged ones; a lane whose last todo merges goes on its own").arg("lane", z6.coerce.number().int().positive(), {
421
+ description: "lane number"
422
+ }).action((opts, ctx) => recordLaneRemove(ctx.journal, ctx.todos, opts.lane, () => laneRemove(ctx, opts.lane)));
423
+
424
+ // projects.ts
425
+ import {
426
+ grantReach,
427
+ listReaches,
428
+ REACHES
429
+ } from "@stringsync/splates-server/projects";
430
+ import { repository as repository4 } from "@stringsync/splates-server/records";
431
+ import { cli as cli6 } from "webappwiz/cmd";
432
+ import { z as z7 } from "zod";
433
+ var splatesProjects = cli6("projects").description("the other splates projects on this machine, and what agents of each may do in the others through splates: `read` their tasks, todos and lanes; add `todos` to their untriaged lane, as every project may until granted otherwise; or hand their `tasks` to agents and orchestrate them. Any repo command runs on another project as `splates --project <slug> <command>`, refused where it needs more than that project granted").fallback("list");
434
+ splatesProjects.command("list").use(repository4({ needs: "read" })).description("print each other project: its slug, its root, what it may do here, and what this one may do there").option("json", z7.boolean(), { default: false, description: "emit JSON" }).action((opts, ctx) => listReaches(ctx, { json: opts.json }));
435
+ splatesProjects.command("grant").use(repository4({ needs: "own" })).description("let agents of another project do as much as `read`, `todos` or `tasks` here, each including those before it; the user's call, so an agent runs it only when the user asks it to").arg("project", z7.string(), { description: "the other project's slug" }).arg("reach", z7.enum(REACHES), {
436
+ description: "read, todos or tasks"
437
+ }).action((opts, ctx) => grantReach(ctx, opts.project, opts.reach));
438
+
439
+ // queue.ts
440
+ import { listQueue, moveQueued } from "@stringsync/splates-server/workers";
441
+ import { cli as cli7 } from "webappwiz/cmd";
442
+ import { z as z8 } from "zod";
443
+ var DEV2 = {
444
+ default: "",
445
+ description: "the `splates server` to ask, a URL such as http://localhost:4310; by default the one an agent runs under, from SPLATES_DEV"
446
+ };
447
+ var splatesQueue = cli7("queue").description("the agents waiting for a slot on a worker of the running `splates server`: each one another agent started (with `splates add --agent`, or a lane's orchestrator handing out todos), started or resumed while every worker it can run on is full, waits here, first come, first served, and starts once a slot frees, as when an agent left idle 30 minutes stops; none is ever failed for waiting. An agent you start waits only while no worker it can run on is up, as with this machine's own removed or none on the platform its project requires");
448
+ splatesQueue.command("list").description("list the agents waiting for a slot, first in line first: position, project, task, conversation, how long each has waited and why (no free slot, no worker up, or a platform no worker runs, as `needs macOS`)").option("dev", z8.string(), DEV2).option("json", z8.boolean(), { default: false, description: "emit JSON" }).action((opts, ctx) => listQueue({
449
+ dev: opts.dev,
450
+ json: opts.json,
451
+ log: ctx.log,
452
+ ps: ctx.ps,
453
+ send: ctx.fetch
454
+ }));
455
+ splatesQueue.command("move").description("move an agent waiting for a slot to another place in line, from 1 for the next to start, then list the queue").arg("which", z8.string(), {
456
+ description: "the task it works, or its conversation's id"
457
+ }).arg("position", z8.coerce.number().int(), {
458
+ description: "where in line to put it, from 1"
459
+ }).option("dev", z8.string(), DEV2).option("json", z8.boolean(), { default: false, description: "emit JSON" }).action((opts, ctx) => moveQueued({
460
+ which: opts.which,
461
+ position: opts.position,
462
+ dev: opts.dev,
463
+ json: opts.json,
464
+ log: ctx.log,
465
+ ps: ctx.ps,
466
+ send: ctx.fetch
467
+ }));
468
+
469
+ // setup.ts
470
+ import { dirname } from "node:path";
471
+ import { SYSTEM_STORES as SYSTEM_STORES2 } from "@stringsync/splates-server/creds";
472
+ import { splatesHome as splatesHome2 } from "@stringsync/splates-server/models";
473
+ import { Projects } from "@stringsync/splates-server/projects";
474
+ import {
475
+ SETUP_AREAS,
476
+ Setup,
477
+ setupProjectAt
478
+ } from "@stringsync/splates-server/setup";
479
+ import {
480
+ fetchWorkerBudget,
481
+ fetchWorkers,
482
+ putWorkerLimit
483
+ } from "@stringsync/splates-server/workers";
484
+ import { color as color2 } from "webappwiz/log";
485
+ async function setup(deps, opts) {
486
+ const machine = await setupOf(deps);
487
+ const { log } = deps;
488
+ if (opts.step !== "") {
489
+ machine.step(opts.step);
490
+ if (opts.skip) {
491
+ await machine.skip(opts.step);
492
+ } else if (opts.unskip) {
493
+ await machine.unskip(opts.step);
494
+ }
495
+ }
496
+ const progress = await machine.read();
497
+ if (opts.step === "") {
498
+ if (opts.json) {
499
+ log.info(JSON.stringify(progress, null, 2));
500
+ return;
501
+ }
502
+ printProgress(log, progress);
503
+ return;
504
+ }
505
+ const step = progress.areas.flatMap((area) => area.steps).find((each) => each.id === opts.step);
506
+ if (opts.json) {
507
+ log.info(JSON.stringify(step, null, 2));
508
+ return;
509
+ }
510
+ printStep(log, step);
511
+ }
512
+ async function setupOf(deps) {
513
+ const { fs, ps, secrets = SYSTEM_STORES2, fetch } = deps;
514
+ const root = deps.project === undefined ? await repoRoot(ps) : (await Projects.ofMachine(deps).named(deps.project)).root;
515
+ return new Setup({
516
+ fs,
517
+ ps,
518
+ home: splatesHome2(ps),
519
+ device: secrets.device,
520
+ fetch,
521
+ workers: deps.ps.env("SPLATES_DEV") ? () => fetchWorkers({ dev: "", log: deps.log, ps: deps.ps }) : undefined,
522
+ workerBudget: deps.ps.env("SPLATES_DEV") ? {
523
+ report: () => fetchWorkerBudget({ dev: "", log: deps.log, ps: deps.ps }),
524
+ set: (limit, dollars) => putWorkerLimit(limit, dollars, {
525
+ dev: "",
526
+ log: deps.log,
527
+ ps: deps.ps
528
+ })
529
+ } : undefined,
530
+ project: root === null ? undefined : setupProjectAt(root, { fs, ps })
531
+ }, SETUP_AREAS);
532
+ }
533
+ async function repoRoot(ps) {
534
+ const { exitCode, stdout } = await ps.spawnCapture([
535
+ "git",
536
+ "-C",
537
+ ps.cwd(),
538
+ "rev-parse",
539
+ "--path-format=absolute",
540
+ "--git-common-dir"
541
+ ]);
542
+ return exitCode === 0 ? dirname(stdout.trim()) : null;
543
+ }
544
+ var MARKS = {
545
+ done: color2.green("✓"),
546
+ skipped: color2.dim("-"),
547
+ ready: color2.yellow("○"),
548
+ waiting: color2.dim("○")
549
+ };
550
+ function printProgress(log, progress) {
551
+ for (const area of progress.areas) {
552
+ const counts = [
553
+ `${area.done}/${area.steps.length} done`,
554
+ area.skipped > 0 ? `${area.skipped} skipped` : ""
555
+ ].filter(Boolean);
556
+ log.info(`${color2.bold(area.title)} ${color2.dim(counts.join(", "))}`);
557
+ for (const step of area.steps) {
558
+ const notes = [
559
+ step.optional ? "optional" : "",
560
+ step.status === "waiting" ? `after ${step.after.join(", ")}` : "",
561
+ step.problem ?? ""
562
+ ].filter(Boolean);
563
+ log.info(` ${MARKS[step.status]} ${step.id} ${step.title}${notes.length > 0 ? color2.dim(` ${notes.join("; ")}`) : ""}`);
564
+ }
565
+ }
566
+ const next = progress.areas.flatMap((area) => area.steps).find((step) => step.id === progress.next);
567
+ log.info(next === undefined ? color2.green("nothing left to set up") : `next: ${next.title} ${color2.dim(`splates setup ${next.id}`)}`);
568
+ }
569
+ function printStep(log, step) {
570
+ log.info(`${color2.bold(step.title)} ${color2.dim(`${step.status}${step.optional ? ", optional" : ""}`)}`);
571
+ log.info(step.why);
572
+ if (step.detail !== null) {
573
+ log.info(step.detail);
574
+ }
575
+ log.info(`you: ${step.user}`);
576
+ log.info(`agent: ${step.agent}`);
577
+ if (step.after.length > 0) {
578
+ log.info(`after: ${step.after.join(", ")}`);
579
+ }
580
+ if (step.problem !== null) {
581
+ log.info(`${color2.red("problem:")} ${step.problem}`);
582
+ }
583
+ }
584
+
585
+ // slots.ts
586
+ import { Slots } from "@stringsync/splates-server/ports";
587
+ import { cli as cli8 } from "webappwiz/cmd";
588
+ import { z as z9 } from "zod";
589
+ var splatesSlots = cli8("slots").description("the words `splates server --slot` hands out on this machine, each held by the tree that claimed it: a task's worktree holds its word until the task merges or is removed, which stops its server and gives the word back");
590
+ splatesSlots.command("list").description("print each claimed word, the tree holding it, its server's pid while one runs, and whether it is orphaned: its tree gone, or a splates repository's main tree, which every agent shares").option("json", z9.boolean(), { default: false, description: "emit JSON" }).action(async (opts, ctx) => {
591
+ const held = await Slots.ofMachine(ctx).status();
592
+ if (opts.json) {
593
+ ctx.log.info(JSON.stringify(held.map(({ slot, tree, pid, orphaned }) => ({
594
+ word: slot.word,
595
+ tree,
596
+ pid,
597
+ orphaned
598
+ }))));
599
+ return;
600
+ }
601
+ if (held.length === 0) {
602
+ ctx.log.info("no slot is claimed");
603
+ return;
604
+ }
605
+ ctx.log.info(held.map(heldLine).join(`
606
+ `));
607
+ });
608
+ splatesSlots.command("prune").description("free every orphaned word, stopping its server, so another tree can claim it; the slots a task's worktree holds are that task's, and left alone. The way to free a slot: never kill another tree's server or edit ~/.splates/slots.json").action(async (_opts, ctx) => {
609
+ const freed = await Slots.ofMachine(ctx).prune();
610
+ if (freed.length === 0) {
611
+ ctx.log.info("nothing to prune: each claimed word is held by a task's worktree");
612
+ return;
613
+ }
614
+ ctx.log.info(freed.map(({ slot, tree, pid }) => `freed ${slot.word} ${slot.emoji} from ${tree}${pid === null ? "" : `, its server (pid ${pid}) stopped`}`).join(`
615
+ `));
616
+ });
617
+ function heldLine({ slot, tree, pid, orphaned }) {
618
+ const server = pid === null ? "no server" : `server pid ${pid}`;
619
+ return `${slot.word} ${slot.emoji} ${tree} ${server}${orphaned ? " orphaned: `splates slots prune` frees it" : ""}`;
620
+ }
621
+
622
+ // todo.ts
623
+ import {
624
+ addedFacts,
625
+ recordUpdate,
626
+ todoAdd,
627
+ todoList,
628
+ todoRelease,
629
+ todoRemove,
630
+ todoShow,
631
+ todosNamed,
632
+ todoTake,
633
+ todoUpdate
634
+ } from "@stringsync/splates-server/board";
635
+ import { commaList as commaList3 } from "@stringsync/splates-server/comma-list";
636
+ import { fail as fail4 } from "@stringsync/splates-server/exit";
637
+ import { repository as repository5 } from "@stringsync/splates-server/records";
638
+ import {
639
+ DEFAULT_TIMEOUT as DEFAULT_TIMEOUT2,
640
+ todoWait,
641
+ untilAfter as untilAfter2
642
+ } from "@stringsync/splates-server/tasks";
643
+ import { cli as cli9 } from "webappwiz/cmd";
644
+ import { Duration as Duration2 } from "webappwiz/time";
645
+ import { z as z10 } from "zod";
646
+ var splatesTodo = cli9("todo").description("defer work for later instead of growing the task you are in: `merge` recommends the next one when a task lands");
647
+ splatesTodo.command("add").use(repository5({ needs: "todos" })).description("note something to do later and move on; run from a worktree, it records the task it came up in").arg("subject", z10.string(), { description: "what is left to do, in a line" }).arg("text", z10.string(), {
648
+ default: "",
649
+ description: "whatever more there is to say about it, in markdown"
650
+ }).option("position", position, {
651
+ description: "where it goes in the list, 1 at the top, pushing those from there down (default: the bottom)"
652
+ }).option("file", z10.string(), {
653
+ default: "",
654
+ description: "files to attach, comma separated: copied under .git/splates/todos/<id>/"
655
+ }).option("blocked-by", z10.string(), {
656
+ default: "",
657
+ description: "ids of the todos blocking it, comma separated: each has to merge before this one starts"
658
+ }).option("lane", z10.string(), {
659
+ default: "",
660
+ description: "the lane it goes in, at the bottom: a lane's number, or `new` for one of its own (default: none, untriaged)"
661
+ }).option("agent", z10.string(), {
662
+ default: "",
663
+ description: "the agent on the roster (`splates agents`) that should work it, which Start on the `splates server` board offers first"
664
+ }).option("model", z10.string(), {
665
+ default: "",
666
+ description: "with --agent: the model, by its id or name on the roster"
667
+ }).option("effort", z10.string(), {
668
+ default: "",
669
+ description: "with --agent: the thinking level, one the agent offers (default: the agent's own)"
670
+ }).action(async (opts, ctx) => {
671
+ if (ctx.elsewhere !== null && opts.lane !== "") {
672
+ fail4("not_granted", "a todo from another project goes in its untriaged lane, for whoever orchestrates it there to place: leave --lane off", { subject: opts.subject });
673
+ }
674
+ const from = await here(ctx);
675
+ const assignee = await assigneeIn(ctx, opts.agent, opts.model, opts.effort);
676
+ const origin = ctx.elsewhere === null ? undefined : {
677
+ project: ctx.elsewhere,
678
+ task: ctx.project === undefined ? null : ctx.service.taskFor(await ctx.git.currentBranch(ctx.ps.cwd()))
679
+ };
680
+ await ctx.journal.record("todo add", from, () => todoAdd(ctx, opts.subject, from, {
681
+ text: opts.text,
682
+ position: opts.position,
683
+ files: commaList3(opts.file),
684
+ blockedBy: todoIds(opts["blocked-by"]),
685
+ lane: laneIn(opts.lane) ?? null,
686
+ assignee,
687
+ origin
688
+ }), addedFacts);
689
+ });
690
+ splatesTodo.command("list").use(repository5({ needs: "read" })).description("every todo, top of the list first, with the task it came from and the task that took it up").option("json", z10.boolean(), { default: false, description: "emit JSON" }).option("open", z10.boolean(), {
691
+ default: false,
692
+ description: "only todos no task has taken up: the ones free to pick up"
693
+ }).option("ready", z10.boolean(), {
694
+ default: false,
695
+ description: "only todos free to start now: nobody has taken them, and nothing blocking them is still open"
696
+ }).action((opts, ctx) => todoList(ctx, {
697
+ json: opts.json,
698
+ open: opts.open,
699
+ ready: opts.ready
700
+ }));
701
+ splatesTodo.command("show").use(repository5({ needs: "read" })).description("one todo in full: where it stands, its files, and its detail").arg("id", z10.coerce.number().int().positive(), { description: "todo id" }).option("json", z10.boolean(), { default: false, description: "emit JSON" }).action((opts, ctx) => todoShow(ctx, opts.id, { json: opts.json }));
702
+ splatesTodo.command("update").use(repository5({ needs: "tasks" })).description("say what a todo is in other words, move it up or down the list, or attach and drop files; it keeps its id").arg("id", z10.coerce.number().int().positive(), { description: "todo id" }).arg("text", z10.string(), {
703
+ default: "",
704
+ description: "whatever more there is to say about it, in markdown; leave out to keep the detail"
705
+ }).option("subject", z10.string(), {
706
+ default: "",
707
+ description: "what is left to do, in a line; leave out to keep it"
708
+ }).option("position", position, {
709
+ description: "where to move it in the list, 1 at the top; the others close up around it"
710
+ }).option("file", z10.string(), {
711
+ default: "",
712
+ description: "files to attach, comma separated"
713
+ }).option("remove-file", z10.string(), {
714
+ default: "",
715
+ description: "attached files to drop, comma separated, by path or stored name"
716
+ }).option("blocked-by", z10.string(), {
717
+ default: "",
718
+ description: "ids of more todos blocking it, comma separated: each has to merge before this one starts; refused when that makes a loop"
719
+ }).option("remove-blocked-by", z10.string(), {
720
+ default: "",
721
+ description: "ids of todos no longer blocking it, comma separated"
722
+ }).option("lane", z10.string(), {
723
+ default: "",
724
+ description: "move it to a lane, at the bottom: a lane's number, `new` for one of its own, or `none` to put it back with the untriaged"
725
+ }).option("agent", z10.string(), {
726
+ default: "",
727
+ description: "the agent on the roster (`splates agents`) that should work it, which Start on the `splates server` board offers first, or `none`"
728
+ }).option("model", z10.string(), {
729
+ default: "",
730
+ description: "with --agent: the model, by its id or name on the roster"
731
+ }).option("effort", z10.string(), {
732
+ default: "",
733
+ description: "with --agent: the thinking level, one the agent offers (default: the agent's own)"
734
+ }).action(async (opts, ctx) => {
735
+ const assignee = await assigneeIn(ctx, opts.agent, opts.model, opts.effort);
736
+ await recordUpdate(ctx.journal, ctx.todos, opts.id, () => todoUpdate(ctx, opts.id, {
737
+ subject: opts.subject,
738
+ text: opts.text,
739
+ position: opts.position,
740
+ files: commaList3(opts.file),
741
+ removeFiles: commaList3(opts["remove-file"]),
742
+ blockedBy: todoIds(opts["blocked-by"]),
743
+ removeBlockedBy: todoIds(opts["remove-blocked-by"]),
744
+ lane: laneIn(opts.lane),
745
+ assignee
746
+ }));
747
+ });
748
+ splatesTodo.command("take").use(repository5({ needs: "own" })).description("take up todos for the task whose worktree you are in, as when they turn out to be part of its work: merging removes them, removing the task puts them back").rest("ids", z10.coerce.number().int().positive(), {
749
+ description: "todo ids"
750
+ }).action(async (opts, ctx) => {
751
+ const task = await here(ctx);
752
+ await ctx.journal.record("todo take", task, () => todoTake(ctx, opts.ids, task), (taken) => ({ todos: todosNamed(taken) }));
753
+ });
754
+ splatesTodo.command("release").use(repository5({ needs: "own" })).description("put taken todos back on the list, as when a task lands without finishing them: `splates todo update` the leftover first; from a worktree, only its own task's").rest("ids", z10.coerce.number().int().positive(), {
755
+ description: "todo ids"
756
+ }).action(async (opts, ctx) => {
757
+ const task = await here(ctx);
758
+ await ctx.journal.record("todo release", task, () => todoRelease(ctx, opts.ids, task), (released) => ({ todos: todosNamed(released) }));
759
+ });
760
+ splatesTodo.command("remove").use(repository5({ needs: "tasks" })).description("drop a todo that was done some other way or no longer applies").arg("id", z10.coerce.number().int().positive(), { description: "todo id" }).action((opts, ctx) => ctx.journal.record("todo remove", null, () => todoRemove(ctx, opts.id), (removed) => ({ todos: todosNamed([removed]) })));
761
+ splatesTodo.command("wait").use(repository5({ needs: "read" })).description("block until a todo leaves the list, which it does when the task that took it merges: for a todo blocked by one another lane is on; ends early when that task is escalated, and gives up with `timeout`").arg("id", z10.coerce.number().int().positive(), { description: "todo id" }).option("timeout-secs", z10.coerce.number(), {
762
+ default: DEFAULT_TIMEOUT2.secs,
763
+ description: "how long to wait before giving up"
764
+ }).action(async (opts, ctx) => {
765
+ const timeout = Duration2.secs(opts["timeout-secs"]);
766
+ const todo = (await ctx.todos.all()).find((each) => each.id === opts.id);
767
+ await ctx.journal.waiting("todo wait", null, {
768
+ todo: opts.id,
769
+ subject: todo?.subject ?? null,
770
+ until: untilAfter2(timeout)
771
+ }, () => todoWait(ctx, opts.id, { timeout }));
772
+ });
773
+
774
+ // tunnel.ts
775
+ import { repository as repository6 } from "@stringsync/splates-server/records";
776
+ import { savedTunnelOf } from "@stringsync/splates-server/server";
777
+ import {
778
+ API_TOKEN,
779
+ tunnelRemove,
780
+ tunnelSetup
781
+ } from "@stringsync/splates-server/tunnel";
782
+ import { cli as cli10 } from "webappwiz/cmd";
783
+ import { z as z11 } from "zod";
784
+ var splatesTunnel = cli10("tunnel").description("reach `splates server` from elsewhere: a Cloudflare tunnel with Cloudflare Access in front, set up once per machine");
785
+ splatesTunnel.command("setup").use(repository6({ needs: "own" })).description(`make a Cloudflare tunnel to \`splates server\`, the DNS record routing <host> to it, and an Access application letting in only --email, through the API with the token kept as the cred ${API_TOKEN}, then save them for this machine, so a bare \`splates server\` comes up tunneled; run again, it changes only what is not so already. Without that token it requests it on the Creds page of \`splates server\`, with how to make it, and exits \`requested\`: \`splates creds wait ${API_TOKEN}\`, then run it again`).arg("host", z11.string(), {
786
+ description: "the public host name, in a zone of the account, as in splates.example.com"
787
+ }).option("email", z11.string(), {
788
+ default: "",
789
+ description: "comma-separated email addresses Access lets in"
790
+ }).option("name", z11.string(), {
791
+ default: "splates",
792
+ description: "what the tunnel, the Access application and its policy are called"
793
+ }).option("team", z11.string(), {
794
+ default: "",
795
+ description: "the Zero Trust team to make, as in <team>.cloudflareaccess.com, when the account has none yet"
796
+ }).action(async (opts, ctx) => ctx.journal.record("tunnel setup", null, async () => tunnelSetup({
797
+ ...opts,
798
+ creds: ctx.creds,
799
+ task: await here(ctx),
800
+ saved: savedTunnelOf(ctx),
801
+ log: ctx.log,
802
+ fetch: ctx.fetch
803
+ })));
804
+ splatesTunnel.command("status").description("print the tunnel `splates tunnel setup` saved for this machine, which a bare `splates server` runs, and whether its token is in the system's secret store").option("json", z11.boolean(), { default: false, description: "emit JSON" }).action(async (opts, ctx) => {
805
+ const saved = savedTunnelOf(ctx);
806
+ const record = await saved.read();
807
+ const token = record === null ? false : await saved.token() !== undefined;
808
+ if (opts.json) {
809
+ ctx.log.info(JSON.stringify(record === null ? null : { ...record, token }));
810
+ return;
811
+ }
812
+ if (record === null) {
813
+ ctx.log.info("no tunnel saved: `splates tunnel setup <host> --email <you>` makes one");
814
+ return;
815
+ }
816
+ ctx.log.info([
817
+ `host: https://${record.host}`,
818
+ `team: ${record.team}.cloudflareaccess.com`,
819
+ `aud: ${record.aud}`,
820
+ `tunnel: ${record.tunnel}`,
821
+ `token: ${token ? "in the device's store" : "missing: run `splates tunnel setup` again"}`
822
+ ].join(`
823
+ `));
824
+ });
825
+ splatesTunnel.command("remove").use(repository6({ needs: "own" })).description(`delete what \`splates tunnel setup\` and \`splates server --slot\` made in Cloudflare by what they saved: each tunnel, the DNS record routing to it, the Access applications and their policy, then forget the saved files and the tunnels' tokens; the API token kept as ${API_TOKEN} stays. Run again after a failure, it finishes what is left`).action(async (_opts, ctx) => ctx.journal.record("tunnel remove", null, async () => tunnelRemove({
826
+ saved: savedTunnelOf(ctx),
827
+ slots: slotTunnelsOf(ctx),
828
+ creds: ctx.creds,
829
+ task: await here(ctx),
830
+ fs: ctx.fs,
831
+ log: ctx.log,
832
+ fetch: ctx.fetch
833
+ })));
834
+
835
+ // worker.ts
836
+ import {
837
+ addWorker,
838
+ drainWorker,
839
+ listWorkers,
840
+ removeWorker,
841
+ setWorkerSlots,
842
+ wakeWorker,
843
+ workerJoin,
844
+ workerRecipe,
845
+ workerRun
846
+ } from "@stringsync/splates-server/workers";
847
+ import { cli as cli11 } from "webappwiz/cmd";
848
+ import { z as z12 } from "zod";
849
+ var splatesWorker = cli11("worker").description("the machines that run tasks for `splates server`: each dials out to its coordinator and runs what it is given, agents, terminals and the git and process reads around them, on its own machine");
850
+ splatesWorker.command("run").description("run a worker on this machine until stopped: it dials the coordinator, by default the `splates server` on this machine through a socket in ~/.splates only you may open, and dials again whenever the link drops, while what it runs carries on; it stops reading a process's output while the coordinator is away too long, so nothing is lost. Once its agents have moved off it, a coordinator on another build has it restart on that build, through `bunx @stringsync/splates@<build>`, as it does when it refuses this worker for another protocol; one that says no build has it exit `outdated`").option("coordinator", z12.string(), {
851
+ default: "",
852
+ description: "the coordinator to dial: a `splates server` URL, whose /api/workers it opens, or unix:<path>; by default this machine's"
853
+ }).option("slots", z12.coerce.number().int().min(0), {
854
+ default: 0,
855
+ description: "how many tasks this worker takes at once; by default one for each core and 4 GB of memory it has, whichever is fewer, as a VM such as WSL2 sees them"
856
+ }).option("id", z12.string(), {
857
+ default: "",
858
+ description: "what the coordinator knows this worker by, kept across restarts so it picks up where it was; by default the machine's host name"
859
+ }).option("join", z12.string(), {
860
+ default: "",
861
+ description: "a join token from `splates worker add` on the coordinator, spent to join it before dialing; the key it answers is kept in this machine's system store, or read from SPLATES_WORKER_KEY on one without"
862
+ }).option("ephemeral", z12.boolean(), {
863
+ default: false,
864
+ description: "keep the key --join answers in memory alone, for a machine destroyed rather than restarted, as one a worker provider makes"
865
+ }).option("key-file", z12.boolean(), {
866
+ default: false,
867
+ description: "keep the key --join answers in ~/.splates/worker.env, which only this user may read, rather than the system store, for a machine whose store is locked or missing at boot, as WSL's is with no one signed in; read it from there on each run after"
868
+ }).option("quiet", z12.boolean(), {
869
+ default: false,
870
+ description: "say only what went wrong, not each time it joins or loses its coordinator, as the worker `splates server` starts beside itself does"
871
+ }).action((opts, ctx) => workerRun({
872
+ coordinator: opts.coordinator,
873
+ slots: opts.slots,
874
+ id: opts.id,
875
+ join: opts.join,
876
+ ephemeral: opts.ephemeral,
877
+ keyFile: opts["key-file"],
878
+ log: opts.quiet ? { info: () => {}, error: (...said) => ctx.log.error(...said) } : ctx.log,
879
+ ps: ctx.ps,
880
+ fs: ctx.fs
881
+ }));
882
+ splatesWorker.command("join").description("spend a join token from `splates worker add` and keep the key it answers, as `splates worker run --join` does, then exit, for an installer that leaves running the worker to a service").option("coordinator", z12.string(), {
883
+ description: "the coordinator to join: its `splates server` URL"
884
+ }).option("join", z12.string(), {
885
+ description: "the join token `splates worker add` printed there"
886
+ }).option("id", z12.string(), {
887
+ default: "",
888
+ description: "what the coordinator knows this worker by; by default the machine's host name"
889
+ }).option("key-file", z12.boolean(), {
890
+ default: false,
891
+ description: "keep the key in ~/.splates/worker.env, which only this user may read, rather than the system store, for `splates worker run --key-file` to read"
892
+ }).action((opts, ctx) => workerJoin({
893
+ coordinator: opts.coordinator,
894
+ join: opts.join,
895
+ id: opts.id,
896
+ keyFile: opts["key-file"],
897
+ log: ctx.log,
898
+ ps: ctx.ps,
899
+ fs: ctx.fs
900
+ }));
901
+ splatesWorker.command("recipe").description("print the shell script, run as root on a fresh Debian or Ubuntu machine, that installs what a Linux worker needs: bun, git, the shared browser and this build of splates, for the user `splates`. It is what a provider's snapshot is built from, and what the Windows installer runs in WSL").action((_opts, ctx) => workerRecipe({ log: ctx.log }));
902
+ splatesWorker.command("list").description("list the workers the running `splates server` has: kind (local over its socket, registered otherwise), platform, build, protocol, slots used and total, health with when it was last heard, tasks and $/hour, then any it refused for speaking another protocol").option("dev", z12.string(), {
903
+ default: "",
904
+ description: "the `splates server` to ask, a URL such as http://localhost:4310; by default the one an agent runs under, from SPLATES_DEV"
905
+ }).option("json", z12.boolean(), { default: false, description: "emit JSON" }).action((opts, ctx) => listWorkers({
906
+ dev: opts.dev,
907
+ json: opts.json,
908
+ log: ctx.log,
909
+ ps: ctx.ps,
910
+ send: ctx.fetch
911
+ }));
912
+ var dev = {
913
+ default: "",
914
+ description: "the `splates server` to ask, a URL such as http://localhost:4310; by default the one an agent runs under, from SPLATES_DEV"
915
+ };
916
+ splatesWorker.command("add").description("add a worker to the running `splates server`: prints a join token, single use and good for 15 minutes, for `splates worker run --join <token>` on the machine to join, which gets a key of its own that `splates worker remove` revokes; with --local N, makes this machine's own worker run N agents at once and take new ones again, adding it back if it was removed; with --provider, has the `workerProvider` in splates.config.ts make one, booted from the snapshot splates builds for it, which joins on its own and whose machine `splates worker remove` destroys").option("local", z12.coerce.number().int().min(0), {
917
+ default: 0,
918
+ description: "this machine's own worker, with this many slots"
919
+ }).option("provider", z12.boolean(), {
920
+ default: false,
921
+ description: "have the server's worker provider make the worker, of its cheapest type unless --type says"
922
+ }).option("type", z12.string(), {
923
+ default: "",
924
+ description: "the worker provider's type to make, as it names it; implies --provider"
925
+ }).option("dev", z12.string(), dev).action((opts, ctx) => addWorker({
926
+ local: opts.local,
927
+ provider: opts.provider,
928
+ type: opts.type,
929
+ dev: opts.dev,
930
+ log: ctx.log,
931
+ ps: ctx.ps,
932
+ send: ctx.fetch
933
+ }));
934
+ splatesWorker.command("drain").description("take no new agents on a worker, letting those on it run to their end, but for one asking you something in a task, which saves its task and lets its slot go, its question kept for you to answer and carry it on elsewhere; --undo takes new ones again").arg("id", z12.string(), {
935
+ description: "the worker, as `splates worker list` names it"
936
+ }).option("undo", z12.boolean(), {
937
+ default: false,
938
+ description: "take new agents again"
939
+ }).option("dev", z12.string(), dev).action((opts, ctx) => drainWorker({
940
+ id: opts.id,
941
+ undo: opts.undo,
942
+ dev: opts.dev,
943
+ log: ctx.log,
944
+ ps: ctx.ps,
945
+ send: ctx.fetch
946
+ }));
947
+ splatesWorker.command("wake").description("wake a worker asleep at home, a Windows PC that joined in WSL2: a worker awake on its network sends the Wake-on-LAN packet. Placement wakes one by itself when agents queue, before making a worker").arg("id", z12.string(), {
948
+ description: "the worker, as `splates worker list` names it"
949
+ }).option("dev", z12.string(), dev).action((opts, ctx) => wakeWorker({
950
+ id: opts.id,
951
+ dev: opts.dev,
952
+ log: ctx.log,
953
+ ps: ctx.ps,
954
+ send: ctx.fetch
955
+ }));
956
+ splatesWorker.command("slots").description("set how many agents a worker runs at once, from now on, over what it started with").arg("id", z12.string(), {
957
+ description: "the worker, as `splates worker list` names it"
958
+ }).arg("slots", z12.coerce.number().int().min(1), {
959
+ description: "how many at once"
960
+ }).option("dev", z12.string(), dev).action((opts, ctx) => setWorkerSlots({
961
+ id: opts.id,
962
+ slots: opts.slots,
963
+ dev: opts.dev,
964
+ log: ctx.log,
965
+ ps: ctx.ps,
966
+ send: ctx.fetch
967
+ }));
968
+ splatesWorker.command("remove").description("remove a worker, revoking its key: one with agents on it drains and is removed once they end; --force ends them now, frees their slots, and places each again on another worker, to carry on its task from the checkpoint it last pushed. This machine's own worker, `local`, stops taking agents, its agents running to their end, and leaves this machine only coordinating until `splates worker add --local <slots>`; it takes no --force").arg("id", z12.string(), {
969
+ description: "the worker, as `splates worker list` names it"
970
+ }).option("force", z12.boolean(), {
971
+ default: false,
972
+ description: "end the agents on it now rather than wait for them"
973
+ }).option("dev", z12.string(), dev).action((opts, ctx) => removeWorker({
974
+ id: opts.id,
975
+ force: opts.force,
976
+ dev: opts.dev,
977
+ log: ctx.log,
978
+ ps: ctx.ps,
979
+ send: ctx.fetch
980
+ }));
981
+
982
+ // splates.ts
983
+ function projectArgs(argv) {
984
+ const [first, second, ...rest] = argv;
985
+ if (first === "--project" && second !== undefined) {
986
+ return { project: second, argv: rest };
987
+ }
988
+ if (first?.startsWith("--project=")) {
989
+ return {
990
+ project: first.slice("--project=".length),
991
+ argv: argv.slice(1)
992
+ };
993
+ }
994
+ return { argv };
995
+ }
996
+ var splates = cli12("splates").use(exits());
997
+ splates.command("init").use(repository7({ creates: true, needs: "own" })).description("make this repository a splates project: set up its records under .git/splates when it has none, install the splates skill of this release with `bunx skills add`, and register it in ~/.splates/projects.json, so `splates server` serves it beside every other; run again, it changes only what is not so already").action((_opts, ctx) => init(ctx));
998
+ splates.command("update").use(repository7({ needs: "own" })).description("bring this repository up to the newest splates on npm: run `update` on that release when it is newer than this one, which installs its splates skill with `bunx skills add` over the copy there, leaving one that links to your own; commit what changed").action(async (_opts, ctx) => {
999
+ const code = await update({
1000
+ root: ctx.git.root,
1001
+ fs: ctx.fs,
1002
+ ps: ctx.ps,
1003
+ log: ctx.log,
1004
+ newest: () => newest()
1005
+ });
1006
+ if (code !== 0) {
1007
+ ctx.ps.exit(code);
1008
+ }
1009
+ });
1010
+ splates.command("heavy").description("run a heavy command (a full test run, an install) in one of this machine's heavy-job slots, waiting for one to come free first, so agents testing at once queue rather than run out of memory; `SPLATES_HEAVY_JOBS` sets how many run at once, 2 by default").rest("command", z13.string(), {
1011
+ description: "the command and its arguments, after `--`"
1012
+ }).action(async (opts, ctx) => {
1013
+ const code = await runHeavy(ctx, opts.command);
1014
+ if (code !== 0) {
1015
+ ctx.ps.exit(code);
1016
+ }
1017
+ });
1018
+ splates.command("forget").use(repository7({ needs: "own" })).description("take this repository off ~/.splates/projects.json, so `splates server` no longer offers it; its records stay, and `splates init` brings it back").action((_opts, ctx) => forget(ctx));
1019
+ splates.command("add").use(repository7({ needs: "tasks" })).description("start a new task: create branch task/<task>, a worktree at ../<repo>-splates.noindex/<task> and a state record").arg("task", z13.string(), { description: "task name (lowercase-with-dashes)" }).option("base", z13.string(), {
1020
+ default: "",
1021
+ description: "branch this task starts from and merges onto (default: trunk); `task/<other>` stacks this task on that one and lands the work in its worktree"
1022
+ }).option("todo", z13.string(), {
1023
+ default: "",
1024
+ description: "take up these todos, comma separated: their words become the plan's Goal, merging removes them, and removing the task puts them back"
1025
+ }).option("goal", z13.string(), {
1026
+ default: "",
1027
+ description: "one or two lines on what done means, written under `## Goal` in the task's SPLATES.md; required unless --todo seeds the Goal"
1028
+ }).option("agent", z13.string(), {
1029
+ default: "",
1030
+ description: "hand the task to an agent on the roster (`splates agents`), which `splates server` starts in the worktree, told to claim it; the conversation you run this from stays where it is"
1031
+ }).option("model", z13.string(), {
1032
+ default: "",
1033
+ description: "with --agent: the model it works on, by its id or name on the roster (default: the agent's own)"
1034
+ }).option("effort", z13.string(), {
1035
+ default: "",
1036
+ description: "with --agent: the thinking level it works at, one the agent offers (default: the agent's own)"
1037
+ }).option("prompt", z13.string(), {
1038
+ default: "",
1039
+ description: "with --agent: what to tell the agent beyond claiming the task, such as a part's instructions"
1040
+ }).option("over-capacity", z13.boolean(), {
1041
+ default: false,
1042
+ description: "with --agent: hand it out though you have as many tasks out to agents as agentsPerAgent (splates.config.ts) allows; only when the person explicitly asked for more at once"
1043
+ }).action(async (opts, ctx) => {
1044
+ if (opts.agent === "" && (opts.model !== "" || opts.effort !== "" || opts.prompt !== "" || opts["over-capacity"])) {
1045
+ fail5("usage", "--model, --effort, --prompt and --over-capacity go with --agent", { task: opts.task });
1046
+ }
1047
+ if (opts.goal.trim() === "" && opts.todo === "") {
1048
+ fail5("usage", `say what done means: \`splates add ${opts.task} --goal "<one or two lines>"\`, or take todos with --todo`, { task: opts.task });
1049
+ }
1050
+ const dispatch = opts.agent === "" ? undefined : {
1051
+ agent: opts.agent,
1052
+ ...opts.model === "" ? {} : { model: opts.model },
1053
+ ...await effortIn(ctx, opts.agent, opts.effort, {
1054
+ task: opts.task
1055
+ }),
1056
+ ...opts.prompt === "" ? {} : { prompt: opts.prompt }
1057
+ };
1058
+ const agents = dispatch === undefined ? [] : await seeded2(rosterOf2(ctx));
1059
+ const all = await ctx.todos.all();
1060
+ const subjects = todoIds(opts.todo).flatMap((id) => all.find((todo) => todo.id === id)?.subject ?? []);
1061
+ await ctx.journal.record("add", opts.task, () => add(ctx, opts.task, {
1062
+ base: opts.base || undefined,
1063
+ todos: todoIds(opts.todo),
1064
+ goal: opts.goal.trim() || undefined,
1065
+ dispatch,
1066
+ agents,
1067
+ away: ctx.elsewhere !== null,
1068
+ overCapacity: opts["over-capacity"],
1069
+ onTree: dispatch === undefined ? () => follow(ctx, opts.task, { base: opts.base || undefined }) : undefined
1070
+ }), {
1071
+ base: opts.base || ctx.config.trunk,
1072
+ todos: todoIds(opts.todo),
1073
+ goal: opts.goal.trim() || null,
1074
+ subjects,
1075
+ agent: dispatch?.agent ?? null,
1076
+ model: dispatch?.model ?? null
1077
+ });
1078
+ if (ctx.elsewhere !== null) {
1079
+ await serveProject(ctx);
1080
+ }
1081
+ });
1082
+ splates.command("claim").use(repository7({ needs: "own" })).description("resume an existing task: take ownership of its worktree and print its path, status and any half-finished rebase; an escalated task goes back to working; refuses while another agent holds the lease, but takes a stale one silently, so `splates show` first if the tree may not be abandoned").arg("task", z13.string(), { description: "task name" }).action(async (opts, ctx) => {
1083
+ await ctx.journal.record("claim", opts.task, () => claim(ctx, opts.task), (claimed) => claimed);
1084
+ await follow(ctx, opts.task);
1085
+ });
1086
+ splates.command("merge").use(repository7({ needs: "own" })).description("land this worktree's branch on its base (trunk unless created with --base): rebase onto it, run tests on the rebased code, fast-forward it in whichever worktree has it checked out, then discard the worktree, branch and record (linear history, never a merge commit, no flag to skip tests); requires committed work, refusing a dirty worktree").action(async (_opts, ctx) => {
1087
+ const task = await here(ctx);
1088
+ const landing = task === null ? null : await ctx.service.find(task);
1089
+ const facts = {};
1090
+ await ctx.journal.record("merge", task, () => merge(ctx, ctx.ps.cwd(), facts), facts);
1091
+ if (task !== null) {
1092
+ await addFactTodos(ctx, task).catch((error) => ctx.log.error(`could not add todos for what the merge found: ${error}`));
1093
+ await follow(ctx, null, { from: task });
1094
+ }
1095
+ if (landing !== null) {
1096
+ await reportToCaller(ctx, landing);
1097
+ }
1098
+ });
1099
+ splates.command("remove").use(repository7({ needs: "tasks" })).description("discard a task: worktree, branch and state file; cheap and encouraged, since redoing a task against current trunk often beats a hard rebase").arg("task", z13.string(), { description: "task name" }).option("force", z13.boolean(), {
1100
+ default: false,
1101
+ description: "discard even when another agent holds the lease"
1102
+ }).option("reason", z13.optional(z13.enum(REMOVE_REASONS)), {
1103
+ description: "why, for the Stats page: nothing (nothing to change), moot (no longer needed), superseded (done some other way), redo (better redone against current trunk)"
1104
+ }).action(async (opts, ctx) => {
1105
+ const removing = await ctx.service.find(opts.task);
1106
+ await ctx.journal.record("remove", opts.task, () => remove(ctx, opts.task, { force: opts.force, reason: opts.reason }), ({ released }) => ({ why: opts.reason ?? null, released }));
1107
+ await follow(ctx, null, { from: opts.task });
1108
+ await reportToCaller(ctx, removing);
1109
+ });
1110
+ splates.command("list").use(repository7({ needs: "read" })).description("list every task: name, status, lease (held: an agent is on it now; stale: gone quiet, normal for a task mid-edit; none), commits ahead of trunk, age").option("json", z13.boolean(), { default: false, description: "emit JSON" }).option("files", z13.boolean(), {
1111
+ default: false,
1112
+ description: "add each task's Goal and changed files (committed or not), to check for overlap before starting"
1113
+ }).action((opts, ctx) => list(ctx, { json: opts.json, files: opts.files }));
1114
+ splates.command("show").use(repository7({ needs: "read" })).description("read one task without touching it: everything `list` shows for it, plus the SPLATES.md its agent left at the worktree root; takes no lease, so it cannot knock that agent off its own tree").arg("task", z13.string(), { description: "task name" }).option("json", z13.boolean(), { default: false, description: "emit JSON" }).action((opts, ctx) => show(ctx, opts.task, { json: opts.json }));
1115
+ splates.command("wait").use(repository7({ needs: "read" })).description("block until a task stops moving: escalated, or gone (merged or removed), or broken; takes no lease, and gives up with `timeout` rather than waiting forever").arg("task", z13.string(), { description: "task name" }).option("timeout-secs", z13.coerce.number(), {
1116
+ default: DEFAULT_TIMEOUT3.secs,
1117
+ description: "how long to wait before giving up"
1118
+ }).action((opts, ctx) => {
1119
+ const timeout = Duration3.secs(opts["timeout-secs"]);
1120
+ return ctx.journal.waiting("wait", opts.task, { until: untilAfter3(timeout) }, () => wait(ctx, opts.task, { timeout }));
1121
+ });
1122
+ splates.command("log").use(repository7({ needs: "read" })).description("show what has been done here recently: one line per add, claim, merge, remove, escalate, retry, todo and more, with how it ended; outlives the tasks themselves").option("count", z13.coerce.number(), {
1123
+ default: DEFAULT_COUNT,
1124
+ description: "how many entries to show"
1125
+ }).option("json", z13.boolean(), { default: false, description: "emit JSON" }).action((opts, ctx) => showLog(ctx, { count: opts.count, json: opts.json }));
1126
+ splates.command("server").description("serve every splates project on this machine as a web page, from wherever it is run: the page picks the project it shows, adds one by its path, running `splates init` there, and shows its todos and tasks, where todos can be added, updated, reordered or removed; it brings each project's records forward as it opens it").option("port", z13.coerce.number(), {
1127
+ default: DEFAULT_PORT,
1128
+ description: "port to listen on, or the next open one above it"
1129
+ }).option("allow-hosts", z13.string(), {
1130
+ default: "",
1131
+ description: "comma-separated host names the page may also be reached by, like a tunnel's; put Cloudflare Access in front of them with --access-team and --access-aud"
1132
+ }).option("access-team", z13.string(), {
1133
+ default: "",
1134
+ description: "the Cloudflare Access team in front of --allow-hosts, as in <team>.cloudflareaccess.com; with --access-aud, those hosts answer only to a verified Access token, and may then use conversations and terminals"
1135
+ }).option("access-aud", z13.string(), {
1136
+ default: "",
1137
+ description: "the AUD tag of that Access application"
1138
+ }).option("tunnel", z13.string(), {
1139
+ default: "",
1140
+ description: "the public host name of a tunnel to run, with cloudflared or the tunnel provider named by splates.config.ts in the repository it is run in, if any, which is allowed as a host too; refused without --access-team and --access-aud, so a tunnel never exposes the page unauthenticated. With none of the tunnel and Access flags, the tunnel `splates tunnel setup` saved runs, if any"
1141
+ }).option("tunnel-name", z13.string(), {
1142
+ default: "",
1143
+ description: "the named tunnel cloudflared runs for --tunnel, by default the one its own config names; not with a tunnel provider of splates.config.ts"
1144
+ }).option("ports-host", z13.string(), {
1145
+ default: "",
1146
+ description: "the wildcard base host to serve task ports under, saved for this machine so a bare `splates server` serves it too: each port a task's processes listen on opens at https://<task>-<port>.<base>, to whoever Cloudflare Access lets in, and is refused without Access; routing *.<base> here, its DNS and TLS, are yours. `none` forgets it, leaving each task port forwarded to a port on this machine's loopback alone"
1147
+ }).option("worker-slots", z13.coerce.number().int().min(1), {
1148
+ default: LOCAL_SLOTS,
1149
+ description: "how many agents that other agents started (with `splates add --agent`, or a lane's orchestrator handing out todos) this machine's worker runs at once; past them, each new or resumed one waits in the queue, first come, first served, which `splates queue` shows and reorders. An agent you start runs at once on any worker up, taking no slot. Read as the server starts its worker, which a restart from the page leaves running"
1150
+ }).option("slot", z13.boolean(), {
1151
+ default: false,
1152
+ description: "serve this checkout's splates at https://<word>.<zone>, a slot of its own on the zone `splates tunnel setup` uses, behind the same Access policy, so several run side by side and a phone opens each by name: a task's worktree keeps its word, as fox, until the task merges or is removed, which stops its server; run again, it stops the tree's last server first. Refused in a splates repository's main tree, which every agent shares. A slot keeps its own projects in ~/.splates/slots/<word>, apart from yours, starting with a throwaway project there; not with the tunnel and Access flags"
1153
+ }).action((opts, ctx) => serveDev(ctx, opts));
1154
+ splates.command("ports").description("list the ports tasks serve, found by the running `splates server` as an editor finds ports to forward: each port a process in a task's worktree, or under its agent, listens on, with its link through the ports host `splates server --ports-host` saved, behind Cloudflare Access, and its link on this machine; give the user both").arg("task", z13.string(), {
1155
+ default: "",
1156
+ description: "only this task's ports; every task's by default"
1157
+ }).option("dev", z13.string(), {
1158
+ default: "",
1159
+ description: "the `splates server` to ask, a URL such as http://localhost:4310; by default the one an agent runs under, from SPLATES_DEV, else this machine's on its default port"
1160
+ }).option("json", z13.boolean(), { default: false, description: "emit JSON" }).action((opts, ctx) => portsLs({
1161
+ task: opts.task,
1162
+ dev: opts.dev,
1163
+ json: opts.json,
1164
+ log: ctx.log,
1165
+ ps: ctx.ps,
1166
+ send: ctx.fetch
1167
+ }));
1168
+ splates.command("agents").description("print the roster of agents splates can start, kept for every project in ~/.splates/agents.json: each agent's launch command and models, with what each model is good at and what it costs, and the thinking levels `--effort` takes, as the agent offered them when last started, for choosing whom to delegate to; writes the defaults (Claude, Codex) there first when there is none, and `splates server` starts conversations with those it lists").option("json", z13.boolean(), { default: false, description: "emit JSON" }).action(async (opts, ctx) => agents({
1169
+ roster: rosterOf2(ctx),
1170
+ offered: await offeredOf(ctx).all(),
1171
+ log: ctx.log
1172
+ }, { json: opts.json }));
1173
+ splates.command("setup").description("set splates up on this machine, one step at a time: with no step, each area's steps, checked from real state, and the one to do next; with a step, why it matters, the one thing the user does and what the agent does. Skips are kept in ~/.splates/setup.json, so a skipped step stops asking").arg("step", z13.string(), {
1174
+ default: "",
1175
+ description: "a step's id; omit for every area's progress"
1176
+ }).option("json", z13.boolean(), { default: false, description: "emit JSON" }).option("skip", z13.boolean(), {
1177
+ default: false,
1178
+ description: "with a step: stop asking for it"
1179
+ }).option("unskip", z13.boolean(), {
1180
+ default: false,
1181
+ description: "with a step: ask for it again"
1182
+ }).action(async (opts, ctx) => {
1183
+ if (opts.step === "" && (opts.skip || opts.unskip)) {
1184
+ fail5("usage", "--skip and --unskip name a step", {});
1185
+ }
1186
+ if (opts.skip && opts.unskip) {
1187
+ fail5("usage", "--skip or --unskip, not both", { step: opts.step });
1188
+ }
1189
+ await setup(ctx, opts);
1190
+ });
1191
+ splates.command("path").use(repository7({ needs: "read" })).description("print a task's worktree path, or the main tree with no task; names another agent's tree without taking its lease").arg("task", z13.string(), {
1192
+ default: "",
1193
+ description: "task name; omit for the main tree"
1194
+ }).action((opts, ctx) => path(ctx, opts.task || undefined));
1195
+ splates.command("checkpoint").use(repository7({ needs: "own" })).description("save this task to the central remote (`remote` in splates.config.ts) before a stop: commit what is uncommitted as a `WIP: checkpoint` commit, to squash before merging, and push the branch as `splates/<task>`, so a worker that dies loses nothing; with no remote configured, leaves the tree as it is").action(async (_opts, ctx) => {
1196
+ await checkpoint(ctx, ctx.ps.cwd());
1197
+ });
1198
+ splates.command("escalate").use(repository7({ needs: "own" })).description("hand this task to a human and stop: records the reason, drops the lease and leaves the worktree exactly as it is; use instead of resolving a genuine conflict badly just to finish").arg("reason", z13.string(), {
1199
+ description: "why this needs a human; with --review, what to look at before approving"
1200
+ }).option("task", z13.string(), {
1201
+ default: "",
1202
+ description: "task name, when run outside its worktree"
1203
+ }).option("review", z13.boolean(), {
1204
+ default: false,
1205
+ description: "ask a person to approve merging, the reason saying what to look at, approved in chat; refused until the task is ready to merge, with every question under ## Blocked and step under ## Next checked"
1206
+ }).action(async (opts, ctx) => {
1207
+ const task = opts.task || await here(ctx);
1208
+ await ctx.journal.record("escalate", task, () => escalate(ctx, opts.reason, ctx.ps.cwd(), {
1209
+ task: opts.task || undefined,
1210
+ review: opts.review
1211
+ }), (facts) => facts);
1212
+ if (task !== null) {
1213
+ await reportToCaller(ctx, await ctx.service.find(task));
1214
+ }
1215
+ });
1216
+ splates.command("tell").use(repository7({ needs: "tasks" })).description("say something in the conversation of the agent a task was handed to with `splates add --agent` or `splates handoff --relay`, as the user would: how an orchestrator wakes an agent that sits idle, stops it for a change of plans, or passes on what its lane taught it, and how a relaying agent passes on the user's answers; only the conversation that handed the task out may").arg("task", z13.string(), { description: "task name" }).arg("text", z13.string(), { description: "what to say, as markdown" }).option("stop", z13.boolean(), {
1217
+ default: false,
1218
+ description: "stop the turn the agent is running first, as the user's Stop does, then say text: for a change of plans"
1219
+ }).action((opts, ctx) => ctx.journal.record("tell", opts.task, () => tell(ctx, opts.task, opts.text, { stop: opts.stop }), (to) => ({ to })));
1220
+ splates.command("handoff").use(repository7({ needs: "own" })).description("hand the task you are at work on to another agent on the roster (`splates agents`), which `splates server` starts in its worktree, told `[SPLATES HANDOFF <task>]` to claim it and carry on from its SPLATES.md; it answers to the user as you did, or with --relay to you, and this conversation leaves the task, though a lane's orchestrator that started you goes on hearing of it; refused to an agent that answers to another conversation, and for a task another conversation is at work on").arg("task", z13.string(), { description: "task name" }).option("agent", z13.string(), {
1221
+ description: "the agent on the roster to hand it to"
1222
+ }).option("model", z13.string(), {
1223
+ default: "",
1224
+ description: "the model it works on, by its id or name on the roster (default: the agent's own)"
1225
+ }).option("effort", z13.string(), {
1226
+ default: "",
1227
+ description: "the thinking level it works at, one the agent offers (default: the agent's own)"
1228
+ }).option("prompt", z13.string(), {
1229
+ default: "",
1230
+ description: "what to tell the agent beyond taking the task over, such as why it is handed on"
1231
+ }).option("relay", z13.boolean(), {
1232
+ default: false,
1233
+ description: "for work you judged another model fits better: the agent answers to you rather than the user, reporting back with `[SPLATES REPORT <task>]` and told what the user says with `splates tell`; needs `splates server`"
1234
+ }).action(async (opts, ctx) => {
1235
+ const agents = await seeded2(rosterOf2(ctx));
1236
+ const effort = await effortIn(ctx, opts.agent, opts.effort, {
1237
+ task: opts.task
1238
+ });
1239
+ await ctx.journal.record("handoff", opts.task, () => handoff(ctx, opts.task, {
1240
+ agent: opts.agent,
1241
+ ...opts.model === "" ? {} : { model: opts.model },
1242
+ ...effort,
1243
+ ...opts.prompt === "" ? {} : { prompt: opts.prompt }
1244
+ }, agents, { relay: opts.relay }), (picked) => ({
1245
+ agent: picked.agent,
1246
+ model: picked.model ?? null,
1247
+ relay: opts.relay
1248
+ }));
1249
+ await follow(ctx, null, { from: opts.task });
1250
+ });
1251
+ splates.command("attach").use(repository7({ needs: "own" })).description("store screenshots and recordings for this task's questions to show: prints the path each was stored at, under .git/splates, which the `splates server` page shows inline; from a conversation (its $SPLATES_CONVERSATION, or --conversation) they stay with it past the task, otherwise they go when the task merges or is removed, and a task holds at most attachmentCapacity bytes; with no files, lists what it holds. A file the user will come back for on its own is an artifact instead (`splates artifact add`)").arg("files", z13.string(), {
1252
+ default: "",
1253
+ description: "files to store, comma separated"
1254
+ }).option("task", z13.string(), {
1255
+ default: "",
1256
+ description: "task name, when run outside its worktree"
1257
+ }).option("remove", z13.string(), {
1258
+ default: "",
1259
+ description: "files it holds to drop first, comma separated, by path or stored name"
1260
+ }).option("conversation", z13.string(), {
1261
+ default: "",
1262
+ description: "conversation to keep the files with, when $SPLATES_CONVERSATION is not set"
1263
+ }).action(async (opts, ctx) => ctx.journal.record("attach", opts.task || await here(ctx), () => attach(ctx, commaList4(opts.files), ctx.ps.cwd(), {
1264
+ task: opts.task || undefined,
1265
+ remove: commaList4(opts.remove),
1266
+ conversation: opts.conversation || undefined
1267
+ }), (stored) => ({ stored, removed: commaList4(opts.remove) })));
1268
+ splates.command("retry").use(repository7({ needs: "own" })).description("give an escalated task another mergeRetryCount merge attempts and put it back to working; the way out of `budget_exhausted` that is not remove and redo, and only from escalated, so a human has seen the tree first").arg("task", z13.string(), { description: "task name" }).action((opts, ctx) => recordRetry(ctx, opts.task));
1269
+ splates.mount("artifact", splatesArtifact).mount("todo", splatesTodo).mount("lane", splatesLane).mount("projects", splatesProjects).mount("creds", splatesCreds).mount("tunnel", splatesTunnel).mount("slots", splatesSlots).mount("worker", splatesWorker).mount("queue", splatesQueue).mount("budget", splatesBudget).mount("coordinator", splatesCoordinator);
1270
+
1271
+ // main.ts
1272
+ async function main() {
1273
+ if (basename2(process.argv[1] ?? "") === LEGACY) {
1274
+ legacyWarnings.once(LEGACY, "splates");
1275
+ }
1276
+ const ps = systemPs();
1277
+ const { project, argv } = projectArgs(ps.args);
1278
+ await splates.run({ fs: new NodeFs, ps, assets, project }, argv);
1279
+ }
1280
+
1281
+ // index.ts
1282
+ await main();