@stringsync/splates 0.0.53 → 0.0.55

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/README.md +7 -24
  2. package/artifact.d.ts +3 -0
  3. package/budget.d.ts +3 -0
  4. package/creds.d.ts +3 -0
  5. package/flags.d.ts +37 -0
  6. package/handoff.d.ts +13 -0
  7. package/hook.d.ts +3 -0
  8. package/index.d.ts +2 -0
  9. package/index.js +1844 -0
  10. package/lane.d.ts +3 -0
  11. package/notes.d.ts +4 -0
  12. package/package.json +12 -14
  13. package/projects.d.ts +3 -0
  14. package/queue.d.ts +3 -0
  15. package/replica.d.ts +3 -0
  16. package/runners.d.ts +3 -0
  17. package/server.d.ts +6 -0
  18. package/setup.d.ts +22 -0
  19. package/slots.d.ts +3 -0
  20. package/splates.d.ts +10 -0
  21. package/task.d.ts +3 -0
  22. package/tunnel.d.ts +3 -0
  23. package/waiting.d.ts +3 -0
  24. package/worker.d.ts +3 -0
  25. package/artifact.ts +0 -120
  26. package/budget.ts +0 -117
  27. package/checkout.ts +0 -8
  28. package/creds.test.ts +0 -336
  29. package/creds.ts +0 -144
  30. package/dev/dev.ts +0 -86
  31. package/dev/eval.ts +0 -21
  32. package/dev/fix.ts +0 -63
  33. package/dev/index.ts +0 -1
  34. package/dev/prove.ts +0 -38
  35. package/dev/root.ts +0 -7
  36. package/dev/ship.ts +0 -46
  37. package/dev/test.ts +0 -154
  38. package/e2e.test.ts +0 -472
  39. package/flags.ts +0 -157
  40. package/hook.ts +0 -328
  41. package/index.ts +0 -18
  42. package/infra.ts +0 -107
  43. package/lane.test.ts +0 -29
  44. package/lane.ts +0 -172
  45. package/notes.ts +0 -19
  46. package/projects.test.ts +0 -191
  47. package/projects.ts +0 -37
  48. package/queue.ts +0 -82
  49. package/screenshots/annotate.png +0 -0
  50. package/screenshots/demo.png +0 -0
  51. package/screenshots/parallel.png +0 -0
  52. package/screenshots/questions.png +0 -0
  53. package/server.ts +0 -287
  54. package/setup.ts +0 -233
  55. package/slots.ts +0 -65
  56. package/splates.test.ts +0 -338
  57. package/splates.ts +0 -567
  58. package/task.ts +0 -354
  59. package/tunnel.ts +0 -106
  60. package/wait-journal.test.ts +0 -96
  61. package/waiting.ts +0 -59
  62. package/worker-relay.test.ts +0 -233
  63. package/worker.ts +0 -300
