passalong 0.3.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/passalong.js CHANGED
@@ -1,6 +1,9 @@
1
1
  // The operations Passalong exposes. Both surfaces (bin/passalong and the MCP server) call these,
2
2
  // so anything an agent can do through MCP a human can do from the terminal and vice versa.
3
+ import { spawnSync } from "node:child_process";
4
+ import { randomBytes } from "node:crypto";
3
5
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
6
+ import { hostname } from "node:os";
4
7
  import { basename, join } from "node:path";
5
8
  import * as api from "./api.js";
6
9
  import { context } from "./capture.js";
@@ -59,7 +62,10 @@ export async function share(markdown, { cwd = process.cwd(), to, follows } = {})
59
62
  const errors = validate(guide);
60
63
  if (errors.length)
61
64
  throw new PassalongError(`guide is not ready to share:\n - ${errors.join("\n - ")}`);
62
- if (guide.meta.status === "draft")
65
+ // A task stays a draft when it is shared. Draft is the column a task waits in until a person
66
+ // says it is ready for an agent — `ready()` — and an agent that drafts one over MCP comes
67
+ // through here too, so publishing on share would let a task queue itself with nobody reading it.
68
+ if (guide.meta.status === "draft" && guide.meta.kind !== "task")
63
69
  guide = { ...guide, meta: { ...guide.meta, status: "published" } };
64
70
 
65
71
  let url = guide.meta.url || null;
@@ -190,19 +196,231 @@ export async function pull(ref, { cwd = process.cwd(), write = true } = {}) {
190
196
  }
191
197
  let path = null;
192
198
  if (write) {
193
- const dir = join(cwd, ".passalong");
194
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
195
- // A pulled guide carries its own share URL in the frontmatter, and that URL needs no account
196
- // to read. Committing this directory would publish the guide to anyone who can see the repo,
197
- // so the directory ignores itself. Delete this file if you do want guides in the repo.
198
- const ignore = join(dir, ".gitignore");
199
- if (!existsSync(ignore)) writeFileSync(ignore, "*\n");
200
- path = join(dir, `${guide.meta.id}.md`);
199
+ path = join(localDir(cwd), `${guide.meta.id}.md`);
201
200
  writeFileSync(path, serialize(guide));
202
201
  }
203
202
  return { guide, from, path, markdown: serialize(guide) };
204
203
  }
205
204
 
