@stringsync/splates 0.0.52 → 0.0.53

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 (60) hide show
  1. package/artifact.ts +120 -0
  2. package/budget.ts +117 -0
  3. package/checkout.ts +8 -0
  4. package/creds.test.ts +336 -0
  5. package/creds.ts +144 -0
  6. package/dev/dev.ts +86 -0
  7. package/dev/eval.ts +21 -0
  8. package/dev/fix.ts +63 -0
  9. package/dev/index.ts +1 -0
  10. package/dev/prove.ts +38 -0
  11. package/dev/root.ts +7 -0
  12. package/dev/ship.ts +46 -0
  13. package/dev/test.ts +154 -0
  14. package/e2e.test.ts +472 -0
  15. package/flags.ts +157 -0
  16. package/hook.ts +328 -0
  17. package/index.ts +18 -0
  18. package/infra.ts +107 -0
  19. package/lane.test.ts +29 -0
  20. package/lane.ts +172 -0
  21. package/notes.ts +19 -0
  22. package/package.json +14 -12
  23. package/projects.test.ts +191 -0
  24. package/projects.ts +37 -0
  25. package/queue.ts +82 -0
  26. package/screenshots/annotate.png +0 -0
  27. package/screenshots/demo.png +0 -0
  28. package/screenshots/parallel.png +0 -0
  29. package/screenshots/questions.png +0 -0
  30. package/server.ts +287 -0
  31. package/setup.ts +233 -0
  32. package/slots.ts +65 -0
  33. package/splates.test.ts +338 -0
  34. package/splates.ts +567 -0
  35. package/task.ts +354 -0
  36. package/tunnel.ts +106 -0
  37. package/wait-journal.test.ts +96 -0
  38. package/waiting.ts +59 -0
  39. package/worker-relay.test.ts +233 -0
  40. package/worker.ts +300 -0
  41. package/artifact.d.ts +0 -3
  42. package/budget.d.ts +0 -3
  43. package/creds.d.ts +0 -3
  44. package/flags.d.ts +0 -37
  45. package/hook.d.ts +0 -3
  46. package/index.d.ts +0 -2
  47. package/index.js +0 -1776
  48. package/infra.d.ts +0 -3
  49. package/lane.d.ts +0 -3
  50. package/notes.d.ts +0 -4
  51. package/projects.d.ts +0 -3
  52. package/queue.d.ts +0 -3
  53. package/server.d.ts +0 -7
  54. package/setup.d.ts +0 -22
  55. package/slots.d.ts +0 -3
  56. package/splates.d.ts +0 -10
  57. package/task.d.ts +0 -3
  58. package/tunnel.d.ts +0 -3
  59. package/waiting.d.ts +0 -3
  60. package/worker.d.ts +0 -3