package/index.js ADDED
@@ -0,0 +1,1844 @@
1
+ #!/usr/bin/env bun
2
+ // @bun
3
+
4
+ // index.ts
5
+ import { basename as basename2 } from "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
+ // handoff.ts
15
+ import { existsSync } from "node:fs";
16
+ import { dirname, join, resolve } from "node:path";
17
+ var devMounted = { value: false };
18
+ var CHECKOUT_BIN = join("bin", "splates");
19
+ var CHECKOUT_ENTRY = join("packages", "splates", "checkout.ts");
20
+ function checkoutAbove(cwd) {
21
+ for (let dir = resolve(cwd);; dir = dirname(dir)) {
22
+ if (existsSync(join(dir, CHECKOUT_BIN)) && existsSync(join(dir, CHECKOUT_ENTRY))) {
23
+ return dir;
24
+ }
25
+ if (dirname(dir) === dir) {
26
+ return null;
27
+ }
28
+ }
29
+ }
30
+ async function handOffDev(argv, cwd) {
31
+ if (devMounted.value || argv[0] !== "dev") {
32
+ return null;
33
+ }
34
+ const checkout = checkoutAbove(cwd);
35
+ if (checkout === null) {
36
+ console.error("splates dev is only in a splates checkout, and this directory is in none: cd into one and run its bin/splates.");
37
+ return 1;
38
+ }
39
+ const proc = Bun.spawn([join(checkout, CHECKOUT_BIN), ...argv], {
40
+ cwd,
41
+ stdin: "inherit",
42
+ stdout: "inherit",
43
+ stderr: "inherit"
44
+ });
45
+ return await proc.exited;
46
+ }
47
+
48
+ // splates.ts
49
+ import { agents } from "@stringsync/splates-server/agents";
50
+ import { attach } from "@stringsync/splates-server/artifacts";
51
+ import { commaList as commaList5 } from "@stringsync/splates-server/comma-list";
52
+ import { refuseGroomer as refuseGroomer4 } from "@stringsync/splates-server/conversations";
53
+ import { exits, fail as fail9 } from "@stringsync/splates-server/exit";
54
+ import { DEFAULT_COUNT, log as showLog } from "@stringsync/splates-server/log";
55
+ import { merge } from "@stringsync/splates-server/merge";
56
+ import { portsLs } from "@stringsync/splates-server/ports";
57
+ import { forget, init } from "@stringsync/splates-server/projects";
58
+ import {
59
+ MIGRATING,
60
+ MIGRATION_LIMIT,
61
+ migrateRecords,
62
+ repository as repository10
63
+ } from "@stringsync/splates-server/records";
64
+ import {
65
+ newest,
66
+ recordsFirst,
67
+ rosterOf as rosterOf2,
68
+ runHeavy,
69
+ seeded as seeded2,
70
+ update
71
+ } from "@stringsync/splates-server/server";
72
+ import { addFactTasks } from "@stringsync/splates-server/stats";
73
+ import {
74
+ add,
75
+ checkpoint,
76
+ claim,
77
+ DEFAULT_TIMEOUT as DEFAULT_TIMEOUT3,
78
+ follow,
79
+ list,
80
+ path,
81
+ REMOVE_REASONS,
82
+ recordRetry,
83
+ remove,
84
+ serveProject,
85
+ show,
86
+ untilAfter as untilAfter3,
87
+ wait
88
+ } from "@stringsync/splates-server/tasks";
89
+ import { cli as cli16 } from "webappwiz/cmd";
90
+ import { Duration as Duration3 } from "webappwiz/time";
91
+ import { z as z17 } from "zod";
92
+
93
+ // artifact.ts
94
+ import { basename, resolve as resolve2 } from "node:path";
95
+ import {
96
+ addArtifact,
97
+ listArtifacts,
98
+ removeArtifact
99
+ } from "@stringsync/splates-server/artifacts";
100
+ import { commaList as commaList2 } from "@stringsync/splates-server/comma-list";
101
+ import { conversationOf } from "@stringsync/splates-server/conversations";
102
+ import { fail as fail2 } from "@stringsync/splates-server/exit";
103
+ import { repository } from "@stringsync/splates-server/records";
104
+ import { cli } from "webappwiz/cmd";
105
+ import { z as z2 } from "zod";
106
+
107
+ // flags.ts
108
+ import { BACKLOG_LANE } from "@stringsync/splates-server/board";
109
+ import { commaList } from "@stringsync/splates-server/comma-list";
110
+ import { SYSTEM_STORES } from "@stringsync/splates-server/creds";
111
+ import { fail } from "@stringsync/splates-server/exit";
112
+ import {
113
+ Offered,
114
+ pickAgent,
115
+ pickEffort,
116
+ splatesHome
117
+ } from "@stringsync/splates-server/models";
118
+ import { SLOT_POOL } from "@stringsync/splates-server/ports";
119
+ import { rosterOf, seeded } from "@stringsync/splates-server/server";
120
+ import { FsStorage } from "@stringsync/splates-server/storage";
121
+ import { SavedTunnel } from "@stringsync/splates-server/tunnel";
122
+ import { z } from "zod";
123
+ var position = z.coerce.number().int().positive().optional();
124
+ async function assigneeIn(deps, agent, model, effort) {
125
+ if (agent === "") {
126
+ if (model !== "" || effort !== "") {
127
+ fail("usage", "--model and --effort go with --agent", {
128
+ model,
129
+ effort
130
+ });
131
+ }
132
+ return;
133
+ }
134
+ if (agent === "none") {
135
+ return null;
136
+ }
137
+ return pickIn(deps, await seeded(rosterOf(deps)), agent, model, effort);
138
+ }
139
+ async function pickIn(deps, agents, agent, model, effort, data = {}) {
140
+ const picked = pickAgent(agents, agent, model === "" ? undefined : model, data);
141
+ return { ...picked, ...await effortIn(deps, picked.agent, effort, data) };
142
+ }
143
+ async function effortIn(deps, agent, effort, data = {}) {
144
+ if (effort === "") {
145
+ return {};
146
+ }
147
+ const offered = (await offeredOf(deps).all())[agent];
148
+ return { effort: pickEffort(offered, agent, effort, data) };
149
+ }
150
+ function offeredOf({ ps, fs }) {
151
+ return new Offered(new FsStorage(splatesHome(ps), fs).document("offered.json"));
152
+ }
153
+ function slotTunnelsOf({
154
+ ps,
155
+ fs,
156
+ secrets = SYSTEM_STORES
157
+ }) {
158
+ return SLOT_POOL.map(({ word }) => SavedTunnel.in(new FsStorage(`${splatesHome(ps)}/slots/${word}`, fs), secrets.device));
159
+ }
160
+ function laneIn(raw) {
161
+ const value = raw.trim();
162
+ if (value === "") {
163
+ return;
164
+ }
165
+ if (value === "new") {
166
+ return "new";
167
+ }
168
+ if (value === "none") {
169
+ return null;
170
+ }
171
+ if (value === "backlog") {
172
+ return BACKLOG_LANE;
173
+ }
174
+ const id = Number(value);
175
+ if (!Number.isInteger(id) || id < 0) {
176
+ fail("usage", `'${value}' is not a lane: pass its number, new or none`, {
177
+ lane: value
178
+ });
179
+ }
180
+ return id;
181
+ }
182
+ function taskIds(raw) {
183
+ return commaList(raw).map((value) => {
184
+ const id = Number(value);
185
+ if (!Number.isInteger(id) || id <= 0) {
186
+ fail("usage", `'${value}' is not a task number`, { number: value });
187
+ }
188
+ return id;
189
+ });
190
+ }
191
+ async function here({
192
+ service,
193
+ git,
194
+ ps,
195
+ project
196
+ }) {
197
+ return project === undefined ? service.taskFor(await git.currentBranch(ps.cwd())) : null;
198
+ }
199
+
200
+ // artifact.ts
201
+ 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 or a demo step is no artifact: `splates attach` it. The conversation is $SPLATES_CONVERSATION, or --conversation <id> from an agent outside `splates server`");
202
+ var conversation = [
203
+ "conversation",
204
+ z2.string(),
205
+ {
206
+ default: "",
207
+ description: "conversation id, when not run by an agent `splates server` started"
208
+ }
209
+ ];
210
+ 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(), {
211
+ default: "",
212
+ description: "the artifact's name; for one file only"
213
+ }).option(...conversation).action(async (opts, ctx) => {
214
+ const paths = commaList2(opts.files);
215
+ if (paths.length === 0) {
216
+ fail2("usage", "name the files to keep: nothing was changed");
217
+ }
218
+ if (opts.name !== "" && paths.length > 1) {
219
+ fail2("usage", "--name names one file: nothing was changed");
220
+ }
221
+ const id = await conversationOf(ctx.service, opts.conversation);
222
+ const files = await Promise.all(paths.map(async (given) => {
223
+ const path = resolve2(ctx.ps.cwd(), given);
224
+ const bytes = await ctx.fs.readBytes(path).catch(() => null);
225
+ if (bytes === null) {
226
+ fail2("usage", `cannot read ${path}: nothing was changed`, {
227
+ file: path
228
+ });
229
+ }
230
+ return { name: opts.name || basename(path), bytes };
231
+ }));
232
+ const task = await here(ctx);
233
+ for (const file of files) {
234
+ await ctx.journal.record("artifact add", task, () => addArtifact(ctx, id, file), (added) => ({
235
+ name: file.name,
236
+ version: added.version,
237
+ bytes: added.bytes
238
+ }));
239
+ }
240
+ });
241
+ 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) => {
242
+ const id = await conversationOf(ctx.service, opts.conversation);
243
+ await ctx.journal.record("artifact remove", await here(ctx), () => removeArtifact(ctx, id, opts.name), (version) => ({ name: opts.name, version }));
244
+ });
245
+ 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)));
246
+ 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));
247
+
248
+ // budget.ts
249
+ import {
250
+ endBudgetRaise,
251
+ raiseBudget,
252
+ setBudget,
253
+ showBudget
254
+ } from "@stringsync/splates-server/workers";
255
+ import { cli as cli2 } from "webappwiz/cmd";
256
+ import { z as z3 } from "zod";
257
+ var DEV = {
258
+ default: "",
259
+ description: "the `splates server` to ask, a URL such as http://localhost:4310; by default the one an agent runs under, from SPLATES_DEV. With none answering, this machine's own budget file is read and changed"
260
+ };
261
+ var JSON_OPTION = { default: false, description: "emit JSON" };
262
+ var LIMIT = z3.enum(["hour", "day", "month"]);
263
+ var SET_LIMIT = z3.enum(["hour", "day", "month", "runners"]);
264
+ var LIMIT_ARG = {
265
+ description: "hour, the rate cap on what made workers running at once cost an hour; day; or month"
266
+ };
267
+ 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 ($1/h by default), and limits on a day ($15) and a month ($200). 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. No more than 16 runners are up at once by default, those the server made and `splates run` made together, counted from the cloud's own list; past that the queue waits for one to free. Spend is an estimate at each type's list price. Agents' own API spend is not counted here");
268
+ 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({
269
+ dev: opts.dev,
270
+ json: opts.json,
271
+ log: ctx.log,
272
+ fs: ctx.fs,
273
+ ps: ctx.ps,
274
+ send: ctx.fetch
275
+ }));
276
+ splatesBudget.command("set").description("set a limit, in dollars (dollars an hour for hour, a count for runners), then show the budget").arg("limit", SET_LIMIT, {
277
+ description: `${LIMIT_ARG.description}; or runners, the most runners up at once in the account, whoever made them (16 by default)`
278
+ }).arg("dollars", z3.string(), {
279
+ description: "like 5 or 0.25 (a whole number for runners, like 16), or `default` to give it back to its default"
280
+ }).option("dev", z3.string(), DEV).option("json", z3.boolean(), JSON_OPTION).action((opts, ctx) => setBudget({
281
+ limit: opts.limit,
282
+ dollars: opts.dollars,
283
+ dev: opts.dev,
284
+ json: opts.json,
285
+ log: ctx.log,
286
+ fs: ctx.fs,
287
+ ps: ctx.ps,
288
+ send: ctx.fetch
289
+ }));
290
+ 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(), {
291
+ default: "1d",
292
+ description: "how long it lasts, in hours, days or weeks, like 24h, 1d or 2w"
293
+ }).option("dev", z3.string(), DEV).option("json", z3.boolean(), JSON_OPTION).action((opts, ctx) => raiseBudget({
294
+ limit: opts.limit,
295
+ dollars: opts.dollars,
296
+ for: opts.for,
297
+ dev: opts.dev,
298
+ json: opts.json,
299
+ log: ctx.log,
300
+ fs: ctx.fs,
301
+ ps: ctx.ps,
302
+ send: ctx.fetch
303
+ }));
304
+ 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({
305
+ id: opts.id,
306
+ dev: opts.dev,
307
+ json: opts.json,
308
+ log: ctx.log,
309
+ fs: ctx.fs,
310
+ ps: ctx.ps,
311
+ send: ctx.fetch
312
+ }));
313
+
314
+ // creds.ts
315
+ import { DEFAULT_TIMEOUT, PROVIDERS } from "@stringsync/splates-server/creds";
316
+ import { fail as fail3 } from "@stringsync/splates-server/exit";
317
+ import { repository as repository2 } from "@stringsync/splates-server/records";
318
+ import { untilAfter } from "@stringsync/splates-server/tasks";
319
+ import { cli as cli3 } from "webappwiz/cmd";
320
+ import { color } from "webappwiz/log";
321
+ import { Duration } from "webappwiz/time";
322
+ import { z as z4 } from "zod";
323
+ var splatesCreds = cli3("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");
324
+ 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", z4.string(), {
325
+ description: "what it is kept as, an environment variable's name"
326
+ }).option("purpose", z4.string(), {
327
+ default: "",
328
+ description: "why it is needed and what it will be used for, in a sentence the person reads before getting it"
329
+ }).option("instructions", z4.string(), {
330
+ default: "",
331
+ description: "how to get it, in markdown: where to go, what to pick, what to paste"
332
+ }).option("device", z4.boolean(), {
333
+ default: false,
334
+ description: "keep it for every project on this device, as for a person's own account, rather than this project alone"
335
+ }).option("provider", z4.optional(z4.enum(PROVIDERS)), {
336
+ description: "who keeps it: `system`, the machine's own store"
337
+ }).option("replace", z4.boolean(), {
338
+ default: false,
339
+ 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"
340
+ }).action(async (opts, ctx) => {
341
+ const scope = opts.device ? "device" : "project";
342
+ const provider = opts.provider ?? "system";
343
+ const request = await ctx.journal.record("creds request", null, async () => ctx.creds.request(opts.name, {
344
+ scope,
345
+ provider,
346
+ purpose: opts.purpose,
347
+ instructions: opts.instructions,
348
+ task: await here(ctx),
349
+ replace: opts.replace
350
+ }), (asked) => ({ name: opts.name, asked: asked !== null }));
351
+ if (request === null) {
352
+ ctx.log.info(`${color.green("kept")} ${opts.name} in ${ctx.creds.label(scope, provider)}: nothing to ask`);
353
+ return;
354
+ }
355
+ ctx.log.info(`${color.yellow("requested")} ${opts.name} ${color.dim(request.purpose)}`);
356
+ ctx.log.error(`It shows on the Creds page of \`splates server\`. \`splates creds wait ${opts.name}\` until it is saved.`);
357
+ });
358
+ 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", z4.string(), { description: "the cred requested" }).option("timeout-secs", z4.coerce.number(), {
359
+ default: DEFAULT_TIMEOUT.secs,
360
+ description: "how long to wait before giving up"
361
+ }).action(async (opts, ctx) => {
362
+ const timeout = Duration.secs(opts["timeout-secs"]);
363
+ await ctx.journal.waiting("creds wait", null, { name: opts.name, until: untilAfter(timeout) }, async () => {
364
+ const ended = await ctx.creds.wait(opts.name, { timeout });
365
+ if (ended === "dismissed") {
366
+ fail3("dismissed", `${opts.name} was dismissed: carry on without it, or ask the person why`, { name: opts.name });
367
+ }
368
+ });
369
+ ctx.log.info(`${color.green("saved")} ${opts.name}`);
370
+ });
371
+ 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", z4.boolean(), { default: false, description: "emit JSON" }).action(async (opts, ctx) => {
372
+ const open = (await ctx.creds.requests()).filter((each) => each.status === "open");
373
+ const kept = await ctx.creds.kept();
374
+ if (opts.json) {
375
+ ctx.log.info(JSON.stringify({ requested: open, kept }, null, 2));
376
+ return;
377
+ }
378
+ for (const each of open) {
379
+ ctx.log.info(`${color.yellow("requested")} ${each.name} ${color.dim(each.scope)}${each.task === null ? "" : color.dim(` by ${each.task}`)}`);
380
+ if (each.purpose !== "") {
381
+ ctx.log.info(` ${each.purpose}`);
382
+ }
383
+ }
384
+ for (const each of kept) {
385
+ ctx.log.info(`${color.green("kept")} ${each.name} ${color.dim(`${each.provider} ${each.scope}`)}${each.savedBy === null ? "" : color.dim(` saved by ${each.savedBy}`)}`);
386
+ }
387
+ if (open.length === 0 && kept.length === 0) {
388
+ ctx.log.info(color.dim("no creds requested or kept"));
389
+ }
390
+ });
391
+
392
+ // hook.ts
393
+ import { commaList as commaList3 } from "@stringsync/splates-server/comma-list";
394
+ import { fail as fail4 } from "@stringsync/splates-server/exit";
395
+ import {
396
+ HookStore,
397
+ hookAdd,
398
+ hookEdit,
399
+ hookGardener,
400
+ hookGroomer,
401
+ hookList,
402
+ hookPause,
403
+ hookRemove,
404
+ lanesNamed,
405
+ stagesNamed,
406
+ TASK_EVENTS
407
+ } from "@stringsync/splates-server/hooks";
408
+ import { repository as repository3 } from "@stringsync/splates-server/records";
409
+ import { cli as cli4 } from "webappwiz/cmd";
410
+ import { z as z5 } from "zod";
411
+ var splatesHook = cli4("hook").description(`automations: what runs on a task event (${TASK_EVENTS.join(", ")}), as \`splates server\` sees the journal take it, or on a schedule it keeps: an agent told of it; or a shell command. Kept for you in this project's .git/splates, never checked in, since an agent hook spends your tokens; splates.config.ts's \`on\` holds the shell commands a project checks in. None set, nothing runs. The page's Automations tab lists them with when each last ran and for what task`);
412
+ splatesHook.command("list").use(repository3({ needs: "read" })).description("every automation set here, by number, with its trigger").option("json", z5.boolean(), { default: false, description: "emit JSON" }).action((opts, ctx) => hookList({ hooks: storeOf(ctx), log: ctx.log }, { json: opts.json }));
413
+ splatesHook.command("add").use(repository3({ needs: "tasks" })).description("run something on a task event, or on a schedule with `--every` or `--cron` in place of the event: `--agent <a> --prompt <words>` starts that agent in the main tree, told the event as `[SPLATES EVENT <event> #N]`, or a schedule as `[SPLATES SCHEDULE <number>]`, then your words; `--run <command>` runs it via `sh -c` in the main tree, with $SPLATES_EVENT (`schedule` for one), $SPLATES_TASKS and $SPLATES_TASK set. A schedule that came due while `splates server` was down runs once as it starts").arg("event", z5.string().optional(), {
414
+ description: "the task event, as task.created; none for a schedule"
415
+ }).option("stage", z5.string(), {
416
+ default: "",
417
+ description: "only for tasks in these stages, comma separated: backlog, ready, queued, running"
418
+ }).option("lane", z5.string(), {
419
+ default: "",
420
+ description: "only for tasks in these lanes, by number, comma separated"
421
+ }).option("every", z5.string(), {
422
+ default: "",
423
+ description: "run every so often, as 30m, 2h or 1d"
424
+ }).option("cron", z5.string(), {
425
+ default: "",
426
+ description: `run on a five-field cron line, in this machine's time, as "0 9 * * 1-5"`
427
+ }).option("agent", z5.string(), {
428
+ default: "",
429
+ description: "the agent to run, as `splates agents` names it"
430
+ }).option("model", z5.string(), {
431
+ default: "",
432
+ description: "its model (default: the agent's own)"
433
+ }).option("effort", z5.string(), {
434
+ default: "",
435
+ description: "its thinking level (default: the agent's own)"
436
+ }).option("prompt", z5.string(), {
437
+ default: "",
438
+ description: "what the agent is told after the event"
439
+ }).option("run", z5.string(), {
440
+ default: "",
441
+ description: "a shell command to run"
442
+ }).action(async (opts, ctx) => {
443
+ const pick = await assigneeIn(ctx, opts.agent, opts.model, opts.effort);
444
+ let hook;
445
+ if (opts.run !== "") {
446
+ if (pick != null || opts.prompt !== "") {
447
+ fail4("usage", "--run goes alone: a shell command runs no agent", {});
448
+ }
449
+ hook = { kind: "run", command: opts.run };
450
+ } else if (pick == null) {
451
+ fail4("usage", "say what runs: --agent with --prompt, or --run", {});
452
+ } else if (opts.prompt === "") {
453
+ fail4("usage", "an agent hook needs --prompt: what to tell it", {});
454
+ } else {
455
+ hook = { kind: "agent", prompt: opts.prompt, ...pick };
456
+ }
457
+ await hookAdd({ hooks: storeOf(ctx), log: ctx.log }, triggerOf(opts), hook);
458
+ });
459
+ splatesHook.command("edit").use(repository3({ needs: "tasks" })).description("change one of your automations, by its number in `splates hook list`, keeping its number, its pause and its last runs. Each flag changes only what it names, as `splates hook add` takes it: `--event`, `--every` or `--cron` sets what sets it off anew, `--stage` or `--lane` alone narrows its event again (`any` for no narrowing), `--prompt` or `--agent` changes what an agent is told or which agent, and `--run` makes it a shell command. The groomer changes through `splates hook groomer`").arg("id", z5.string(), { description: "its number in `splates hook list`" }).option("event", z5.string(), {
460
+ default: "",
461
+ description: "a task event to run on, as task.created, in place of what sets it off now"
462
+ }).option("stage", z5.string(), {
463
+ default: "",
464
+ description: "only for tasks in these stages, comma separated, or `any`: backlog, ready, queued, running"
465
+ }).option("lane", z5.string(), {
466
+ default: "",
467
+ description: "only for tasks in these lanes, by number, comma separated, or `any`"
468
+ }).option("every", z5.string(), {
469
+ default: "",
470
+ description: "run every so often, as 30m, 2h or 1d"
471
+ }).option("cron", z5.string(), {
472
+ default: "",
473
+ description: `run on a five-field cron line, in this machine's time, as "0 9 * * 1-5"`
474
+ }).option("agent", z5.string(), {
475
+ default: "",
476
+ description: "the agent to run, as `splates agents` names it"
477
+ }).option("model", z5.string(), {
478
+ default: "",
479
+ description: "its model, with --agent (default: the agent's own)"
480
+ }).option("effort", z5.string(), {
481
+ default: "",
482
+ description: "its thinking level, with --agent (default: the agent's own)"
483
+ }).option("prompt", z5.string(), {
484
+ default: "",
485
+ description: "what the agent is told after the event"
486
+ }).option("run", z5.string(), {
487
+ default: "",
488
+ description: "a shell command to run in place of an agent"
489
+ }).action(async (opts, ctx) => {
490
+ const { id, agent, model, effort, ...change } = opts;
491
+ const pick = await assigneeIn(ctx, agent, model, effort);
492
+ if (pick === null) {
493
+ fail4("usage", "an agent automation needs an agent: name one, or --run", {});
494
+ }
495
+ await hookEdit({ hooks: storeOf(ctx), log: ctx.log }, id, {
496
+ ...change,
497
+ ...pick === undefined ? {} : { pick }
498
+ });
499
+ });
500
+ splatesHook.command("remove").use(repository3({ needs: "tasks" })).description("take away an automation, by its number in `splates hook list`, or every one on an event").arg("id", z5.string(), {
501
+ description: "its number, or a task event, as task.created"
502
+ }).action((opts, ctx) => hookRemove({ hooks: storeOf(ctx), log: ctx.log }, opts.id));
503
+ splatesHook.command("pause").use(repository3({ needs: "tasks" })).description("stop an automation running, keeping it to resume").arg("id", z5.string(), { description: "its number in `splates hook list`" }).action((opts, ctx) => hookPause({ hooks: storeOf(ctx), log: ctx.log }, opts.id, true));
504
+ splatesHook.command("resume").use(repository3({ needs: "tasks" })).description("let a paused automation run again").arg("id", z5.string(), { description: "its number in `splates hook list`" }).action((opts, ctx) => hookPause({ hooks: storeOf(ctx), log: ctx.log }, opts.id, false));
505
+ splatesHook.command("groomer").use(repository3({ needs: "tasks" })).description("turn on the project's groomer, built-in automation `groomer`, or set the agent it runs as, as the Automations page does: on each task deferred to the backlog, and the backlog as `splates server` starts, a conversation of its own per run, archived as the run ends, organizes each task in order and marks it ready, never doing it. `--off` turns it off, retiring its conversations").option("agent", z5.string(), {
506
+ default: "",
507
+ description: "the agent it runs as, as `splates agents` names it"
508
+ }).option("model", z5.string(), {
509
+ default: "",
510
+ description: "its model (default: the agent's own)"
511
+ }).option("effort", z5.string(), {
512
+ default: "",
513
+ description: "its thinking level (default: the agent's own)"
514
+ }).option("off", z5.boolean(), {
515
+ default: false,
516
+ description: "turn it off"
517
+ }).action(async (opts, ctx) => {
518
+ const pick = await assigneeIn(ctx, opts.agent, opts.model, opts.effort);
519
+ if (opts.off === (pick != null)) {
520
+ fail4("usage", "say how it runs with --agent, or turn it off with --off", {});
521
+ }
522
+ await hookGroomer({ hooks: storeOf(ctx), log: ctx.log }, pick ?? null);
523
+ });
524
+ splatesHook.command("gardener").use(repository3({ needs: "tasks" })).description("turn on the notes gardener, built-in automation `notes-gardener`, or set the agent it runs as and its schedule, as the Automations page does: on each run, an agent reads each project note no open task names by its path, checks it against the code and the other notes, and drafts the fixes in one task, `notes-garden`: rewriting what went stale, merging notes that say one thing, splitting one that says several, settling a contradiction the code settles and sharpening descriptions, then asks you to review the change rather than merge it, asking about any contradiction the code does not settle. No run starts while `notes-garden` is open. Off by default, since each run reads code for every note on your tokens; it runs every 7d until you set `--every` or `--cron`. `--off` turns it off").option("agent", z5.string(), {
525
+ default: "",
526
+ description: "the agent it runs as, as `splates agents` names it"
527
+ }).option("model", z5.string(), {
528
+ default: "",
529
+ description: "its model (default: the agent's own)"
530
+ }).option("effort", z5.string(), {
531
+ default: "",
532
+ description: "its thinking level (default: the agent's own)"
533
+ }).option("every", z5.string(), {
534
+ default: "",
535
+ description: "run every so often, as 1d (default: as it was, or 7d)"
536
+ }).option("cron", z5.string(), {
537
+ default: "",
538
+ description: `run on a five-field cron line, in this machine's time, as "0 9 * * 1"`
539
+ }).option("off", z5.boolean(), {
540
+ default: false,
541
+ description: "turn it off"
542
+ }).action(async (opts, ctx) => {
543
+ const pick = await assigneeIn(ctx, opts.agent, opts.model, opts.effort);
544
+ if (opts.off === (pick != null)) {
545
+ fail4("usage", "say how it runs with --agent, or turn it off with --off", {});
546
+ }
547
+ await hookGardener({ hooks: storeOf(ctx), log: ctx.log }, pick ?? null, {
548
+ every: opts.every,
549
+ cron: opts.cron
550
+ });
551
+ });
552
+ function triggerOf(opts) {
553
+ const given = [opts.event, opts.every, opts.cron].filter((each) => each !== undefined && each !== "");
554
+ if (given.length !== 1) {
555
+ fail4("usage", "say what sets it off: a task event, --every or --cron, one of them", {});
556
+ }
557
+ if (opts.event === undefined || opts.event === "") {
558
+ if (opts.stage !== "" || opts.lane !== "") {
559
+ fail4("usage", "--stage and --lane narrow a task event, not a schedule", {});
560
+ }
561
+ return opts.every !== "" ? { every: opts.every } : { cron: opts.cron };
562
+ }
563
+ const filter = {};
564
+ if (opts.stage !== "") {
565
+ filter.stages = stagesNamed(commaList3(opts.stage));
566
+ }
567
+ if (opts.lane !== "") {
568
+ filter.lanes = lanesNamed(opts.lane);
569
+ }
570
+ const event = opts.event;
571
+ return Object.keys(filter).length === 0 ? { event } : { event, filter };
572
+ }
573
+ function storeOf(ctx) {
574
+ return new HookStore(ctx.storage);
575
+ }
576
+
577
+ // lane.ts
578
+ import {
579
+ APPROVAL,
580
+ BACKLOG_LANE as BACKLOG_LANE2,
581
+ laneAdd,
582
+ laneList,
583
+ laneRemove,
584
+ laneShow,
585
+ laneUpdate,
586
+ laneUpdated,
587
+ recordLaneRemove
588
+ } from "@stringsync/splates-server/board";
589
+ import { refuseGroomer } from "@stringsync/splates-server/conversations";
590
+ import { UNTRIAGED } from "@stringsync/splates-server/plans";
591
+ import { refuseAgentQueue } from "@stringsync/splates-server/queue";
592
+ import { repository as repository4 } from "@stringsync/splates-server/records";
593
+ import { cli as cli5 } from "webappwiz/cmd";
594
+ import { z as z6 } from "zod";
595
+ var laneNumber = z6.union([
596
+ z6.literal("backlog").transform(() => BACKLOG_LANE2),
597
+ z6.coerce.number().int().nonnegative()
598
+ ]);
599
+ var splatesLane = cli5("lane").description("lanes: named groups of tasks, for you to see related work together on the board; queue one whole with `splates lane queue`. Backlog (lane 0) is permanent and holds unmatched deferred work. Untriaged is groomed into lanes and cannot be queued whole");
600
+ splatesLane.command("list").use(repository4({ needs: "read" })).description("every lane: its name, its queued tasks in order, the started tasks on it, its next task, and what blocks that in other lanes").option("json", z6.boolean(), { default: false, description: "emit JSON" }).action((opts, ctx) => laneList(ctx, { json: opts.json }));
601
+ splatesLane.command("show").use(repository4({ needs: "read" })).description("one lane step by step, and what its agent does next: start the next task, or wait for the one blocking it in another lane to land first; `untriaged` lists the tasks in no lane, to place in lanes").arg("lane", z6.union([z6.literal(UNTRIAGED).transform(() => null), laneNumber], {
602
+ error: "pass a lane's number, backlog, or untriaged"
603
+ }), { description: "lane number, `backlog`, or `untriaged`" }).option("json", z6.boolean(), { default: false, description: "emit JSON" }).action((opts, ctx) => laneShow(ctx, opts.lane, { json: opts.json }));
604
+ splatesLane.command("add").use(repository4({ needs: "tasks" })).description("start an empty lane, after the others").arg("name", z6.string(), {
605
+ description: "what its tasks are for, like `Licensing`"
606
+ }).action(async (opts, ctx) => {
607
+ await ctx.journal.record("lane add", null, () => laneAdd(ctx, opts.name), (lane) => ({ lane: lane.id, name: lane.name }));
608
+ });
609
+ splatesLane.command("update").use(repository4({ needs: "tasks" })).description("rename a lane, or move it among the others").arg("lane", laneNumber, {
610
+ description: "lane number"
611
+ }).option("name", z6.string().optional(), {
612
+ description: "what it is called from now on"
613
+ }).option("position", position, {
614
+ description: "where to move it among the lanes, 1 the first column on the board; the others close up around it"
615
+ }).action(async (opts, ctx) => {
616
+ const change = { name: opts.name, position: opts.position };
617
+ await ctx.journal.record("lane update", null, () => laneUpdate(ctx, opts.lane, change), (record) => laneUpdated(opts.lane, record, change));
618
+ });
619
+ splatesLane.command("queue").use(repository4({ needs: "tasks" })).description("queue a lane whole: each of its tasks is queued as soon as nothing blocks it, now and as tasks land, until the lane is empty; a task you take out with `splates queue remove` stays out until you queue it again. Untriaged cannot be queued whole").arg("lane", laneNumber, {
620
+ description: "lane number"
621
+ }).action(async (opts, ctx) => {
622
+ refuseAgentQueue(ctx.service, "lane queue");
623
+ await refuseGroomer(ctx.service, "lane queue");
624
+ const change = { queued: true };
625
+ await ctx.journal.record("lane update", null, () => laneUpdate(ctx, opts.lane, change), (record) => laneUpdated(opts.lane, record, change));
626
+ });
627
+ splatesLane.command("approve").use(repository4({ needs: "tasks" })).description("let a queued lane queue more tasks on its own: it stops when its budget is spent, which starts at its tasks then plus 5, and this adds 10 to what is left; refused from an agent").arg("lane", laneNumber, {
628
+ description: "lane number"
629
+ }).action(async (opts, ctx) => {
630
+ refuseAgentQueue(ctx.service, "lane approve");
631
+ const change = { approve: APPROVAL };
632
+ await ctx.journal.record("lane update", null, () => laneUpdate(ctx, opts.lane, change), (record) => laneUpdated(opts.lane, record, change));
633
+ });
634
+ splatesLane.command("unqueue").use(repository4({ needs: "tasks" })).description("stop queueing a lane whole, taking out of the queue each of its tasks that has yet to start; those under way go on").arg("lane", laneNumber, {
635
+ description: "lane number"
636
+ }).action(async (opts, ctx) => {
637
+ refuseAgentQueue(ctx.service, "lane unqueue");
638
+ const change = { queued: false };
639
+ await ctx.journal.record("lane update", null, () => laneUpdate(ctx, opts.lane, change), (record) => laneUpdated(opts.lane, record, change));
640
+ });
641
+ splatesLane.command("remove").use(repository4({ needs: "tasks" })).description("remove a lane and its tasks, kept to restore; a lane whose last task merges goes on its own; refused from an agent, which asks with a permission card").arg("lane", laneNumber, {
642
+ description: "lane number"
643
+ }).action((opts, ctx) => {
644
+ refuseAgentQueue(ctx.service, "lane remove");
645
+ return recordLaneRemove(ctx.journal, ctx.tasks, opts.lane, () => laneRemove(ctx, opts.lane));
646
+ });
647
+
648
+ // notes.ts
649
+ import { loadConfig } from "@stringsync/splates-server/config";
650
+ import { Notes } from "@stringsync/splates-server/notes";
651
+ async function notes(ctx) {
652
+ const root = ctx.project === undefined ? (await ctx.git.run(ctx.ps.cwd(), "rev-parse", "--show-toplevel")).stdout.trim() : ctx.git.root;
653
+ const config = await loadConfig(root, { fs: ctx.fs, git: ctx.git });
654
+ const found = await new Notes(ctx.fs).list(root, config);
655
+ for (const note of found) {
656
+ ctx.log.info(note.description);
657
+ }
658
+ }
659
+
660
+ // projects.ts
661
+ import {
662
+ grantReach,
663
+ listReaches,
664
+ REACHES
665
+ } from "@stringsync/splates-server/projects";
666
+ import { repository as repository5 } from "@stringsync/splates-server/records";
667
+ import { cli as cli6 } from "webappwiz/cmd";
668
+ import { z as z7 } from "zod";
669
+ 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 and lanes; add tasks to their untriaged lane with `queue`, as every project may until granted otherwise; or run their queue, lanes and hooks with `tasks`. Any repo command runs on another project as `splates --project <slug> <command>`, refused where it needs more than that project granted").fallback("list");
670
+ splatesProjects.command("list").use(repository5({ 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 }));
671
+ splatesProjects.command("grant").use(repository5({ needs: "own" })).description("let agents of another project do as much as `read`, `queue` 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), {
672
+ description: "read, queue or tasks"
673
+ }).action((opts, ctx) => grantReach(ctx, opts.project, opts.reach));
674
+
675
+ // queue.ts
676
+ import { tasksNamed } from "@stringsync/splates-server/board";
677
+ import { refuseGroomer as refuseGroomer2 } from "@stringsync/splates-server/conversations";
678
+ import {
679
+ queueAdd,
680
+ queueList,
681
+ queueMove,
682
+ queueRemove,
683
+ refuseAgentQueue as refuseAgentQueue2
684
+ } from "@stringsync/splates-server/queue";
685
+ import { repository as repository6 } from "@stringsync/splates-server/records";
686
+ import { cli as cli7 } from "webappwiz/cmd";
687
+ import { z as z8 } from "zod";
688
+ var splatesQueue = cli7("queue").description("the queue: the tasks a worker of `splates server` starts as soon as it has a slot free, each after every task it is blocked by, otherwise in the order you set. Only you queue a task, from the backlog or groomed, and an agent asks you to with a queue card; one that names no model runs on the roster's first agent");
689
+ splatesQueue.command("list").use(repository6({ needs: "read" })).description("the queue in the order workers start it, with each task's model and what it waits on: the open tasks it is blocked by, or those removed without merging").option("json", z8.boolean(), { default: false, description: "emit JSON" }).action((opts, ctx) => queueList(ctx, { json: opts.json }));
690
+ splatesQueue.command("add").use(repository6({ needs: "tasks" })).description("queue tasks for a worker to start once nothing they are blocked by is open; refused for one already under way").rest("ids", z8.coerce.number().int().positive(), {
691
+ description: "task numbers"
692
+ }).action(async (opts, ctx) => {
693
+ refuseAgentQueue2(ctx.service, "queue add");
694
+ await refuseGroomer2(ctx.service, "queue add");
695
+ await ctx.journal.record("queue add", await here(ctx), () => queueAdd(ctx, opts.ids), (queued) => ({ tasks: tasksNamed(queued) }));
696
+ });
697
+ splatesQueue.command("remove").use(repository6({ needs: "tasks" })).description("take tasks out of the queue, back to ready when they name a model, else the backlog; the tasks stay").rest("ids", z8.coerce.number().int().positive(), {
698
+ description: "task numbers"
699
+ }).action(async (opts, ctx) => {
700
+ refuseAgentQueue2(ctx.service, "queue remove");
701
+ await ctx.journal.record("queue remove", await here(ctx), () => queueRemove(ctx, opts.ids), (removed) => ({ tasks: tasksNamed(removed) }));
702
+ });
703
+ splatesQueue.command("move").use(repository6({ needs: "tasks" })).description("move a queued task to another place in the queue, from 1 for the next to start; what it is blocked by still goes first. Then lists the queue").arg("id", z8.coerce.number().int().positive(), { description: "task number" }).arg("position", z8.coerce.number().int().positive(), {
704
+ description: "where in the queue to put it, from 1"
705
+ }).action(async (opts, ctx) => {
706
+ refuseAgentQueue2(ctx.service, "queue move");
707
+ return queueMove(ctx, opts.id, opts.position);
708
+ });
709
+
710
+ // replica.ts
711
+ import { dirname as dirname2 } from "node:path";
712
+ import { loadConfig as loadConfig2 } from "@stringsync/splates-server/config";
713
+ import { SYSTEM_STORES as SYSTEM_STORES2 } from "@stringsync/splates-server/creds";
714
+ import { fail as fail5 } from "@stringsync/splates-server/exit";
715
+ import { Projects } from "@stringsync/splates-server/projects";
716
+ import {
717
+ FORMAT,
718
+ ProjectHome,
719
+ repository as repository7
720
+ } from "@stringsync/splates-server/records";
721
+ import {
722
+ openReplica,
723
+ REPLICA_ACCESS_KEY,
724
+ REPLICA_SECRET_KEY,
725
+ Replicator,
726
+ restoreFromReplica,
727
+ statusLines
728
+ } from "@stringsync/splates-server/replica";
729
+ import { cli as cli8 } from "webappwiz/cmd";
730
+ import { Credentials } from "webappwiz/creds";
731
+ import { z as z9 } from "zod";
732
+ var splatesReplica = cli8("replica").description("the copy of this project's records `splates server` keeps a few seconds behind every change, in the bucket or folder `replica` names in splates.config.ts");
733
+ splatesReplica.command("status").use(repository7({ needs: "read" })).description("print where the replica is, when it last held every change, how many changes wait to reach it and since when, and why it is failing if it is; as of the server's last batch, so it reads the same with the server stopped").option("json", z9.boolean(), { default: false, description: "emit JSON" }).action(async (opts, ctx) => {
734
+ const status = await Replicator.recorded(ctx.storage);
735
+ if (opts.json) {
736
+ ctx.log.info(JSON.stringify(status));
737
+ return;
738
+ }
739
+ ctx.log.info(statusLines(status, ctx.config.replica !== null, Date.now()).join(`
740
+ `));
741
+ });
742
+ splatesReplica.command("restore").description("with `splates server` stopped, rebuild this project's records from the replica `replica` names in splates.config.ts, adopting its origin so the server started on them replicates on into it: how a server moves to another machine or comes back after its box is lost. Stops when the project already has records here, unless --force leaves them aside as state.db.moved. The roster and Auto pick rules are not in the replica; set them up again. A bucket's REPLICA_ACCESS_KEY and REPLICA_SECRET_KEY must be filled on this machine first").option("force", z9.boolean(), {
743
+ default: false,
744
+ description: "restore over the records here, leaving them aside"
745
+ }).action(async (opts, ctx) => {
746
+ const root = await mainTree(ctx);
747
+ const projects = Projects.ofMachine(ctx);
748
+ const home = new ProjectHome(root, { ...ctx, projects });
749
+ const stores = ctx.secrets ?? SYSTEM_STORES2;
750
+ const credentials = new Credentials([
751
+ await stores.project(root),
752
+ stores.device
753
+ ]);
754
+ const config = await loadConfig2(root, { fs: ctx.fs });
755
+ if (config.replica === null) {
756
+ fail5("usage", "no replica: name one as `replica` in splates.config.ts first");
757
+ }
758
+ const replica = openReplica(config.replica, {
759
+ root,
760
+ fs: ctx.fs,
761
+ creds: {
762
+ credentials,
763
+ request: async () => {
764
+ throw new Error(`fill ${REPLICA_ACCESS_KEY} and ${REPLICA_SECRET_KEY} with \`wiz creds\` first`);
765
+ }
766
+ }
767
+ });
768
+ try {
769
+ const done = await restoreFromReplica(replica, {
770
+ target: home,
771
+ fs: ctx.fs,
772
+ format: FORMAT,
773
+ force: opts.force
774
+ });
775
+ ctx.log.info([
776
+ `restored ${done.values} record(s) and ${done.logs} log(s) from ${replica.name}, last caught up on ${done.host}`,
777
+ ...done.replaced === null ? [] : [`the records here went aside as ${done.replaced}`],
778
+ `project: ${root} (register its checkout with \`splates init\` if it is new on this machine)`,
779
+ "start `splates server` to serve it and replicate on"
780
+ ].join(`
781
+ `));
782
+ } catch (error) {
783
+ fail5("usage", error instanceof Error ? error.message : String(error));
784
+ }
785
+ });
786
+ splatesReplica.command("retire").use(repository7({ needs: "own" })).description("after the server moved elsewhere (stop it and check `splates replica status` says caught up first), leave this project's records aside as state.db.moved, so a splates run here finds no stale records and says where the server went").arg("to", z9.string(), {
787
+ description: "where the server went, as `droplet.example.com`"
788
+ }).action(async (opts, ctx) => {
789
+ const home = new ProjectHome(ctx.git.root, ctx);
790
+ ctx.dispose();
791
+ const aside = await home.moveAside(opts.to);
792
+ ctx.log.info(`records left aside as ${aside}; the server is at ${opts.to}`);
793
+ });
794
+ async function mainTree(ctx) {
795
+ const { exitCode, stdout } = await ctx.ps.spawnCapture([
796
+ "git",
797
+ "-C",
798
+ ctx.ps.cwd(),
799
+ "rev-parse",
800
+ "--path-format=absolute",
801
+ "--git-common-dir"
802
+ ]);
803
+ if (exitCode !== 0) {
804
+ fail5("usage", `not a git repository: ${ctx.ps.cwd()}`);
805
+ }
806
+ return dirname2(stdout.trim());
807
+ }
808
+
809
+ // runners.ts
810
+ import { join as join2 } from "node:path";
811
+ import { loadConfig as loadConfig3 } from "@stringsync/splates-server/config";
812
+ import { fail as fail6 } from "@stringsync/splates-server/exit";
813
+ import { splatesHome as splatesHome2 } from "@stringsync/splates-server/models";
814
+ import {
815
+ REAPER_FILE,
816
+ withRunners
817
+ } from "@stringsync/splates-server/server";
818
+ import {
819
+ DIRECT_IDLE,
820
+ NoRunner,
821
+ parseTimeout,
822
+ runnerServe,
823
+ sizeLabel
824
+ } from "@stringsync/splates-server/workers";
825
+ import { cli as cli9 } from "webappwiz/cmd";
826
+ import { sleep } from "webappwiz/time";
827
+ import { z as z10 } from "zod";
828
+ var splatesRunners = cli9("runners").description(`the runners \`splates run\` makes for itself in the project's cloud (\`cloud\` in splates.config.ts) when \`splates server\` has none for it: each takes as many commands at once as fit in its CPUs and memory, another is made when none has room, within \`splates budget\`, and each is deleted once idle ${DIRECT_IDLE.mins} minutes`);
829
+ splatesRunners.command("list").description("list them: name, size and price, the address Fly's proxy reaches them at, and busy, idle, or the machine's state").option("json", z10.boolean(), { default: false, description: "emit JSON" }).action(async (opts, ctx) => {
830
+ const runners = await opened(ctx, (each) => each.list());
831
+ if (opts.json) {
832
+ ctx.log.info(JSON.stringify(runners));
833
+ return;
834
+ }
835
+ ctx.log.info(runners.length === 0 ? "no runner is up" : runners.map(runnerLine).join(`
836
+ `));
837
+ });
838
+ splatesRunners.command("reap").description(`delete each runner idle ${DIRECT_IDLE.mins} minutes or more, every idle one once the budget's day or month is spent, and any that never booted; with --watch, again every so often for as long as any is up, which \`splates run\` leaves running behind it, one on this machine at a time, so none is left running unused`).option("watch", z10.string(), {
839
+ default: "",
840
+ description: "how long to wait before each look, as 10m or 90s, looking again for as long as any runner is up"
841
+ }).option("idle", z10.string(), {
842
+ default: "",
843
+ description: `how long a runner must have stood idle, as 10m or 0s; ${DIRECT_IDLE.mins}m by default`
844
+ }).action(async (opts, ctx) => {
845
+ const idle = opts.idle === "" ? DIRECT_IDLE : lengthOf(opts.idle);
846
+ if (opts.watch === "") {
847
+ const reaped = await opened(ctx, (each) => each.reap(idle));
848
+ ctx.log.info(reaped.length === 0 ? "nothing to reap" : reaped.map((line) => `deleted ${line}`).join(`
849
+ `));
850
+ return;
851
+ }
852
+ if (!await watching(ctx)) {
853
+ return;
854
+ }
855
+ const every = lengthOf(opts.watch);
856
+ for (;; ) {
857
+ await sleep(every);
858
+ const left = await opened(ctx, async (each) => {
859
+ await each.reap(idle);
860
+ return (await each.list()).length;
861
+ });
862
+ if (left === 0) {
863
+ return;
864
+ }
865
+ }
866
+ });
867
+ async function watching(ctx) {
868
+ const file = reaperFile(ctx);
869
+ const held = Number((await ctx.fs.read(file).catch(() => "")).trim());
870
+ if (Number.isInteger(held) && held > 0 && held !== process.pid && ctx.ps.alive(held)) {
871
+ return false;
872
+ }
873
+ await ctx.fs.write(file, String(process.pid));
874
+ return true;
875
+ }
876
+ async function opened(ctx, use) {
877
+ const { stdout, exitCode } = await ctx.ps.spawnCapture([
878
+ "git",
879
+ "rev-parse",
880
+ "--show-toplevel"
881
+ ]);
882
+ if (exitCode !== 0) {
883
+ fail6("usage", "run it in a splates project", {});
884
+ }
885
+ const { cloud } = await loadConfig3(stdout.trim(), { fs: ctx.fs });
886
+ if (cloud === null) {
887
+ fail6("no_runner", "this project names no `cloud` in splates.config.ts, so `splates run` makes no runners", {});
888
+ }
889
+ try {
890
+ return await withRunners(ctx, cloud, use);
891
+ } catch (error) {
892
+ if (error instanceof NoRunner) {
893
+ fail6("no_runner", error.message, {});
894
+ }
895
+ throw error;
896
+ }
897
+ }
898
+ function lengthOf(text) {
899
+ return parseTimeout(text) ?? fail6("usage", `${text} is no length of time: name one as 10m or 90s`, {});
900
+ }
901
+ function runnerLine(runner) {
902
+ const size = runner.size === null ? runner.type : sizeLabel(runner.size, ...runner.dollarsPerHour === null ? [] : [runner.dollarsPerHour]);
903
+ return `${runner.id} ${size} ${runner.address ?? "no address yet"} ${runner.state}`;
904
+ }
905
+ function reaperFile(ctx) {
906
+ return join2(splatesHome2(ctx.ps), REAPER_FILE);
907
+ }
908
+ splatesRunners.command("serve").description("serve only job channels at /api/jobs over authenticated WebSockets; for the runner image, with SPLATES_RUNNER_SECRET from its hosting environment").option("port", z10.coerce.number().int().min(0).max(65535), {
909
+ default: 8080,
910
+ description: "HTTP port behind the hosting proxy"
911
+ }).action(({ port }, { ps }) => runnerServe({ port, ps }));
912
+
913
+ // server.ts
914
+ import {
915
+ DEFAULT_PORT,
916
+ serveDev
917
+ } from "@stringsync/splates-server/server";
918
+ import { cli as cli10 } from "webappwiz/cmd";
919
+ import { z as z11 } from "zod";
920
+ var splatesServer = cli10("server").description("the splates server: run it on this machine").fallback("run");
921
+ splatesServer.command("run").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 queued and started tasks, where queued tasks can be added, updated, reordered or removed; it brings each project's records forward as it opens it. A server that crashes starts again after 2s, then 4s, 8s and 16s, and is left down after five crashes in a row that each came within a minute of its start").option("port", z11.coerce.number(), {
922
+ default: DEFAULT_PORT,
923
+ description: "port to listen on, or the next open one above it"
924
+ }).option("allow-hosts", z11.string(), {
925
+ default: "",
926
+ 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"
927
+ }).option("access-team", z11.string(), {
928
+ default: "",
929
+ 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"
930
+ }).option("access-aud", z11.string(), {
931
+ default: "",
932
+ description: "the AUD tag of that Access application"
933
+ }).option("tunnel", z11.string(), {
934
+ default: "",
935
+ 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"
936
+ }).option("tunnel-name", z11.string(), {
937
+ default: "",
938
+ 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"
939
+ }).option("ports-host", z11.string(), {
940
+ default: "",
941
+ 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"
942
+ }).option("keepalive", z11.boolean(), {
943
+ default: false,
944
+ description: "keep a machine left to serve awake while the server runs: on macOS with `caffeinate`, on power or idle, though closing a laptop's lid still sleeps it; on Linux with a systemd-inhibit lock on sleep and idle, which the server serves on without, saying so, where systemd is missing or refuses it. Refused on other platforms"
945
+ }).option("slot", z11.boolean(), {
946
+ default: false,
947
+ 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"
948
+ }).action((opts, ctx) => serveDev(ctx, opts));
949
+
950
+ // setup.ts
951
+ import { dirname as dirname3 } from "node:path";
952
+ import { SYSTEM_STORES as SYSTEM_STORES3 } from "@stringsync/splates-server/creds";
953
+ import { fail as fail7 } from "@stringsync/splates-server/exit";
954
+ import { splatesHome as splatesHome3 } from "@stringsync/splates-server/models";
955
+ import { Projects as Projects2 } from "@stringsync/splates-server/projects";
956
+ import {
957
+ openRepository
958
+ } from "@stringsync/splates-server/records";
959
+ import { Replicator as Replicator2 } from "@stringsync/splates-server/replica";
960
+ import {
961
+ SETUP_AREAS,
962
+ Setup,
963
+ setupProjectAt
964
+ } from "@stringsync/splates-server/setup";
965
+ import {
966
+ fetchWorkerBudget,
967
+ fetchWorkers,
968
+ putWorkerLimit
969
+ } from "@stringsync/splates-server/workers";
970
+ import { color as color2 } from "webappwiz/log";
971
+ async function setup(deps, opts) {
972
+ if (opts.step === "" && (opts.skip || opts.unskip || opts.undo)) {
973
+ fail7("usage", "--skip and --unskip name a step, --undo an area", {});
974
+ }
975
+ if (opts.undo && (opts.skip || opts.unskip)) {
976
+ fail7("usage", "--undo names an area, not a step to skip", {
977
+ area: opts.step
978
+ });
979
+ }
980
+ if (opts.skip && opts.unskip) {
981
+ fail7("usage", "--skip or --unskip, not both", { step: opts.step });
982
+ }
983
+ const machine = await setupOf(deps);
984
+ const { log } = deps;
985
+ if (opts.undo) {
986
+ const plan = await machine.undoPlan(opts.step);
987
+ if (opts.json) {
988
+ log.info(JSON.stringify(plan, null, 2));
989
+ return;
990
+ }
991
+ printUndo(log, plan);
992
+ return;
993
+ }
994
+ if (opts.step !== "") {
995
+ machine.step(opts.step);
996
+ if (opts.skip) {
997
+ await machine.skip(opts.step);
998
+ } else if (opts.unskip) {
999
+ await machine.unskip(opts.step);
1000
+ }
1001
+ }
1002
+ const progress = await machine.read();
1003
+ if (opts.step === "") {
1004
+ if (opts.json) {
1005
+ log.info(JSON.stringify(progress, null, 2));
1006
+ return;
1007
+ }
1008
+ printProgress(log, progress);
1009
+ return;
1010
+ }
1011
+ const step = progress.areas.flatMap((area) => area.steps).find((each) => each.id === opts.step);
1012
+ if (opts.json) {
1013
+ log.info(JSON.stringify(step, null, 2));
1014
+ return;
1015
+ }
1016
+ printStep(log, step);
1017
+ }
1018
+ async function setupOf(deps) {
1019
+ const { fs, ps, secrets = SYSTEM_STORES3, fetch } = deps;
1020
+ const root = deps.project === undefined ? await repoRoot(ps) : (await Projects2.ofMachine(deps).named(deps.project)).root;
1021
+ return new Setup({
1022
+ fs,
1023
+ ps,
1024
+ home: splatesHome3(ps),
1025
+ device: secrets.device,
1026
+ fetch,
1027
+ workers: deps.ps.env("SPLATES_DEV") ? () => fetchWorkers({ dev: "", log: deps.log, ps: deps.ps }) : undefined,
1028
+ workerBudget: deps.ps.env("SPLATES_DEV") ? {
1029
+ report: () => fetchWorkerBudget({ dev: "", log: deps.log, ps: deps.ps }),
1030
+ set: (limit, dollars) => putWorkerLimit(limit, dollars, {
1031
+ dev: "",
1032
+ log: deps.log,
1033
+ ps: deps.ps
1034
+ })
1035
+ } : undefined,
1036
+ project: root === null ? undefined : setupProjectAt(root, { fs, ps }, {
1037
+ replica: () => withRecords(deps, root, ({ storage }) => Replicator2.recorded(storage)),
1038
+ kept: (name) => withRecords(deps, root, async ({ creds }) => await creds.credentials.get(name) !== undefined)
1039
+ })
1040
+ }, SETUP_AREAS);
1041
+ }
1042
+ async function withRecords(deps, root, read) {
1043
+ const records = await openRepository(deps, { at: root });
1044
+ try {
1045
+ return await read(records);
1046
+ } finally {
1047
+ records.dispose();
1048
+ }
1049
+ }
1050
+ async function repoRoot(ps) {
1051
+ const { exitCode, stdout } = await ps.spawnCapture([
1052
+ "git",
1053
+ "-C",
1054
+ ps.cwd(),
1055
+ "rev-parse",
1056
+ "--path-format=absolute",
1057
+ "--git-common-dir"
1058
+ ]);
1059
+ return exitCode === 0 ? dirname3(stdout.trim()) : null;
1060
+ }
1061
+ var MARKS = {
1062
+ done: color2.green("✓"),
1063
+ skipped: color2.dim("-"),
1064
+ ready: color2.yellow("○"),
1065
+ waiting: color2.dim("○")
1066
+ };
1067
+ function printProgress(log, progress) {
1068
+ for (const area of progress.areas) {
1069
+ const counts = [
1070
+ `${area.done}/${area.steps.length} done`,
1071
+ area.skipped > 0 ? `${area.skipped} skipped` : ""
1072
+ ].filter(Boolean);
1073
+ log.info(`${color2.bold(area.title)} ${color2.dim(counts.join(", "))}`);
1074
+ for (const step of area.steps) {
1075
+ const notes = [
1076
+ step.optional ? "optional" : "",
1077
+ step.status === "waiting" ? `after ${step.after.join(", ")}` : "",
1078
+ step.problem ?? "",
1079
+ step.warning === null ? "" : "to put right"
1080
+ ].filter(Boolean);
1081
+ log.info(` ${MARKS[step.status]} ${step.id} ${step.title}${notes.length > 0 ? color2.dim(` ${notes.join("; ")}`) : ""}`);
1082
+ }
1083
+ }
1084
+ const steps = progress.areas.flatMap((area) => area.steps);
1085
+ const next = steps.find((step) => step.id === progress.next);
1086
+ for (const step of steps.filter((each) => each.warning !== null)) {
1087
+ log.info(`${color2.yellow("put right:")} ${step.title} ${color2.dim(`splates setup ${step.id}`)}`);
1088
+ }
1089
+ log.info(next === undefined ? color2.green("nothing left to set up") : `next: ${next.title} ${color2.dim(`splates setup ${next.id}`)}`);
1090
+ }
1091
+ function printStep(log, step) {
1092
+ log.info(`${color2.bold(step.title)} ${color2.dim(`${step.status}${step.optional ? ", optional" : ""}`)}`);
1093
+ log.info(step.why);
1094
+ if (step.detail !== null) {
1095
+ log.info(step.detail);
1096
+ }
1097
+ if (step.warning !== null) {
1098
+ log.info(`${color2.yellow("put right:")} ${step.warning}`);
1099
+ }
1100
+ log.info(`you: ${step.user}`);
1101
+ log.info(`agent: ${step.agent}`);
1102
+ if (step.after.length > 0) {
1103
+ log.info(`after: ${step.after.join(", ")}`);
1104
+ }
1105
+ if (step.problem !== null) {
1106
+ log.info(`${color2.red("problem:")} ${step.problem}`);
1107
+ }
1108
+ }
1109
+ function printUndo(log, plan) {
1110
+ log.info(`${color2.bold(`Undo ${plan.title}`)} ${color2.dim(plan.area)}`);
1111
+ if (plan.refused !== null) {
1112
+ log.info(`${color2.red("refused:")} ${plan.refused}`);
1113
+ return;
1114
+ }
1115
+ for (const step of plan.steps) {
1116
+ const started = step.done ? "" : ` ${color2.yellow("started")}`;
1117
+ log.info(` ${step.title} ${color2.dim(step.id)}${started}`);
1118
+ for (const line of step.removes) {
1119
+ log.info(` removes ${line}`);
1120
+ }
1121
+ for (const line of step.leaves) {
1122
+ log.info(` leaves ${line}`);
1123
+ }
1124
+ }
1125
+ for (const cred of plan.creds) {
1126
+ log.info(cred.keptFor === null ? ` forgets ${cred.name}` : ` keeps ${cred.name} ${color2.dim(`${cred.keptFor} still uses it`)}`);
1127
+ }
1128
+ if (plan.confirm !== null) {
1129
+ log.info(` the page asks for ${plan.confirm} to be typed`);
1130
+ }
1131
+ log.info(color2.dim("the Setup page's Undo does it, after the user confirms"));
1132
+ }
1133
+
1134
+ // slots.ts
1135
+ import { Slots } from "@stringsync/splates-server/ports";
1136
+ import { cli as cli11 } from "webappwiz/cmd";
1137
+ import { z as z12 } from "zod";
1138
+ var splatesSlots = cli11("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");
1139
+ 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", z12.boolean(), { default: false, description: "emit JSON" }).action(async (opts, ctx) => {
1140
+ const held = await Slots.ofMachine(ctx).status();
1141
+ if (opts.json) {
1142
+ ctx.log.info(JSON.stringify(held.map(({ slot, tree, pid, orphaned }) => ({
1143
+ word: slot.word,
1144
+ tree,
1145
+ pid,
1146
+ orphaned
1147
+ }))));
1148
+ return;
1149
+ }
1150
+ if (held.length === 0) {
1151
+ ctx.log.info("no slot is claimed");
1152
+ return;
1153
+ }
1154
+ ctx.log.info(held.map(heldLine).join(`
1155
+ `));
1156
+ });
1157
+ 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) => {
1158
+ const freed = await Slots.ofMachine(ctx).prune();
1159
+ if (freed.length === 0) {
1160
+ ctx.log.info("nothing to prune: each claimed word is held by a task's worktree");
1161
+ return;
1162
+ }
1163
+ ctx.log.info(freed.map(({ slot, tree, pid }) => `freed ${slot.word} ${slot.emoji} from ${tree}${pid === null ? "" : `, its server (pid ${pid}) stopped`}`).join(`
1164
+ `));
1165
+ });
1166
+ function heldLine({ slot, tree, pid, orphaned }) {
1167
+ const server = pid === null ? "no server" : `server pid ${pid}`;
1168
+ return `${slot.word} ${slot.emoji} ${tree} ${server}${orphaned ? " orphaned: `splates slots prune` frees it" : ""}`;
1169
+ }
1170
+
1171
+ // task.ts
1172
+ import {
1173
+ addedFacts,
1174
+ recordUpdate,
1175
+ taskAdd,
1176
+ taskList,
1177
+ taskRemove,
1178
+ taskShow,
1179
+ tasksNamed as tasksNamed2,
1180
+ taskUpdate
1181
+ } from "@stringsync/splates-server/board";
1182
+ import { commaList as commaList4 } from "@stringsync/splates-server/comma-list";
1183
+ import { refuseGroomer as refuseGroomer3 } from "@stringsync/splates-server/conversations";
1184
+ import { fail as fail8 } from "@stringsync/splates-server/exit";
1185
+ import { repository as repository8 } from "@stringsync/splates-server/records";
1186
+ import {
1187
+ DEFAULT_TIMEOUT as DEFAULT_TIMEOUT2,
1188
+ taskWait,
1189
+ untilAfter as untilAfter2
1190
+ } from "@stringsync/splates-server/tasks";
1191
+ import { cli as cli12 } from "webappwiz/cmd";
1192
+ import { Duration as Duration2 } from "webappwiz/time";
1193
+ import { z as z13 } from "zod";
1194
+ var splatesTask = cli12("task").description("the numbered tasks: defer work for later instead of growing the task you are in, and `splates add <slug> --task <n>` starts one; `merge` recommends the next one when a task lands. The person puts tasks in `splates queue`, for workers to start");
1195
+ async function projectFrom(ctx) {
1196
+ if (ctx.elsewhere === null) {
1197
+ return;
1198
+ }
1199
+ return (await ctx.projects.list()).find((each) => each.root === ctx.git.root)?.slug;
1200
+ }
1201
+ function adding(name, description, ready) {
1202
+ splatesTask.command(name).use(repository8({ needs: "queue" })).description(description).arg("subject", z13.string(), {
1203
+ description: "what is left to do, in a line"
1204
+ }).arg("text", z13.string(), {
1205
+ default: "",
1206
+ description: "whatever more there is to say about it, in markdown"
1207
+ }).option("slug", z13.string(), {
1208
+ description: "a short name for the task that takes it (lowercase-with-dashes), naming its branch, worktree and conversation: a few words for what it changes, not the subject cut short"
1209
+ }).option("position", position, {
1210
+ description: "where it goes in the list, 1 at the top, pushing those from there down (default: the bottom)"
1211
+ }).repeated("file", z13.string(), {
1212
+ description: "files to attach, comma separated or the flag repeated: copied under .git/splates/tasks/<n>/"
1213
+ }).repeated("blocked-by", z13.string(), {
1214
+ description: "numbers of the tasks blocking it, comma separated or the flag repeated: each has to merge before this one starts"
1215
+ }).option("lane", z13.string(), {
1216
+ default: "",
1217
+ description: "the lane it goes in, at the bottom: a lane's number, `backlog` for the permanent catch-all, or `new` for one of its own (default: none, untriaged)"
1218
+ }).option("agent", z13.string(), {
1219
+ default: "",
1220
+ description: "the agent on the roster (`splates agents`) that should work it, which Start on the `splates server` board offers first"
1221
+ }).option("model", z13.string(), {
1222
+ default: "",
1223
+ description: "with --agent: the model, by its id or name on the roster"
1224
+ }).option("effort", z13.string(), {
1225
+ default: "",
1226
+ description: "with --agent: the thinking level, one the agent offers (default: the agent's own)"
1227
+ }).action(async (opts, ctx) => {
1228
+ if (ctx.elsewhere !== null && opts.lane !== "") {
1229
+ fail8("not_granted", "a task from another project goes in its untriaged lane, for whoever orchestrates it there to place: leave --lane off", { subject: opts.subject });
1230
+ }
1231
+ const from = await here(ctx);
1232
+ const assignee = await assigneeIn(ctx, opts.agent, opts.model, opts.effort);
1233
+ const origin = ctx.elsewhere === null ? undefined : {
1234
+ project: ctx.elsewhere,
1235
+ task: ctx.project === undefined ? null : ctx.service.taskFor(await ctx.git.currentBranch(ctx.ps.cwd()))
1236
+ };
1237
+ const project = await projectFrom(ctx);
1238
+ await ctx.journal.record("task add", from, () => taskAdd(ctx, opts.subject, from, {
1239
+ slug: opts.slug,
1240
+ text: opts.text,
1241
+ position: opts.position,
1242
+ files: commaList4(opts.file.join(",")),
1243
+ blockedBy: taskIds(opts["blocked-by"].join(",")),
1244
+ lane: laneIn(opts.lane) ?? null,
1245
+ assignee,
1246
+ origin,
1247
+ ready,
1248
+ project
1249
+ }), addedFacts);
1250
+ });
1251
+ }
1252
+ adding("add", "queue a task to do later and move on, groomed, so it goes in ready: you checked no task already asks it, and linked what blocks it; run from a worktree, it records the task it came up in", true);
1253
+ adding("defer", "queue a task as it is, rough, in the backlog for whoever grooms it: to check for one that already asks it, link what blocks it, and mark it ready; run from a worktree, it records the task it came up in", false);
1254
+ splatesTask.command("list").use(repository8({ needs: "read" })).description("every queued task, top of the list first, with the task it came from and the started one that took it up").option("json", z13.boolean(), { default: false, description: "emit JSON" }).option("open", z13.boolean(), {
1255
+ default: false,
1256
+ description: "only tasks no started one has taken up: the ones free to pick up"
1257
+ }).option("ready", z13.boolean(), {
1258
+ default: false,
1259
+ description: "only tasks free to start now: none has taken them, and nothing blocking them is still open"
1260
+ }).action(async (opts, ctx) => taskList(ctx, {
1261
+ json: opts.json,
1262
+ open: opts.open,
1263
+ ready: opts.ready,
1264
+ project: await projectFrom(ctx)
1265
+ }));
1266
+ splatesTask.command("show").use(repository8({ needs: "read" })).description("one task in full: where it stands, its files, and its detail").arg("id", z13.coerce.number().int().positive(), { description: "task number" }).option("json", z13.boolean(), { default: false, description: "emit JSON" }).action(async (opts, ctx) => taskShow(ctx, opts.id, {
1267
+ json: opts.json,
1268
+ project: await projectFrom(ctx),
1269
+ others: (await ctx.projects.list()).filter((project) => project.root !== ctx.git.root).map((project) => project.slug)
1270
+ }));
1271
+ splatesTask.command("update").use(repository8({ needs: "tasks" })).description("say what a queued task is in other words, move it up or down the list, or attach and drop files; it keeps its number").arg("id", z13.coerce.number().int().positive(), { description: "task number" }).arg("text", z13.string(), {
1272
+ default: "",
1273
+ description: "whatever more there is to say about it, in markdown; leave out to keep the detail"
1274
+ }).option("subject", z13.string(), {
1275
+ default: "",
1276
+ description: "what is left to do, in a line; leave out to keep it"
1277
+ }).option("slug", z13.string(), {
1278
+ default: "",
1279
+ description: "a new name for the task that takes it (lowercase-with-dashes); leave out to keep it"
1280
+ }).option("position", position, {
1281
+ description: "where to move it in the list, 1 at the top; the others close up around it"
1282
+ }).repeated("file", z13.string(), {
1283
+ description: "files to attach, comma separated or the flag repeated"
1284
+ }).repeated("remove-file", z13.string(), {
1285
+ description: "attached files to drop, comma separated or the flag repeated, by path or stored name"
1286
+ }).repeated("blocked-by", z13.string(), {
1287
+ description: "numbers of more tasks blocking it, comma separated or the flag repeated: each has to merge before this one starts; refused when that makes a loop"
1288
+ }).repeated("remove-blocked-by", z13.string(), {
1289
+ description: "numbers of tasks no longer blocking it, comma separated or the flag repeated"
1290
+ }).option("lane", z13.string(), {
1291
+ default: "",
1292
+ description: "move it to a lane, at the bottom: a lane's number, `backlog` for the permanent catch-all, `new` for one of its own, or `none` for untriaged"
1293
+ }).option("agent", z13.string(), {
1294
+ default: "",
1295
+ description: "the agent on the roster (`splates agents`) that should work it, which Start on the `splates server` board offers first, or `none`"
1296
+ }).option("model", z13.string(), {
1297
+ default: "",
1298
+ description: "with --agent: the model, by its id or name on the roster"
1299
+ }).option("effort", z13.string(), {
1300
+ default: "",
1301
+ description: "with --agent: the thinking level, one the agent offers (default: the agent's own)"
1302
+ }).option("ready", z13.boolean(), {
1303
+ default: false,
1304
+ description: "mark it groomed, moving it from the backlog to ready: no task already asks it, and what blocks it is linked"
1305
+ }).option("manual", z13.boolean(), {
1306
+ default: false,
1307
+ description: "queue it by hand only: a queued lane passes over it, and it starts once you `splates queue add` it"
1308
+ }).option("auto", z13.boolean(), {
1309
+ default: false,
1310
+ description: "let a queued lane queue it again, undoing --manual"
1311
+ }).action(async (opts, ctx) => {
1312
+ if (opts.manual && opts.auto) {
1313
+ fail8("usage", "pass --manual or --auto, not both", { number: opts.id });
1314
+ }
1315
+ await refuseGroomer3(ctx.service, "task update", await ctx.tasks.find(opts.id));
1316
+ const assignee = await assigneeIn(ctx, opts.agent, opts.model, opts.effort);
1317
+ await recordUpdate(ctx.journal, ctx.tasks, opts.id, () => taskUpdate(ctx, opts.id, {
1318
+ subject: opts.subject,
1319
+ slug: opts.slug,
1320
+ text: opts.text,
1321
+ position: opts.position,
1322
+ files: commaList4(opts.file.join(",")),
1323
+ removeFiles: commaList4(opts["remove-file"].join(",")),
1324
+ blockedBy: taskIds(opts["blocked-by"].join(",")),
1325
+ removeBlockedBy: taskIds(opts["remove-blocked-by"].join(",")),
1326
+ lane: laneIn(opts.lane),
1327
+ assignee,
1328
+ ready: opts.ready,
1329
+ manual: opts.manual ? true : opts.auto ? false : undefined
1330
+ }));
1331
+ });
1332
+ splatesTask.command("remove").use(repository8({ needs: "tasks" })).description("drop a queued task that was done some other way or no longer applies").arg("id", z13.coerce.number().int().positive(), { description: "task number" }).action(async (opts, ctx) => {
1333
+ await refuseGroomer3(ctx.service, "task remove", await ctx.tasks.find(opts.id));
1334
+ await ctx.journal.record("task remove", null, () => taskRemove(ctx, opts.id), (removed) => ({ tasks: tasksNamed2([removed]) }));
1335
+ });
1336
+ splatesTask.command("wait").use(repository8({ needs: "read" })).description("block until a task leaves the queue, which it does when the task that took it merges: for a task blocked by one another lane is on; ends early when that task waits on a person to answer its questions, and gives up with `timeout`").arg("id", z13.coerce.number().int().positive(), { description: "task number" }).option("timeout-secs", z13.coerce.number(), {
1337
+ default: DEFAULT_TIMEOUT2.secs,
1338
+ description: "how long to wait before giving up"
1339
+ }).action(async (opts, ctx) => {
1340
+ const timeout = Duration2.secs(opts["timeout-secs"]);
1341
+ const task = (await ctx.tasks.all()).find((each) => each.id === opts.id);
1342
+ await ctx.journal.waiting("task wait", null, {
1343
+ number: opts.id,
1344
+ subject: task?.subject ?? null,
1345
+ until: untilAfter2(timeout)
1346
+ }, () => taskWait(ctx, opts.id, { timeout }));
1347
+ });
1348
+
1349
+ // tunnel.ts
1350
+ import { repository as repository9 } from "@stringsync/splates-server/records";
1351
+ import { savedTunnelOf } from "@stringsync/splates-server/server";
1352
+ import {
1353
+ API_TOKEN,
1354
+ tunnelRemove,
1355
+ tunnelSetup
1356
+ } from "@stringsync/splates-server/tunnel";
1357
+ import { cli as cli13 } from "webappwiz/cmd";
1358
+ import { z as z14 } from "zod";
1359
+ var splatesTunnel = cli13("tunnel").description("reach `splates server` from elsewhere: a Cloudflare tunnel with Cloudflare Access in front, set up once per machine");
1360
+ splatesTunnel.command("setup").use(repository9({ 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", z14.string(), {
1361
+ description: "the public host name, in a zone of the account, as in splates.example.com"
1362
+ }).option("email", z14.string(), {
1363
+ default: "",
1364
+ description: "comma-separated email addresses Access lets in"
1365
+ }).option("name", z14.string(), {
1366
+ default: "splates",
1367
+ description: "what the tunnel, the Access application and its policy are called"
1368
+ }).option("team", z14.string(), {
1369
+ default: "",
1370
+ description: "the Zero Trust team to make, as in <team>.cloudflareaccess.com, when the account has none yet"
1371
+ }).action(async (opts, ctx) => ctx.journal.record("tunnel setup", null, async () => tunnelSetup({
1372
+ ...opts,
1373
+ creds: ctx.creds,
1374
+ task: await here(ctx),
1375
+ saved: savedTunnelOf(ctx),
1376
+ log: ctx.log,
1377
+ fetch: ctx.fetch
1378
+ })));
1379
+ 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", z14.boolean(), { default: false, description: "emit JSON" }).action(async (opts, ctx) => {
1380
+ const saved = savedTunnelOf(ctx);
1381
+ const record = await saved.read();
1382
+ const token = record === null ? false : await saved.token() !== undefined;
1383
+ if (opts.json) {
1384
+ ctx.log.info(JSON.stringify(record === null ? null : { ...record, token }));
1385
+ return;
1386
+ }
1387
+ if (record === null) {
1388
+ ctx.log.info("no tunnel saved: `splates tunnel setup <host> --email <you>` makes one");
1389
+ return;
1390
+ }
1391
+ ctx.log.info([
1392
+ `host: https://${record.host}`,
1393
+ `team: ${record.team}.cloudflareaccess.com`,
1394
+ `aud: ${record.aud}`,
1395
+ `tunnel: ${record.tunnel}`,
1396
+ `token: ${token ? "in the device's store" : "missing: run `splates tunnel setup` again"}`
1397
+ ].join(`
1398
+ `));
1399
+ });
1400
+ splatesTunnel.command("remove").use(repository9({ 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({
1401
+ saved: savedTunnelOf(ctx),
1402
+ slots: slotTunnelsOf(ctx),
1403
+ creds: ctx.creds,
1404
+ task: await here(ctx),
1405
+ fs: ctx.fs,
1406
+ log: ctx.log,
1407
+ fetch: ctx.fetch
1408
+ })));
1409
+
1410
+ // worker.ts
1411
+ import { splatesHome as splatesHome4 } from "@stringsync/splates-server/models";
1412
+ import {
1413
+ addWorker,
1414
+ drainWorker,
1415
+ FileLogger,
1416
+ listWorkers,
1417
+ removeWorker,
1418
+ setWorkerSlots,
1419
+ WORKER_JOIN,
1420
+ workerJoin,
1421
+ workerRecipe,
1422
+ workerRun
1423
+ } from "@stringsync/splates-server/workers";
1424
+ import { cli as cli15 } from "webappwiz/cmd";
1425
+ import { CompositeLogger } from "webappwiz/log";
1426
+ import { z as z16 } from "zod";
1427
+
1428
+ // waiting.ts
1429
+ import { listQueue, moveQueued } from "@stringsync/splates-server/workers";
1430
+ import { cli as cli14 } from "webappwiz/cmd";
1431
+ import { z as z15 } from "zod";
1432
+ var DEV2 = {
1433
+ default: "",
1434
+ description: "the `splates server` to ask, a URL such as http://localhost:4310; by default the one an agent runs under, from SPLATES_DEV"
1435
+ };
1436
+ var splatesWaiting = cli14("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 tasks), 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");
1437
+ splatesWaiting.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", z15.string(), DEV2).option("json", z15.boolean(), { default: false, description: "emit JSON" }).action((opts, ctx) => listQueue({
1438
+ dev: opts.dev,
1439
+ json: opts.json,
1440
+ log: ctx.log,
1441
+ fs: ctx.fs,
1442
+ ps: ctx.ps,
1443
+ send: ctx.fetch
1444
+ }));
1445
+ splatesWaiting.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", z15.string(), {
1446
+ description: "the task it works, or its conversation's id"
1447
+ }).arg("position", z15.coerce.number().int(), {
1448
+ description: "where in line to put it, from 1"
1449
+ }).option("dev", z15.string(), DEV2).option("json", z15.boolean(), { default: false, description: "emit JSON" }).action((opts, ctx) => moveQueued({
1450
+ which: opts.which,
1451
+ position: opts.position,
1452
+ dev: opts.dev,
1453
+ json: opts.json,
1454
+ log: ctx.log,
1455
+ fs: ctx.fs,
1456
+ ps: ctx.ps,
1457
+ send: ctx.fetch
1458
+ }));
1459
+
1460
+ // worker.ts
1461
+ var splatesWorker = cli15("worker").description("the machines that run tasks for `splates server`: each dials out to it and runs what it is given, agents, terminals and the git and process reads around them, on its own machine");
1462
+ splatesWorker.command("run").description("run a worker on this machine until stopped: it dials `splates server`, by default the one 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 server is away too long, so nothing is lost. Once its agents have moved off it, a server 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("server", z16.string(), {
1463
+ default: "",
1464
+ description: "the `splates server` to dial: its URL, whose /api/workers it opens, or unix:<path>; by default this machine's"
1465
+ }).option("coordinator", z16.string(), {
1466
+ default: "",
1467
+ description: "the old name for --server, still read for workers installed before it, and passed to a build older than it"
1468
+ }).option("slots", z16.coerce.number().int().min(0), {
1469
+ default: 0,
1470
+ 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"
1471
+ }).option("id", z16.string(), {
1472
+ default: "",
1473
+ description: "what the server knows this worker by, kept across restarts so it picks up where it was; by default the id it joined as, kept beside its key, else the machine's host name"
1474
+ }).option("join", z16.string(), {
1475
+ default: "",
1476
+ description: "a join token from `splates worker add` on the server, 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"
1477
+ }).option("ephemeral", z16.boolean(), {
1478
+ default: false,
1479
+ description: "keep the key --join answers in memory alone, for a machine destroyed rather than restarted, as one a worker provider makes"
1480
+ }).option("key-file", z16.boolean(), {
1481
+ default: false,
1482
+ 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"
1483
+ }).option("quiet", z16.boolean(), {
1484
+ default: false,
1485
+ description: "say only what went wrong, not each time it joins or loses its server, as the worker `splates server` starts beside itself does; all of it still goes to worker.log in ~/.splates, the one before it kept as worker.log.1"
1486
+ }).action((opts, ctx) => workerRun({
1487
+ coordinator: opts.server || opts.coordinator,
1488
+ slots: opts.slots,
1489
+ id: opts.id,
1490
+ join: opts.join || ctx.ps.env(WORKER_JOIN) || "",
1491
+ ephemeral: opts.ephemeral,
1492
+ keyFile: opts["key-file"],
1493
+ log: opts.quiet ? quietLog(ctx) : ctx.log,
1494
+ ps: ctx.ps,
1495
+ fs: ctx.fs
1496
+ }));
1497
+ 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("server", z16.string(), {
1498
+ default: "",
1499
+ description: "the `splates server` to join: its URL"
1500
+ }).option("coordinator", z16.string(), {
1501
+ default: "",
1502
+ description: "the old name for --server"
1503
+ }).option("join", z16.string(), {
1504
+ default: "",
1505
+ description: "the join token `splates worker add` printed there; by default read from SPLATES_WORKER_JOIN, which keeps it out of the process list other users of the machine can read"
1506
+ }).option("id", z16.string(), {
1507
+ default: "",
1508
+ description: "what the server knows this worker by, kept beside its key so a change of host name leaves it the same; by default the machine's host name"
1509
+ }).option("key-file", z16.boolean(), {
1510
+ default: false,
1511
+ 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"
1512
+ }).action((opts, ctx) => workerJoin({
1513
+ coordinator: opts.server || opts.coordinator,
1514
+ join: opts.join || ctx.ps.env(WORKER_JOIN) || "",
1515
+ id: opts.id,
1516
+ keyFile: opts["key-file"],
1517
+ log: ctx.log,
1518
+ ps: ctx.ps,
1519
+ fs: ctx.fs
1520
+ }));
1521
+ 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 }));
1522
+ 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", z16.string(), {
1523
+ default: "",
1524
+ description: "the `splates server` to ask, a URL such as http://localhost:4310; by default the one an agent runs under, from SPLATES_DEV"
1525
+ }).option("json", z16.boolean(), { default: false, description: "emit JSON" }).action((opts, ctx) => listWorkers({
1526
+ dev: opts.dev,
1527
+ json: opts.json,
1528
+ log: ctx.log,
1529
+ fs: ctx.fs,
1530
+ ps: ctx.ps,
1531
+ send: ctx.fetch
1532
+ }));
1533
+ var dev = {
1534
+ default: "",
1535
+ description: "the `splates server` to ask, a URL such as http://localhost:4310; by default the one an agent runs under, from SPLATES_DEV"
1536
+ };
1537
+ 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", z16.coerce.number().int().min(0), {
1538
+ default: 0,
1539
+ description: "this machine's own worker, with this many slots"
1540
+ }).option("provider", z16.boolean(), {
1541
+ default: false,
1542
+ description: "have the server's worker provider make the worker, of its cheapest type unless --type says"
1543
+ }).option("type", z16.string(), {
1544
+ default: "",
1545
+ description: "the worker provider's type to make, as it names it; implies --provider"
1546
+ }).option("dev", z16.string(), dev).action((opts, ctx) => addWorker({
1547
+ local: opts.local,
1548
+ provider: opts.provider,
1549
+ type: opts.type,
1550
+ dev: opts.dev,
1551
+ log: ctx.log,
1552
+ fs: ctx.fs,
1553
+ ps: ctx.ps,
1554
+ send: ctx.fetch
1555
+ }));
1556
+ 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", z16.string(), {
1557
+ description: "the worker, as `splates worker list` names it"
1558
+ }).option("undo", z16.boolean(), {
1559
+ default: false,
1560
+ description: "take new agents again"
1561
+ }).option("dev", z16.string(), dev).action((opts, ctx) => drainWorker({
1562
+ id: opts.id,
1563
+ undo: opts.undo,
1564
+ dev: opts.dev,
1565
+ log: ctx.log,
1566
+ fs: ctx.fs,
1567
+ ps: ctx.ps,
1568
+ send: ctx.fetch
1569
+ }));
1570
+ splatesWorker.command("slots").description("set how many agents a worker runs at once, from now on, over what it started with").arg("id", z16.string(), {
1571
+ description: "the worker, as `splates worker list` names it"
1572
+ }).arg("slots", z16.coerce.number().int().min(1), {
1573
+ description: "how many at once"
1574
+ }).option("dev", z16.string(), dev).action((opts, ctx) => setWorkerSlots({
1575
+ id: opts.id,
1576
+ slots: opts.slots,
1577
+ dev: opts.dev,
1578
+ log: ctx.log,
1579
+ fs: ctx.fs,
1580
+ ps: ctx.ps,
1581
+ send: ctx.fetch
1582
+ }));
1583
+ 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", z16.string(), {
1584
+ description: "the worker, as `splates worker list` names it"
1585
+ }).option("force", z16.boolean(), {
1586
+ default: false,
1587
+ description: "end the agents on it now rather than wait for them"
1588
+ }).option("dev", z16.string(), dev).action((opts, ctx) => removeWorker({
1589
+ id: opts.id,
1590
+ force: opts.force,
1591
+ dev: opts.dev,
1592
+ log: ctx.log,
1593
+ fs: ctx.fs,
1594
+ ps: ctx.ps,
1595
+ send: ctx.fetch
1596
+ }));
1597
+ splatesWorker.mount("queue", splatesWaiting);
1598
+ function quietLog(ctx) {
1599
+ const file = new FileLogger(ctx.fs, `${splatesHome4(ctx.ps)}/worker.log`);
1600
+ return new CompositeLogger([
1601
+ { info: () => {}, error: (...said) => ctx.log.error(...said) },
1602
+ file
1603
+ ]);
1604
+ }
1605
+
1606
+ // splates.ts
1607
+ function projectArgs(argv) {
1608
+ const [first, second, ...rest] = argv;
1609
+ if (first === "--project" && second !== undefined) {
1610
+ return { project: second, argv: rest };
1611
+ }
1612
+ if (first?.startsWith("--project=")) {
1613
+ return {
1614
+ project: first.slice("--project=".length),
1615
+ argv: argv.slice(1)
1616
+ };
1617
+ }
1618
+ return { argv };
1619
+ }
1620
+ var splates = cli16("splates").use(exits());
1621
+ splates.command("notes").use(repository10({ needs: "read" })).description("print each project note's frontmatter description, one per line; notesDir in splates.config.ts defaults to notes; an absent directory prints nothing").action((_opts, ctx) => notes(ctx));
1622
+ splates.command("init").use(repository10({ 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));
1623
+ splates.command("migrate").description(`move this repository's records under .git/splates to this splates's format, as \`splates server\` does as it starts: backed up first, the steps run with ${MIGRATION_LIMIT.human()} to finish, and the backup put back if one fails or runs over, with why kept in .git/splates-migration.json; it tries again past a failure kept there, which the server and every other command leave alone. Run it only while no older \`splates server\` serves the repository, which would refuse the records once moved; \`splates update\` restarts one first`).action(async (_opts, ctx) => {
1624
+ const format = await migrateRecords(ctx);
1625
+ if (ctx.ps.env(MIGRATING) === undefined) {
1626
+ ctx.log.info(`splates's records are in format ${format}`);
1627
+ }
1628
+ });
1629
+ splates.command("update").use(recordsFirst()).use(repository10({ 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 moves the records under .git/splates to its format, as `splates migrate` does, unless an older `splates server` serves the repository, which it asks to restart onto that release once its agents have checkpointed, migrating them as it starts; then 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) => {
1630
+ const code = await update({
1631
+ root: ctx.git.root,
1632
+ fs: ctx.fs,
1633
+ ps: ctx.ps,
1634
+ log: ctx.log,
1635
+ newest: () => newest()
1636
+ });
1637
+ if (code !== 0) {
1638
+ ctx.ps.exit(code);
1639
+ }
1640
+ });
1641
+ splates.command("run").description("run a command that needs a machine of its own (a full test run, an install) on a runner, in a checkout of this worktree as it stands, uncommitted edits included, its output streamed back and its exit code yours: one that has joined `splates server` when it has one, else, in a project whose splates.config.ts names a `cloud`, one `splates run` makes there itself, server or no server (`splates runners`), refusing with `no_runner` rather than run here when none can be had, as without the cloud's token or past `splates budget`. A project that names no cloud runs it here, 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, 1 by default. Run on a runner, it changes nothing here: files it writes stay there").option("cpus", z17.coerce.number().positive().optional(), {
1642
+ description: "the CPUs it asks of a runner, which takes as many commands at once as fit; the project's `job` in splates.config.ts, else 4, by default"
1643
+ }).option("memory", z17.string(), {
1644
+ default: "",
1645
+ description: "the memory it asks of a runner, as 8GB or 512MB; the project's `job` in splates.config.ts, else 8GB, by default"
1646
+ }).option("timeout", z17.string(), {
1647
+ default: "",
1648
+ description: "how long it may run, as 30m, 90s or 2h, before it is killed and exits 124, so a hung command holds no runner or slot; the project's `job.timeout` in splates.config.ts, else 30m, by default"
1649
+ }).rest("command", z17.string(), {
1650
+ description: "the command and its arguments, after `--`"
1651
+ }).action(async (opts, ctx) => {
1652
+ const code = await runHeavy(ctx, opts.command, undefined, {
1653
+ ...opts.cpus === undefined ? {} : { cpus: opts.cpus },
1654
+ memory: opts.memory,
1655
+ timeout: opts.timeout
1656
+ });
1657
+ if (code !== 0) {
1658
+ ctx.ps.exit(code);
1659
+ }
1660
+ });
1661
+ splates.command("forget").use(repository10({ 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));
1662
+ splates.command("add").use(repository10({ needs: "tasks" })).description("start a task: a new one, or with --task one from the queue; create branch task/<slug>, a worktree at ../<repo>-splates.noindex/<slug> and a state record").arg("slug", z17.string(), {
1663
+ description: "the task's name, for its branch and worktree (lowercase-with-dashes)"
1664
+ }).option("base", z17.string(), {
1665
+ default: "",
1666
+ 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"
1667
+ }).option("task", z17.string(), {
1668
+ default: "",
1669
+ description: "start this queued task by number: its words become the plan's Goal, merging removes it, and removing this task puts it back where it was"
1670
+ }).option("goal", z17.string(), {
1671
+ default: "",
1672
+ description: "one or two lines on what done means, written under `## Goal` in the task's SPLATES.md; required unless --task seeds the Goal"
1673
+ }).option("agent", z17.string(), {
1674
+ default: "",
1675
+ description: "how `splates server`'s queue hands the task to an agent on the roster (`splates agents`), which it starts in the worktree; refused from an agent, which starts no agents and queues work with `splates task add` instead"
1676
+ }).option("model", z17.string(), {
1677
+ default: "",
1678
+ description: "with --agent: the model it works on, by its id or name on the roster (default: the agent's own)"
1679
+ }).option("effort", z17.string(), {
1680
+ default: "",
1681
+ description: "with --agent: the thinking level it works at, one the agent offers (default: the agent's own)"
1682
+ }).option("prompt", z17.string(), {
1683
+ default: "",
1684
+ description: "with --agent: what to tell the agent beyond claiming the task"
1685
+ }).option("held", z17.string(), {
1686
+ default: "",
1687
+ description: "with --agent: the conversation `splates server`'s queue held for the queued task, which takes the task rather than a new one"
1688
+ }).action(async (opts, ctx) => {
1689
+ await refuseGroomer4(ctx.service, "add");
1690
+ if (opts.agent === "" && (opts.model !== "" || opts.effort !== "" || opts.prompt !== "" || opts.held !== "")) {
1691
+ fail9("usage", "--model, --effort, --prompt and --held go with --agent", {
1692
+ task: opts.slug
1693
+ });
1694
+ }
1695
+ if (opts.goal.trim() === "" && opts.task === "") {
1696
+ fail9("usage", `say what done means: \`splates add ${opts.slug} --goal "<one or two lines>"\`, or start queued tasks with --task`, { task: opts.slug });
1697
+ }
1698
+ const dispatch = opts.agent === "" ? undefined : {
1699
+ agent: opts.agent,
1700
+ ...opts.model === "" ? {} : { model: opts.model },
1701
+ ...await effortIn(ctx, opts.agent, opts.effort, {
1702
+ task: opts.slug
1703
+ }),
1704
+ ...opts.prompt === "" ? {} : { prompt: opts.prompt },
1705
+ ...opts.held === "" ? {} : { held: opts.held }
1706
+ };
1707
+ const agents = dispatch === undefined ? [] : await seeded2(rosterOf2(ctx));
1708
+ const all = await ctx.tasks.all();
1709
+ const subjects = taskIds(opts.task).flatMap((id) => all.find((each) => each.id === id)?.subject ?? []);
1710
+ await ctx.journal.record("add", opts.slug, () => add(ctx, opts.slug, {
1711
+ base: opts.base || undefined,
1712
+ tasks: taskIds(opts.task),
1713
+ goal: opts.goal.trim() || undefined,
1714
+ dispatch,
1715
+ agents,
1716
+ away: ctx.elsewhere !== null,
1717
+ onTree: dispatch === undefined ? () => follow(ctx, opts.slug, { base: opts.base || undefined }) : undefined
1718
+ }), {
1719
+ base: opts.base || ctx.config.trunk,
1720
+ tasks: taskIds(opts.task),
1721
+ goal: opts.goal.trim() || null,
1722
+ subjects,
1723
+ agent: dispatch?.agent ?? null,
1724
+ model: dispatch?.model ?? null
1725
+ });
1726
+ if (ctx.elsewhere !== null) {
1727
+ await serveProject(ctx);
1728
+ }
1729
+ });
1730
+ splates.command("claim").use(repository10({ needs: "own" })).description("resume an existing task: take ownership of its worktree and print its path, status and any half-finished rebase; 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", z17.string(), { description: "task name" }).action(async (opts, ctx) => {
1731
+ await ctx.journal.record("claim", opts.task, () => claim(ctx, opts.task), (claimed) => claimed);
1732
+ await follow(ctx, opts.task);
1733
+ });
1734
+ splates.command("merge").use(repository10({ 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").option("message", z17.string(), {
1735
+ default: "",
1736
+ description: "squash the task's commits into one with this message before landing, saying what the base gains in at least 5 characters"
1737
+ }).action(async (opts, ctx) => {
1738
+ await refuseGroomer4(ctx.service, "merge");
1739
+ const task = await here(ctx);
1740
+ const facts = {};
1741
+ await ctx.journal.record("merge", task, () => merge({ ...ctx, send: ctx.fetch }, ctx.ps.cwd(), facts, {
1742
+ message: opts.message
1743
+ }), facts);
1744
+ if (task !== null) {
1745
+ await addFactTasks(ctx, task).catch((error) => ctx.log.error(`could not queue tasks for what the merge found: ${error}`));
1746
+ await follow(ctx, null, { from: task });
1747
+ }
1748
+ });
1749
+ splates.command("remove").use(repository10({ 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", z17.string(), { description: "task name" }).option("force", z17.boolean(), {
1750
+ default: false,
1751
+ description: "discard even when another agent holds the lease"
1752
+ }).option("reason", z17.optional(z17.enum(REMOVE_REASONS)), {
1753
+ 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), blocked (set aside until what it waits on lands, so back on the list)"
1754
+ }).action(async (opts, ctx) => {
1755
+ await ctx.journal.record("remove", opts.task, () => remove(ctx, opts.task, { force: opts.force, reason: opts.reason }), ({ released }) => ({ why: opts.reason ?? null, released }));
1756
+ await follow(ctx, null, { from: opts.task });
1757
+ });
1758
+ splates.command("list").use(repository10({ needs: "read" })).description("list every started task: its number, 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; `splates task list` lists the queue").option("json", z17.boolean(), { default: false, description: "emit JSON" }).option("files", z17.boolean(), {
1759
+ default: false,
1760
+ description: "add each task's Goal and changed files (committed or not), to check for overlap before starting"
1761
+ }).action((opts, ctx) => list(ctx, { json: opts.json, files: opts.files }));
1762
+ splates.command("show").use(repository10({ 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; for a task no longer live, the newest finished one of that name: how it ended, its base, the commit it landed as, its conversation and the SPLATES.md it ended on").arg("task", z17.string(), { description: "task name" }).option("json", z17.boolean(), { default: false, description: "emit JSON" }).action((opts, ctx) => show(ctx, opts.task, { json: opts.json }));
1763
+ splates.command("wait").use(repository10({ needs: "read" })).description("block until a task stops moving: waiting on a person to answer its questions, or gone (merged or removed), or broken; takes no lease, and gives up with `timeout` rather than waiting forever").arg("task", z17.string(), { description: "task name" }).option("timeout-secs", z17.coerce.number(), {
1764
+ default: DEFAULT_TIMEOUT3.secs,
1765
+ description: "how long to wait before giving up"
1766
+ }).action((opts, ctx) => {
1767
+ const timeout = Duration3.secs(opts["timeout-secs"]);
1768
+ return ctx.journal.waiting("wait", opts.task, { until: untilAfter3(timeout) }, () => wait(ctx, opts.task, { timeout }));
1769
+ });
1770
+ splates.command("log").use(repository10({ needs: "read" })).description("show what has been done here recently: one line per add, claim, merge, remove, retry, queued task and more, with how it ended; outlives the tasks themselves").option("count", z17.coerce.number(), {
1771
+ default: DEFAULT_COUNT,
1772
+ description: "how many entries to show"
1773
+ }).option("json", z17.boolean(), { default: false, description: "emit JSON" }).action((opts, ctx) => showLog(ctx, { count: opts.count, json: opts.json }));
1774
+ splates.mount("server", splatesServer);
1775
+ 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", z17.string(), {
1776
+ default: "",
1777
+ description: "only this task's ports; every task's by default"
1778
+ }).option("dev", z17.string(), {
1779
+ default: "",
1780
+ 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"
1781
+ }).option("json", z17.boolean(), { default: false, description: "emit JSON" }).action((opts, ctx) => portsLs({
1782
+ task: opts.task,
1783
+ dev: opts.dev,
1784
+ json: opts.json,
1785
+ log: ctx.log,
1786
+ ps: ctx.ps,
1787
+ send: ctx.fetch
1788
+ }));
1789
+ 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", z17.boolean(), { default: false, description: "emit JSON" }).action(async (opts, ctx) => agents({
1790
+ roster: rosterOf2(ctx),
1791
+ offered: await offeredOf(ctx).all(),
1792
+ log: ctx.log
1793
+ }, { json: opts.json }));
1794
+ 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", z17.string(), {
1795
+ default: "",
1796
+ description: "a step's id; omit for every area's progress"
1797
+ }).option("json", z17.boolean(), { default: false, description: "emit JSON" }).option("skip", z17.boolean(), {
1798
+ default: false,
1799
+ description: "with a step: stop asking for it"
1800
+ }).option("unskip", z17.boolean(), {
1801
+ default: false,
1802
+ description: "with a step: ask for it again"
1803
+ }).option("undo", z17.boolean(), {
1804
+ default: false,
1805
+ description: "with an area's id in place of a step: print what undoing it would remove, the creds it forgets and keeps, and what only the user can remove; the Setup page's Undo does it, after the user confirms"
1806
+ }).action((opts, ctx) => setup(ctx, opts));
1807
+ splates.command("path").use(repository10({ 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", z17.string(), {
1808
+ default: "",
1809
+ description: "task name; omit for the main tree"
1810
+ }).action((opts, ctx) => path(ctx, opts.task || undefined));
1811
+ splates.command("checkpoint").use(repository10({ 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) => {
1812
+ await checkpoint(ctx, ctx.ps.cwd());
1813
+ });
1814
+ splates.command("attach").use(repository10({ 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; those the task's final SPLATES.md shows are copied to keep with its finished record. 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", z17.string(), {
1815
+ default: "",
1816
+ description: "files to store, comma separated"
1817
+ }).option("task", z17.string(), {
1818
+ default: "",
1819
+ description: "task name, when run outside its worktree"
1820
+ }).option("remove", z17.string(), {
1821
+ default: "",
1822
+ description: "files it holds to drop first, comma separated, by path or stored name"
1823
+ }).option("conversation", z17.string(), {
1824
+ default: "",
1825
+ description: "conversation to keep the files with, when $SPLATES_CONVERSATION is not set"
1826
+ }).action(async (opts, ctx) => ctx.journal.record("attach", opts.task || await here(ctx), () => attach(ctx, commaList5(opts.files), ctx.ps.cwd(), {
1827
+ task: opts.task || undefined,
1828
+ remove: commaList5(opts.remove),
1829
+ conversation: opts.conversation || undefined
1830
+ }), (stored) => ({ stored, removed: commaList5(opts.remove) })));
1831
+ splates.command("retry").use(repository10({ needs: "own" })).description("give a task that spent its merge attempts another mergeRetryCount of them; the way out of `budget_exhausted` that is not remove and redo, for a person to run once they have seen the tree").arg("task", z17.string(), { description: "task name" }).action((opts, ctx) => recordRetry(ctx, opts.task));
1832
+ splates.mount("artifact", splatesArtifact).mount("task", splatesTask).mount("lane", splatesLane).mount("projects", splatesProjects).mount("creds", splatesCreds).mount("tunnel", splatesTunnel).mount("slots", splatesSlots).mount("worker", splatesWorker).mount("runners", splatesRunners).mount("queue", splatesQueue).mount("hook", splatesHook).mount("budget", splatesBudget).mount("replica", splatesReplica);
1833
+
1834
+ // index.ts
1835
+ if (basename2(process.argv[1] ?? "") === LEGACY) {
1836
+ legacyWarnings.once(LEGACY, "splates");
1837
+ }
1838
+ var ps = systemPs();
1839
+ var { project, argv } = projectArgs(ps.args);
1840
+ var handedOff = await handOffDev(argv, ps.cwd());
1841
+ if (handedOff !== null) {
1842
+ process.exit(handedOff);
1843
+ }
1844
+ await splates.run({ fs: new NodeFs, ps, assets, project }, argv);