205
+ /**
206
+ * `.passalong/` in a working directory, made if it is missing.
207
+ *
208
+ * A pulled guide carries its own share URL in the frontmatter, and that URL needs no account to
209
+ * read. Committing this directory would publish the guide to anyone who can see the repo, so the
210
+ * directory ignores itself. Delete its .gitignore if you do want guides in the repo.
211
+ */
212
+ function localDir(cwd) {
213
+ const dir = join(cwd, ".passalong");
214
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
215
+ const ignore = join(dir, ".gitignore");
216
+ if (!existsSync(ignore)) writeFileSync(ignore, "*\n");
217
+ return dir;
218
+ }
219
+
220
+ /**
221
+ * Who this worktree is to the task queue, minted the first time it asks.
222
+ *
223
+ * A claim belongs to an agent, not an account: one person runs several, and "no two agents on one
224
+ * task" means nothing if the server cannot tell them apart. The id lives in the worktree's own
225
+ * `.passalong/agent.json`, so a restarted session in the same worktree is the same agent and gets
226
+ * its task back, and a second worktree of the same repo is a different agent. Per machine would
227
+ * let two worktrees on one laptop share a lock; per session would lose the task on every restart.
228
+ */
229
+ export function agent(cwd = process.cwd()) {
230
+ const c = context(cwd);
231
+ const root = c.root || cwd;
232
+ const file = join(localDir(root), "agent.json");
233
+ let id = "";
234
+ try {
235
+ id = String(JSON.parse(readFileSync(file, "utf8")).id || "");
236
+ } catch {}
237
+ if (!/^[a-z0-9]{8,64}$/.test(id)) {
238
+ id = randomBytes(12).toString("hex");
239
+ writeFileSync(file, `${JSON.stringify({ id }, null, 2)}\n`);
240
+ }
241
+ return { agent: id, host: hostname(), repo: c.repo, worktree: root };
242
+ }
243
+
244
+ /** This worktree's agent if it has one, without creating it. `agent` is "" when there is none. */
245
+ function knownAgent(cwd = process.cwd()) {
246
+ const c = context(cwd);
247
+ const root = c.root || cwd;
248
+ let id = "";
249
+ try {
250
+ id = String(JSON.parse(readFileSync(join(root, ".passalong", "agent.json"), "utf8")).id || "");
251
+ } catch {}
252
+ return { agent: /^[a-z0-9]{8,64}$/.test(id) ? id : "", repo: c.repo };
253
+ }
254
+
255
+ function needsSync(what) {
256
+ if (!api.loggedIn())
257
+ throw new PassalongError(
258
+ `${what} needs sync — the queue lives on the server (run \`passalong login\`)`,
259
+ );
260
+ }
261
+
262
+ /** Every task you can see, with where it is and who has it. */
263
+ export async function tasks() {
264
+ needsSync("the task queue");
265
+ return (await api.tasks()).tasks;
266
+ }
267
+
268
+ /**
269
+ * Take the next task for this worktree's repo, or get back the one this worktree already holds.
270
+ * `null` when there is nothing to do. The guide is written to `.passalong/<id>.md` like a pull.
271
+ */
272
+ export async function nextTask({ cwd = process.cwd(), any = false } = {}) {
273
+ needsSync("taking a task");
274
+ const { task } = await api.nextTask({ ...agent(cwd), any });
275
+ if (!task) return null;
276
+ const guide = parse(task.markdown);
277
+ const path = join(localDir(agent(cwd).worktree), `${task.id}.md`);
278
+ writeFileSync(path, task.markdown);
279
+ store.save(guide);
280
+ return { ...task, path };
281
+ }
282
+
283
+ /** Word from the agent holding a task: renews its lease, and `note` becomes the board's line. */
284
+ export async function taskProgress(id, note, { cwd = process.cwd() } = {}) {
285
+ needsSync("reporting progress");
286
+ const { agent: a } = agent(cwd);
287
+ return api.taskProgress(id, note ? { agent: a, note } : { agent: a });
288
+ }
289
+
290
+ /**
291
+ * The work is done: the task moves to review with a transfer guide about it attached.
292
+ *
293
+ * Pass `markdown` and this writes that guide too, with `parent:` naming the task, so the write-up
294
+ * and the finish are one step and neither can be skipped or pointed at the wrong guide. Pass
295
+ * `report` instead when the guide is already published.
296
+ */
297
+ export async function finishTask(
298
+ id,
299
+ { report, markdown, pr = "", note = "", cwd = process.cwd() } = {},
300
+ ) {
301
+ needsSync("finishing a task");
302
+ if (!report && markdown) {
303
+ const g = parse(markdown);
304
+ report = (await share(serialize({ meta: { ...g.meta, parent: id }, body: g.body }), { cwd }))
305
+ .guide.meta.id;
306
+ }
307
+ if (!report)
308
+ throw new PassalongError(
309
+ "finishing needs the write-up: `markdown` for a transfer guide about the work, or `report` with the id of one",
310
+ );
311
+ return api.finishTask(id, { agent: agent(cwd).agent, report, pr, note });
312
+ }
313
+
314
+ /**
315
+ * The four verbs every guide answers to, whatever its kind (docs/V2.md §11). Each answer carries
316
+ * `next`, the calls that make sense from here, and `say` when the move is to stop.
317
+ *
318
+ * take: this worktree's agent takes guide `id`, or with no id the next one waiting for it. The
319
+ * document is written to `.passalong/<id>.md`, like a pull, so the agent works from a file.
320
+ */
321
+ export async function take(id, { cwd = process.cwd(), any = false } = {}) {
322
+ needsSync("taking work");
323
+ const who = agent(cwd);
324
+ const r = await api.take({ ...who, ...(id ? { id: await resolveId(id) } : {}), any });
325
+ if (!r.guide?.markdown) return r;
326
+ const path = join(localDir(who.worktree), `${r.guide.id}.md`);
327
+ writeFileSync(path, r.guide.markdown);
328
+ store.save(parse(r.guide.markdown));
329
+ return { ...r, path };
330
+ }
331
+
332
+ /** Word from the agent that holds it: the lease starts again, and `note` is what the hub shows. */
333
+ export async function progress(id, note, { cwd = process.cwd() } = {}) {
334
+ needsSync("reporting progress");
335
+ return api.progress(id, { agent: agent(cwd).agent, ...(note ? { note } : {}) });
336
+ }
337
+
338
+ /**
339
+ * Done here. A task hands in a write-up — `markdown`, published with `parent:` naming the task, or
340
+ * `report`, the id of one already published — for its author to review against Acceptance. Any
341
+ * other guide hands in whether its Verification held: `ok`, and a `note` when it did not.
342
+ */
343
+ export async function handIn(
344
+ id,
345
+ { ok, note = "", markdown, report, pr = "", cwd = process.cwd() } = {},
346
+ ) {
347
+ needsSync("handing work in");
348
+ if (!report && markdown) {
349
+ const g = parse(markdown);
350
+ report = (await share(serialize({ meta: { ...g.meta, parent: id }, body: g.body }), { cwd }))
351
+ .guide.meta.id;
352
+ }
353
+ return api.handIn(id, {
354
+ agent: agent(cwd).agent,
355
+ note,
356
+ ...(report ? { report, pr } : {}),
357
+ ...(typeof ok === "boolean" ? { ok } : {}),
358
+ });
359
+ }
360
+
361
+ /** Not this agent's to do: what it held is open again, and `why` goes to whoever is next. */
362
+ export async function pass(id, why, { cwd = process.cwd() } = {}) {
363
+ needsSync("passing work");
364
+ if (!String(why || "").trim())
365
+ throw new PassalongError("say why you are passing it, so whoever is next knows");
366
+ return api.pass(id, { agent: agent(cwd).agent, why });
367
+ }
368
+
369
+ /**
370
+ * What this worktree's agent holds right now, and what is waiting for it: what the session-start
371
+ * and stop hooks read (src/hooks.js). `held` is null when it holds nothing. Counts are best effort
372
+ * — a hook must never fail a session over a count.
373
+ */
374
+ export async function now({ cwd = process.cwd() } = {}) {
375
+ needsSync("seeing what you hold");
376
+ // Read, never minted: the hooks run in every repo a session opens, and a worktree that has never
377
+ // taken anything has no agent — and should not be given a .passalong/ folder for asking.
378
+ const who = knownAgent(cwd);
379
+ const { working } = await api.working();
380
+ const held = (who.agent && working.find((w) => w.agent === who.agent && w.by?.you)) || null;
381
+ const waiting = { ready: 0, inbox: 0 };
382
+ if (!held) {
383
+ const [t, i] = await Promise.allSettled([api.tasks(), api.inbox()]);
384
+ if (t.status === "fulfilled")
385
+ waiting.ready = t.value.tasks.filter(
386
+ (x) => x.state === "ready" && x.target === who.repo,
387
+ ).length;
388
+ if (i.status === "fulfilled") waiting.inbox = i.value.guides.length;
389
+ }
390
+ return { held, waiting, agent: who.agent };
391
+ }
392
+
393
+ /**
394
+ * The gate, a person's three answers to a task. Approve: the work in review is done. Reject: it
395
+ * goes back to Ready with `why` on it for the next agent. Release: a task an agent holds, live or
396
+ * stalled, goes back to Ready with a note saying where that agent left it.
397
+ *
398
+ * The local copy is dropped after each, so the next read of the task fetches what the server made
399
+ * of it — a reject or a release rewrites the document, and a stale copy would hide exactly the
400
+ * line the next reader needs.
401
+ */
402
+ export async function approveTask(id) {
403
+ needsSync("approving a task");
404
+ const r = await api.approveTask(id);
405
+ store.remove(id);
406
+ return r;
407
+ }
408
+
409
+ export async function rejectTask(id, why) {
410
+ needsSync("rejecting a task");
411
+ if (!String(why || "").trim()) throw new PassalongError("say why: the next agent reads it");
412
+ const r = await api.rejectTask(id, why);
413
+ store.remove(id);
414
+ return r;
415
+ }
416
+
417
+ export async function releaseTask(id) {
418
+ needsSync("releasing a task");
419
+ const r = await api.releaseTask(id);
420
+ store.remove(id);
421
+ return r;
422
+ }
423
+
206
424
  /**
207
425
  * Pull a guide *and* take the handoff: the two halves of starting work, in one call.
208
426
  *
@@ -252,9 +470,8 @@ export function handoffNudge(meta = {}) {
252
470
  if (!meta.to && !meta.team) return "";
253
471
  return (
254
472
  "<!-- passalong: this guide was handed to someone. If you are about to do the work, call " +
255
- "start_guide instead of get_guide — it takes the handoff so the sender stops guessing. " +
256
- "If you are only reading, or it turns out not to be yours, answer with ack_guide taken=false " +
257
- "and a reason. -->"
473
+ "take instead of get_guide — it takes the handoff so the sender stops guessing. " +
474
+ "If you are only reading, or it turns out not to be yours, answer with pass with a reason. -->"
258
475
  );
259
476
  }
260
477
 
@@ -296,6 +513,85 @@ export async function attach(file, { name = "" } = {}) {
296
513
  return { ...shot, markdown: `![${name || basename(file)}](${shot.url})` };
297
514
  }
298
515
 
516
+ /**
517
+ * The line a guide arrives with, saying what a follow-up is and how to write one.
518
+ *
519
+ * A follow-up is more context for a guide, written as its own guide with `parent` naming the
520
+ * original — a missing detail, a step that needed explaining, what changed since, what someone
521
+ * found doing it. Whoever opens the original, person or agent, gets its follow-ups with it, which
522
+ * is why context belongs there rather than in a one-line verdict note.
523
+ *
524
+ * Trailing, like the handoff nudge, so it never sits in front of the frontmatter. It is here and
525
+ * not only in the server's instructions because an agent reads the payload it is working from and
526
+ * skims everything else — the same reason the bug lead is inside the document. For a bug, the
527
+ * context worth adding is usually the fix, when it is worth repeating somewhere else: that is a
528
+ * transfer guide, and the bug is what it adds context to.
529
+ */
530
+ export function followUpNote(meta = {}) {
531
+ if (!meta.id) return "";
532
+ if (meta.kind === "bug")
533
+ return (
534
+ "<!-- passalong: once this is fixed, if the fix is worth repeating somewhere else, that is " +
535
+ "more context for this bug: publish it as a transfer guide with publish_guide " +
536
+ `parent=${meta.id}, and whoever opens this bug gets it too. -->`
537
+ );
538
+ return (
539
+ "<!-- passalong: a follow-up is more context for this guide, written as its own guide. If " +
540
+ "this guide needs more — a missing detail, a step that needed explaining, what changed since, " +
541
+ `what you found doing it — publish that with publish_guide parent=${meta.id}, and whoever ` +
542
+ "opens this guide gets it too. -->"
543
+ );
544
+ }
545
+
546
+ /** What heads a guide's follow-ups when they are handed to an agent. Same words on both servers. */
547
+ export const FOLLOW_UPS_LEAD =
548
+ "FOLLOW-UPS — more context added to this guide, oldest first. Read them before acting; where " +
549
+ "one contradicts the original, the follow-up is newer.";
550
+
551
+ /**
552
+ * A guide's follow-ups as the server lists them, oldest first, or [] when there are none, when
553
+ * sync is off, or when anything goes wrong. Context is never worth failing a pull over: the guide
554
+ * itself is what was asked for, and it is already in hand.
555
+ *
556
+ * Only with sync, because follow-ups live on the server and are scoped to what this account can
557
+ * read — the same condition `resolve()` uses to look a guide up there. Fetching them records no
558
+ * pull on them: reading context for a guide is not opening those guides.
559
+ *
560
+ * `markdown` asks for each one's content too (capped server-side). The api calls are injectable
561
+ * so this can be tested without a network.
562
+ */
563
+ export async function followUpGuides(
564
+ meta = {},
565
+ { markdown = false, children = api.children, loggedIn = api.loggedIn } = {},
566
+ ) {
567
+ if (!meta.id || !loggedIn()) return [];
568
+ try {
569
+ const { guides = [] } = await children(meta.id, { markdown });
570
+ const listed = (Array.isArray(guides) ? guides : []).filter((g) => g?.id);
571
+ // With content the server already sends oldest first; the plain listing is newest first.
572
+ return markdown ? listed : listed.reverse();
573
+ } catch {
574
+ return [];
575
+ }
576
+ }
577
+
578
+ /**
579
+ * A guide's follow-ups with their content, formatted to go after the document for an agent — the
580
+ * same shape the remote server's get_guide returns — or "" when there are none.
581
+ */
582
+ export async function followUps(meta = {}, deps = {}) {
583
+ const guides = (await followUpGuides(meta, { ...deps, markdown: true })).filter(
584
+ (g) => typeof g.markdown === "string",
585
+ );
586
+ if (!guides.length) return "";
587
+ return [
588
+ FOLLOW_UPS_LEAD,
589
+ ...guides.map(
590
+ (g) => `--- follow-up ${g.id}: ${g.title || "untitled"} ---\n${g.markdown.trimEnd()}`,
591
+ ),
592
+ ].join("\n\n");
593
+ }
594
+
299
595
  /** Local guides merged with synced ones (by id), newest first, optionally filtered. */