package/splates.ts ADDED
@@ -0,0 +1,567 @@
1
+ // scry-ignore-file compact-files: the root roster of top-level commands and their flags, each body handed to its own module and each group mounted from its own file
2
+
3
+ import { agents } from "@stringsync/splates-server/agents";
4
+ import { attach } from "@stringsync/splates-server/artifacts";
5
+ import { commaList } from "@stringsync/splates-server/comma-list";
6
+ import { refuseGroomer } from "@stringsync/splates-server/conversations";
7
+ import { exits, fail } from "@stringsync/splates-server/exit";
8
+ import { DEFAULT_COUNT, log as showLog } from "@stringsync/splates-server/log";
9
+ import { merge } from "@stringsync/splates-server/merge";
10
+ import { portsLs } from "@stringsync/splates-server/ports";
11
+ import { forget, init } from "@stringsync/splates-server/projects";
12
+ import type { Facts } from "@stringsync/splates-server/records";
13
+ import {
14
+ MIGRATING,
15
+ MIGRATION_LIMIT,
16
+ migrateRecords,
17
+ repository,
18
+ } from "@stringsync/splates-server/records";
19
+ import type { SplatesDeps } from "@stringsync/splates-server/server";
20
+ import {
21
+ newest,
22
+ recordsFirst,
23
+ rosterOf,
24
+ runHeavy,
25
+ seeded,
26
+ update,
27
+ } from "@stringsync/splates-server/server";
28
+ import { addFactTasks } from "@stringsync/splates-server/stats";
29
+ import {
30
+ add,
31
+ checkpoint,
32
+ claim,
33
+ DEFAULT_TIMEOUT,
34
+ follow,
35
+ list,
36
+ path,
37
+ REMOVE_REASONS,
38
+ recordRetry,
39
+ remove,
40
+ serveProject,
41
+ show,
42
+ untilAfter,
43
+ wait,
44
+ } from "@stringsync/splates-server/tasks";
45
+ import { cli } from "webappwiz/cmd";
46
+ import { Duration } from "webappwiz/time";
47
+ import { z } from "zod";
48
+ import { splatesArtifact } from "./artifact";
49
+ import { splatesBudget } from "./budget";
50
+ import { splatesCreds } from "./creds";
51
+ import { effortIn, here, offeredOf, taskIds } from "./flags";
52
+ import { splatesHook } from "./hook";
53
+ import { splatesLane } from "./lane";
54
+ import { notes } from "./notes";
55
+ import { splatesProjects } from "./projects";
56
+ import { splatesQueue } from "./queue";
57
+ import { splatesServer } from "./server";
58
+ import { setup } from "./setup";
59
+ import { splatesSlots } from "./slots";
60
+ import { splatesTask } from "./task";
61
+ import { splatesTunnel } from "./tunnel";
62
+ import { splatesWorker } from "./worker";
63
+
64
+ /**
65
+ * Splits a leading `--project <slug>`, or `--project=<slug>`, off the
66
+ * arguments, for `run` to be handed as `project`.
67
+ */
68
+ export function projectArgs(argv: string[]): {
69
+ project?: string;
70
+ argv: string[];
71
+ } {
72
+ const [first, second, ...rest] = argv;
73
+ if (first === "--project" && second !== undefined) {
74
+ return { project: second, argv: rest };
75
+ }
76
+ if (first?.startsWith("--project=")) {
77
+ return {
78
+ project: first.slice("--project=".length),
79
+ argv: argv.slice(1),
80
+ };
81
+ }
82
+ return { argv };
83
+ }
84
+
85
+ // Outermost first: a refusal raised in an action unwinds past `repository` and
86
+ // stops at `exits`, which is the only thing that ends the process.
87
+ export const splates = cli<SplatesDeps>("splates").use(exits<SplatesDeps>());
88
+
89
+ // Every action is handed the dependencies it runs with, so it passes the
90
+ // context straight on: each command takes the few it names off it.
91
+ splates
92
+ .command("notes")
93
+ .use(repository({ needs: "read" }))
94
+ .description(
95
+ "print each project note's frontmatter description, one per line; notesDir in splates.config.ts defaults to notes; an absent directory prints nothing",
96
+ )
97
+ .action((_opts, ctx) => notes(ctx));
98
+
99
+ splates
100
+ .command("init")
101
+ .use(repository({ creates: true, needs: "own" }))
102
+ .description(
103
+ "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",
104
+ )
105
+ .action((_opts, ctx) => init(ctx));
106
+
107
+ splates
108
+ .command("migrate")
109
+ .description(
110
+ `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`,
111
+ )
112
+ .action(async (_opts, ctx) => {
113
+ const format = await migrateRecords(ctx);
114
+ if (ctx.ps.env(MIGRATING) === undefined) {
115
+ ctx.log.info(`splates's records are in format ${format}`);
116
+ }
117
+ });
118
+
119
+ splates
120
+ .command("update")
121
+ .use(recordsFirst())
122
+ .use(repository({ needs: "own" }))
123
+ .description(
124
+ "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",
125
+ )
126
+ .action(async (_opts, ctx) => {
127
+ const code = await update({
128
+ root: ctx.git.root,
129
+ fs: ctx.fs,
130
+ ps: ctx.ps,
131
+ log: ctx.log,
132
+ newest: () => newest(),
133
+ });
134
+ if (code !== 0) {
135
+ ctx.ps.exit(code);
136
+ }
137
+ });
138
+
139
+ splates
140
+ .command("heavy")
141
+ .description(
142
+ "run a heavy command (a full test run, an install) in one of this machine's heavy-job slots, waiting for one to come free first, so agents testing at once queue rather than run out of memory; `SPLATES_HEAVY_JOBS` sets how many run at once, 2 by default",
143
+ )
144
+ .rest("command", z.string(), {
145
+ description: "the command and its arguments, after `--`",
146
+ })
147
+ .action(async (opts, ctx) => {
148
+ const code = await runHeavy(ctx, opts.command);
149
+ if (code !== 0) {
150
+ ctx.ps.exit(code);
151
+ }
152
+ });
153
+
154
+ splates
155
+ .command("forget")
156
+ .use(repository({ needs: "own" }))
157
+ .description(
158
+ "take this repository off ~/.splates/projects.json, so `splates server` no longer offers it; its records stay, and `splates init` brings it back",
159
+ )
160
+ .action((_opts, ctx) => forget(ctx));
161
+
162
+ splates
163
+ .command("add")
164
+ .use(repository({ needs: "tasks" }))
165
+ .description(
166
+ "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",
167
+ )
168
+ .arg("slug", z.string(), {
169
+ description:
170
+ "the task's name, for its branch and worktree (lowercase-with-dashes)",
171
+ })
172
+ .option("base", z.string(), {
173
+ default: "",
174
+ description:
175
+ "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",
176
+ })
177
+ .option("task", z.string(), {
178
+ default: "",
179
+ description:
180
+ "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",
181
+ })
182
+ .option("goal", z.string(), {
183
+ default: "",
184
+ description:
185
+ "one or two lines on what done means, written under `## Goal` in the task's SPLATES.md; required unless --task seeds the Goal",
186
+ })
187
+ .option("agent", z.string(), {
188
+ default: "",
189
+ description:
190
+ "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",
191
+ })
192
+ .option("model", z.string(), {
193
+ default: "",
194
+ description:
195
+ "with --agent: the model it works on, by its id or name on the roster (default: the agent's own)",
196
+ })
197
+ .option("effort", z.string(), {
198
+ default: "",
199
+ description:
200
+ "with --agent: the thinking level it works at, one the agent offers (default: the agent's own)",
201
+ })
202
+ .option("prompt", z.string(), {
203
+ default: "",
204
+ description:
205
+ "with --agent: what to tell the agent beyond claiming the task",
206
+ })
207
+ .option("held", z.string(), {
208
+ default: "",
209
+ description:
210
+ "with --agent: the conversation `splates server`'s queue held for the queued task, which takes the task rather than a new one",
211
+ })
212
+ .action(async (opts, ctx) => {
213
+ await refuseGroomer(ctx.service, "add");
214
+ if (
215
+ opts.agent === "" &&
216
+ (opts.model !== "" ||
217
+ opts.effort !== "" ||
218
+ opts.prompt !== "" ||
219
+ opts.held !== "")
220
+ ) {
221
+ fail("usage", "--model, --effort, --prompt and --held go with --agent", {
222
+ task: opts.slug,
223
+ });
224
+ }
225
+ if (opts.goal.trim() === "" && opts.task === "") {
226
+ fail(
227
+ "usage",
228
+ `say what done means: \`splates add ${opts.slug} --goal "<one or two lines>"\`, or start queued tasks with --task`,
229
+ { task: opts.slug },
230
+ );
231
+ }
232
+ const dispatch =
233
+ opts.agent === ""
234
+ ? undefined
235
+ : {
236
+ agent: opts.agent,
237
+ ...(opts.model === "" ? {} : { model: opts.model }),
238
+ ...(await effortIn(ctx, opts.agent, opts.effort, {
239
+ task: opts.slug,
240
+ })),
241
+ ...(opts.prompt === "" ? {} : { prompt: opts.prompt }),
242
+ ...(opts.held === "" ? {} : { held: opts.held }),
243
+ };
244
+ const agents = dispatch === undefined ? [] : await seeded(rosterOf(ctx));
245
+ const all = await ctx.tasks.all();
246
+ const subjects = taskIds(opts.task).flatMap(
247
+ (id) => all.find((each) => each.id === id)?.subject ?? [],
248
+ );
249
+ await ctx.journal.record(
250
+ "add",
251
+ opts.slug,
252
+ () =>
253
+ add(ctx, opts.slug, {
254
+ base: opts.base || undefined,
255
+ tasks: taskIds(opts.task),
256
+ goal: opts.goal.trim() || undefined,
257
+ dispatch,
258
+ agents,
259
+ away: ctx.elsewhere !== null,
260
+ // Before the task is live, or the page shows it a moment as an
261
+ // external agent's. A task handed to another agent is that
262
+ // agent's, so this conversation stays where it is.
263
+ onTree:
264
+ dispatch === undefined
265
+ ? () => follow(ctx, opts.slug, { base: opts.base || undefined })
266
+ : undefined,
267
+ }),
268
+ {
269
+ base: opts.base || ctx.config.trunk,
270
+ tasks: taskIds(opts.task),
271
+ goal: opts.goal.trim() || null,
272
+ subjects,
273
+ agent: dispatch?.agent ?? null,
274
+ model: dispatch?.model ?? null,
275
+ },
276
+ );
277
+ if (ctx.elsewhere !== null) {
278
+ await serveProject(ctx);
279
+ }
280
+ });
281
+
282
+ splates
283
+ .command("claim")
284
+ .use(repository({ needs: "own" }))
285
+ .description(
286
+ "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",
287
+ )
288
+ .arg("task", z.string(), { description: "task name" })
289
+ .action(async (opts, ctx) => {
290
+ await ctx.journal.record(
291
+ "claim",
292
+ opts.task,
293
+ () => claim(ctx, opts.task),
294
+ (claimed) => claimed,
295
+ );
296
+ await follow(ctx, opts.task);
297
+ });
298
+
299
+ splates
300
+ .command("merge")
301
+ .use(repository({ needs: "own" }))
302
+ .description(
303
+ "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",
304
+ )
305
+ .option("message", z.string(), {
306
+ default: "",
307
+ description:
308
+ "squash the task's commits into one with this message before landing, saying what the base gains in at least 5 characters",
309
+ })
310
+ .action(async (opts, ctx) => {
311
+ await refuseGroomer(ctx.service, "merge");
312
+ const task = await here(ctx);
313
+ // Filled by `merge` as it learns each; a refusal keeps what it had.
314
+ const facts: Partial<Facts["merge"]> = {};
315
+ await ctx.journal.record(
316
+ "merge",
317
+ task,
318
+ () => merge(ctx, ctx.ps.cwd(), facts, { message: opts.message }),
319
+ facts as Facts["merge"],
320
+ );
321
+ if (task !== null) {
322
+ // After the merge's own event, so the conversation says it landed
323
+ // before what it found; landed is landed, whatever happens here.
324
+ await addFactTasks(ctx, task).catch((error: unknown) =>
325
+ ctx.log.error(
326
+ `could not queue tasks for what the merge found: ${error}`,
327
+ ),
328
+ );
329
+ await follow(ctx, null, { from: task });
330
+ }
331
+ });
332
+
333
+ splates
334
+ .command("remove")
335
+ .use(repository({ needs: "tasks" }))
336
+ .description(
337
+ "discard a task: worktree, branch and state file; cheap and encouraged, since redoing a task against current trunk often beats a hard rebase",
338
+ )
339
+ .arg("task", z.string(), { description: "task name" })
340
+ .option("force", z.boolean(), {
341
+ default: false,
342
+ description: "discard even when another agent holds the lease",
343
+ })
344
+ .option("reason", z.optional(z.enum(REMOVE_REASONS)), {
345
+ description:
346
+ "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)",
347
+ })
348
+ .action(async (opts, ctx) => {
349
+ await ctx.journal.record(
350
+ "remove",
351
+ opts.task,
352
+ () => remove(ctx, opts.task, { force: opts.force, reason: opts.reason }),
353
+ ({ released }) => ({ why: opts.reason ?? null, released }),
354
+ );
355
+ await follow(ctx, null, { from: opts.task });
356
+ });
357
+
358
+ splates
359
+ .command("list")
360
+ .use(repository({ needs: "read" }))
361
+ .description(
362
+ "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",
363
+ )
364
+ .option("json", z.boolean(), { default: false, description: "emit JSON" })
365
+ .option("files", z.boolean(), {
366
+ default: false,
367
+ description:
368
+ "add each task's Goal and changed files (committed or not), to check for overlap before starting",
369
+ })
370
+ .action((opts, ctx) => list(ctx, { json: opts.json, files: opts.files }));
371
+
372
+ splates
373
+ .command("show")
374
+ .use(repository({ needs: "read" }))
375
+ .description(
376
+ "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",
377
+ )
378
+ .arg("task", z.string(), { description: "task name" })
379
+ .option("json", z.boolean(), { default: false, description: "emit JSON" })
380
+ .action((opts, ctx) => show(ctx, opts.task, { json: opts.json }));
381
+
382
+ splates
383
+ .command("wait")
384
+ .use(repository({ needs: "read" }))
385
+ .description(
386
+ "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",
387
+ )
388
+ .arg("task", z.string(), { description: "task name" })
389
+ .option("timeout-secs", z.coerce.number(), {
390
+ default: DEFAULT_TIMEOUT.secs,
391
+ description: "how long to wait before giving up",
392
+ })
393
+ .action((opts, ctx) => {
394
+ const timeout = Duration.secs(opts["timeout-secs"]);
395
+ return ctx.journal.waiting(
396
+ "wait",
397
+ opts.task,
398
+ { until: untilAfter(timeout) },
399
+ () => wait(ctx, opts.task, { timeout }),
400
+ );
401
+ });
402
+
403
+ splates
404
+ .command("log")
405
+ .use(repository({ needs: "read" }))
406
+ .description(
407
+ "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",
408
+ )
409
+ .option("count", z.coerce.number(), {
410
+ default: DEFAULT_COUNT,
411
+ description: "how many entries to show",
412
+ })
413
+ .option("json", z.boolean(), { default: false, description: "emit JSON" })
414
+ .action((opts, ctx) => showLog(ctx, { count: opts.count, json: opts.json }));
415
+
416
+ splates.mount("server", splatesServer);
417
+
418
+ splates
419
+ .command("ports")
420
+ .description(
421
+ "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, which on a worker whose server runs elsewhere only that worker's own ports have; give the user both",
422
+ )
423
+ .arg("task", z.string(), {
424
+ default: "",
425
+ description: "only this task's ports; every task's by default",
426
+ })
427
+ .option("dev", z.string(), {
428
+ default: "",
429
+ description:
430
+ "the `splates server` to ask, a URL such as http://localhost:4310; by default the one an agent runs under, from SPLATES_DEV, else the coordinator this machine's worker relays to, else this machine's on its default port",
431
+ })
432
+ .option("json", z.boolean(), { default: false, description: "emit JSON" })
433
+ .action((opts, ctx) =>
434
+ portsLs({
435
+ task: opts.task,
436
+ dev: opts.dev,
437
+ json: opts.json,
438
+ log: ctx.log,
439
+ fs: ctx.fs,
440
+ ps: ctx.ps,
441
+ send: ctx.fetch,
442
+ }),
443
+ );
444
+
445
+ splates
446
+ .command("agents")
447
+ .description(
448
+ "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",
449
+ )
450
+ .option("json", z.boolean(), { default: false, description: "emit JSON" })
451
+ .action(async (opts, ctx) =>
452
+ agents(
453
+ {
454
+ roster: rosterOf(ctx),
455
+ offered: await offeredOf(ctx).all(),
456
+ log: ctx.log,
457
+ },
458
+ { json: opts.json },
459
+ ),
460
+ );
461
+
462
+ splates
463
+ .command("setup")
464
+ .description(
465
+ "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",
466
+ )
467
+ .arg("step", z.string(), {
468
+ default: "",
469
+ description: "a step's id; omit for every area's progress",
470
+ })
471
+ .option("json", z.boolean(), { default: false, description: "emit JSON" })
472
+ .option("skip", z.boolean(), {
473
+ default: false,
474
+ description: "with a step: stop asking for it",
475
+ })
476
+ .option("unskip", z.boolean(), {
477
+ default: false,
478
+ description: "with a step: ask for it again",
479
+ })
480
+ .option("undo", z.boolean(), {
481
+ default: false,
482
+ description:
483
+ "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",
484
+ })
485
+ .action((opts, ctx) => setup(ctx, opts));
486
+
487
+ splates
488
+ .command("path")
489
+ .use(repository({ needs: "read" }))
490
+ .description(
491
+ "print a task's worktree path, or the main tree with no task; names another agent's tree without taking its lease",
492
+ )
493
+ .arg("task", z.string(), {
494
+ default: "",
495
+ description: "task name; omit for the main tree",
496
+ })
497
+ .action((opts, ctx) => path(ctx, opts.task || undefined));
498
+
499
+ splates
500
+ .command("checkpoint")
501
+ .use(repository({ needs: "own" }))
502
+ .description(
503
+ "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",
504
+ )
505
+ .action(async (_opts, ctx) => {
506
+ await checkpoint(ctx, ctx.ps.cwd());
507
+ });
508
+
509
+ splates
510
+ .command("attach")
511
+ .use(repository({ needs: "own" }))
512
+ .description(
513
+ "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`)",
514
+ )
515
+ .arg("files", z.string(), {
516
+ default: "",
517
+ description: "files to store, comma separated",
518
+ })
519
+ .option("task", z.string(), {
520
+ default: "",
521
+ description: "task name, when run outside its worktree",
522
+ })
523
+ .option("remove", z.string(), {
524
+ default: "",
525
+ description:
526
+ "files it holds to drop first, comma separated, by path or stored name",
527
+ })
528
+ .option("conversation", z.string(), {
529
+ default: "",
530
+ description:
531
+ "conversation to keep the files with, when $SPLATES_CONVERSATION is not set",
532
+ })
533
+ .action(async (opts, ctx) =>
534
+ ctx.journal.record(
535
+ "attach",
536
+ opts.task || (await here(ctx)),
537
+ () =>
538
+ attach(ctx, commaList(opts.files), ctx.ps.cwd(), {
539
+ task: opts.task || undefined,
540
+ remove: commaList(opts.remove),
541
+ conversation: opts.conversation || undefined,
542
+ }),
543
+ (stored) => ({ stored, removed: commaList(opts.remove) }),
544
+ ),
545
+ );
546
+
547
+ splates
548
+ .command("retry")
549
+ .use(repository({ needs: "own" }))
550
+ .description(
551
+ "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",
552
+ )
553
+ .arg("task", z.string(), { description: "task name" })
554
+ .action((opts, ctx) => recordRetry(ctx, opts.task));
555
+
556
+ splates
557
+ .mount("artifact", splatesArtifact)
558
+ .mount("task", splatesTask)
559
+ .mount("lane", splatesLane)
560
+ .mount("projects", splatesProjects)
561
+ .mount("creds", splatesCreds)
562
+ .mount("tunnel", splatesTunnel)
563
+ .mount("slots", splatesSlots)
564
+ .mount("worker", splatesWorker)
565
+ .mount("queue", splatesQueue)
566
+ .mount("hook", splatesHook)
567
+ .mount("budget", splatesBudget);