300
596
  export async function list(query = "", { remote = true, scope = "" } = {}) {
301
597
  const local = scope && scope !== "all" && scope !== "mine" ? [] : store.search(query);
@@ -400,11 +696,149 @@ export async function setStatus(id, status) {
400
696
  throw new PassalongError(`status must be one of ${STATUSES.join(", ")}`);
401
697
  const { guide } = await resolve(id);
402
698
  const next = { ...guide, meta: { ...guide.meta, status } };
403
- store.save(next);
699
+ // The server first. Saved the other way round, a refusal left the local copy saying what the
700
+ // server had just refused — a teammate's `ready` on someone else's task read as done here.
404
701
  if (api.loggedIn() && guide.meta.url) await api.setStatus(id, status);
702
+ store.save(next);
405
703
  return next;
406
704
  }
407
705
 
706
+ /**
707
+ * Move a task from Draft to Ready: a person has read it and an agent may take it.
708
+ *
709
+ * Only tasks. Every other kind is published the moment it is shared, so "ready" on one would
710
+ * either do nothing or reopen something archived under a name that does not say so.
711
+ */
712
+ export async function ready(id) {
713
+ const { guide } = await resolve(id);
714
+ if (guide.meta.kind !== "task")
715
+ throw new PassalongError(`${guide.meta.id || id} is not a task — ready is for tasks in Draft`);
716
+ if (guide.meta.status !== "draft")
717
+ throw new PassalongError(`${guide.meta.id} is not in Draft (status: ${guide.meta.status})`);
718
+ return setStatus(guide.meta.id, "published");
719
+ }
720
+
721
+ /**
722
+ * One goal as several tasks, written as drafts in order. Each step may name the earlier steps it
723
+ * needs by position (`after: [0, 1]`), and those become its `blocked_by`, so the queue hands them
724
+ * out in an order that works and runs the independent ones side by side.
725
+ *
726
+ * Only earlier steps: a step that waits on itself or on a later one is a plan that never finishes,
727
+ * and it is refused before anything is written rather than discovered as a queue that never moves.
728
+ * Every step lands in Draft — a wrong decomposition is several agents building the wrong thing at
729
+ * once, so a person reads the plan before any of it runs.
730
+ */
731
+ export async function planTasks(steps, { cwd = process.cwd() } = {}) {
732
+ if (!Array.isArray(steps) || !steps.length)
733
+ throw new PassalongError("a plan needs at least one step");
734
+ steps.forEach((step, i) => {
735
+ for (const j of step.after || [])
736
+ if (!Number.isInteger(j) || j < 0 || j >= i)
737
+ throw new PassalongError(
738
+ `step ${i} ("${step.title}") can only wait on an earlier step, not ${j}`,
739
+ );
740
+ });
741
+ const repo = context(cwd).repo;
742
+ const ids = [];
743
+ for (const step of steps) {
744
+ const section = (name, text) =>
745
+ String(text || "").trim() ? [`## ${name}`, String(text).trim(), ""] : [];
746
+ const body = [
747
+ ...section("Goal", step.goal),
748
+ ...section("Context", step.context),
749
+ ...section("Constraints", step.constraints),
750
+ ...section("Acceptance", step.acceptance),
751
+ ...section("Out of scope", step.out_of_scope),
752
+ ].join("\n");
753
+ const meta = { title: step.title, kind: "task", target_context: step.target_context ?? repo };
754
+ const after = (step.after || []).map((j) => ids[j]);
755
+ if (after.length) meta.blocked_by = after;
756
+ ids.push((await share(serialize({ meta, body }), { cwd })).guide.meta.id);
757
+ }
758
+ return ids;
759
+ }
760
+
761
+ /** What an agent started by `work()` is told. The task itself goes in too, so it cannot start blind. */
762
+ export function workPrompt(t) {
763
+ return [
764
+ `You hold passalong task ${t.id}, and no other agent can take it while you do.`,
765
+ `It is written to ${t.path}; here it is:`,
766
+ "",
767
+ t.markdown.trim(),
768
+ "",
769
+ "Do it in this repo. There are no Steps: work out how to reach Goal within Constraints, and",
770
+ "leave Out of scope alone. Call the passalong MCP tool progress with id",
771
+ `${t.id} and a one-line status at each milestone — 30 minutes of silence stalls the task.`,
772
+ "When every Acceptance check holds, commit what you changed as one commit whose message",
773
+ `starts with "${t.id}: " — several tasks share this worktree, and a reviewer reads each one's`,
774
+ "change on its own. Then call hand_in with id",
775
+ `${t.id}, \`pr\` set to that commit's hash, and \`markdown\`: a transfer guide saying what you`,
776
+ "did, what you decided and why, and how you checked each Acceptance line. If either call",
777
+ "says you no longer hold the task, stop.",
778
+ "",
779
+ "Nobody reads what you print: this session runs unattended. If something only a person can",
780
+ "do stands between you and an Acceptance line — a permission, a secret, a decision — call",
781
+ `progress with id ${t.id} and a note starting "BLOCKED: " that says exactly what you`,
782
+ "need, leave the task unfinished and uncommitted, and stop. The board shows that note.",
783
+ ].join("\n");
784
+ }
785
+
786
+ /**
787
+ * Work the queue from this worktree: take the next task, hand it to an agent, and when the agent
788
+ * has finished it take the next, until there is nothing left.
789
+ *
790
+ * The agent runs in this worktree, so it is the same agent to the queue — its progress and
791
+ * hand_in land on the claim this took. `agent` is a command the prompt is appended to;
792
+ * `claude -p` by default, or whatever PASSALONG_AGENT says.
793
+ *
794
+ * An agent that exits without finishing stops the loop. Asking for the next task would hand the
795
+ * same one back, since a worktree holds one task at a time, and the loop would run the agent on it
796
+ * forever. The task stays claimed here, for the next run to resume or a person to release.
797
+ */
798
+ export async function work({
799
+ cwd = process.cwd(),
800
+ agent = process.env.PASSALONG_AGENT || "claude -p",
801
+ any = false,
802
+ once = false,
803
+ onTask = () => {},
804
+ } = {}) {
805
+ needsSync("working the queue");
806
+ const [command, ...args] = agent.trim().split(/\s+/);
807
+ const finished = [];
808
+ for (;;) {
809
+ const t = await nextTask({ cwd, any });
810
+ if (!t) return { finished, stopped: "empty" };
811
+ onTask(t);
812
+ const run = spawnSync(command, [...args, workPrompt(t)], {
813
+ cwd,
814
+ stdio: "inherit",
815
+ env: { ...process.env, PASSALONG_TASK: t.id, PASSALONG_TASK_PATH: t.path },
816
+ });
817
+ const state = (await tasks()).find((x) => x.id === t.id)?.state;
818
+ if (state !== "review")
819
+ return {
820
+ finished,
821
+ stopped: "unfinished",
822
+ task: t.id,
823
+ state,
824
+ exit: run.status ?? run.error?.message,
825
+ };
826
+ finished.push(t.id);
827
+ if (once) return { finished, stopped: "once" };
828
+ }
829
+ }
830
+
831
+ /**
832
+ * Make every task you wrote that is still in Draft ready, and return their ids. For the moment a
833
+ * planner has written several and you have read them all. Only your own: moving a task into the
834
+ * queue is its author's call.
835
+ */
836
+ export async function readyDrafts() {
837
+ const drafts = (await tasks()).filter((t) => t.mine && t.state === "draft");
838
+ for (const t of drafts) await ready(t.id);
839
+ return drafts.map((t) => t.id);
840
+ }
841
+
408
842
  export async function remove(id) {
409
843
  // Server first: if it refuses (not the author), the local copy must survive too.
410
844
  if (api.loggedIn()) {