passalong 0.17.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -28,25 +28,24 @@ codex mcp add passalong -- passalong mcp # Codex: the same MCP server
28
28
  ## Commands
29
29
 
30
30
  ```
31
- passalong share [file] [--to team[/handle]] publish a guide → id + link; --to hands it to a team or teammate
32
- passalong pull <id|link> fetch a guide into ./.passalong/ (git-ignored) and print it
31
+ passalong share [file] [--to team[/handle]] publish a guide as a draft → id + link; --to addresses it
32
+ passalong pull <id|link> print a guide; --save also writes ./.passalong/<id>.md (git-ignored)
33
33
  passalong inbox guides handed to you that you have not pulled yet
34
34
  passalong board waiting on you, in flight, landed, worth keeping
35
35
  passalong activity [--all] what happened while you were away; clears unless --all
36
- passalong list [query] your guides and your teams', local and synced
36
+ passalong list [query] your guides and your teams'
37
37
  passalong open <id> [--print] view a guide in the browser (or the terminal)
38
38
  passalong hub open your synced guides in the browser
39
- passalong works <id> <shot> you tried it and it holds up, with a screenshot of it working
40
- passalong broken <id> <why> you tried it and it does not — the author is told, with your reason
41
39
  passalong archive <id> off your board and out of the free tier's count (was `done`)
42
40
  passalong promote <id> deprecated: mark promoted
43
41
  passalong rm <id> delete a guide locally and from sync
44
42
  passalong export [dir] dump every guide as plain markdown
45
- passalong login [token] sign in from the browser, or attach this machine with a token
43
+ passalong login sign in from the browser
44
+ passalong login --with-token read a token from stdin instead (CI, scripts)
46
45
  passalong login --new add another account to this machine
47
46
  passalong accounts the accounts signed in here, and which is the default
48
47
  passalong use <name> make one of them the default
49
- passalong logout <name> forget one of them
48
+ passalong logout [name] forget one of them (default: the current one)
50
49
  passalong me [--handle H] [--name N] [--email E] who you are to teammates
51
50
  passalong team current team and its members
52
51
  passalong team create <name> start a team (you become its owner)
@@ -74,11 +73,10 @@ passalong inbox # on Ada's side: what was handed to you
74
73
  passalong activity # back on your side: it landed, and who pulled it
75
74
  ```
76
75
 
77
- A receiver who tries the work reports back with `passalong works <id> <screenshot.png>` or
78
- `passalong broken <id> <why>`. That is separate from `done`, which means *implemented*: a verdict
79
- means it actually runs. A passing one has to show it — at least one screenshot of it working, kept
80
- until 5 days after the guide is closed — a failing one has to say why, and the author sees it on
81
- their board and in their inbox.
76
+ Every guide, whatever its kind, moves the same way: it is a draft until a person marks it Ready
77
+ (`passalong ready <id>`), then whoever takes it (`passalong take <id>`) holds it, hands it in with
78
+ the evidence, and a person approves it or sends it back with the reason (`passalong approve` /
79
+ `passalong reject`).
82
80
 
83
81
  Sign in at [the hub](https://passalong.dev/hub) with an email and password, and mint
84
82
  an API token there for the CLI and MCP servers — named, revocable, and shown once. Your password
@@ -102,10 +100,10 @@ promote them into the team's small set of maintained references.
102
100
 
103
101
  Tools: `search_guides`, `inbox`, `get_guide`, `publish_guide` (with `to`), `guide_template`, `set_guide_status`.
104
102
 
105
- ## Guides are files
103
+ ## Guides are plain markdown
106
104
 
107
- Plain markdown with frontmatter in `~/.passalong/guides`. Yours to edit, grep, and commit.
108
- `passalong export` dumps everything. Works with no account; `passalong login` adds sync and share links.
105
+ Plain markdown with frontmatter, held on the server; nothing piles up on your machine. Ask for a
106
+ file when you want one: `passalong pull <id> --save`, or `passalong export` for every guide.
109
107
 
110
108
  Point at a self-hosted server with `PASSALONG_API=https://your-host` before `passalong login`.
111
109
 
package/bin/passalong CHANGED
@@ -7,25 +7,25 @@
7
7
  // TARGET is a team, team/@handle for one person, or "team/#group" for the people who do a
8
8
  // thing — quote that one, since a shell may read the # as a comment
9
9
  // passalong task "<what needs doing>" write a task for an agent; it waits in Draft
10
- // passalong ready <id...|--all> tasks you have read are ready — an agent may take them now
10
+ // passalong ready <id...|--all> guides you have read are ready — whoever they are for may take them now
11
11
  // passalong tasks every task and where it is: draft, ready, claimed, stalled, review
12
12
  // passalong work [--once] [--agent CMD] run an agent on each ready task here, one after another
13
- // passalong approve <id> the work in review is done
14
- // passalong reject <id> <why> not done: back to Ready, and the next agent reads why
13
+ // passalong approve <id> the work in review is done, of any kind
14
+ // passalong reject <id> <why> not done: back to Ready, and whoever takes it next reads why
15
15
  // passalong release <id> take work back from whoever holds it, of any kind
16
16
  // passalong attach <file> upload a picture or a file and print the markdown that points at it
17
17
  // passalong reply <id> <text> write to whoever holds it (--file PATH to attach); or leave a note
18
- // passalong pull <id|link> fetch a guide into ./.passalong/ and print it
19
- // passalong start <id|link> pull it and take it — one command for "I am doing this"
18
+ // passalong pull <id|link> [--save] print a guide; --save also writes ./.passalong/<id>.md
19
+ // passalong start <id|link> [--save] the same as take, by link as well as id
20
20
  // passalong inbox guides handed to you that you have not pulled yet
21
21
  // passalong board waiting on you, in flight, landed, worth keeping
22
22
  // passalong activity [--all] what happened while you were away; clears unless --all
23
23
  // passalong log [repo] what you did, newest first, by month
24
- // passalong list [query] your guides and your teams', local and synced
24
+ // passalong list [query] your guides and your teams'
25
25
  // passalong open <id> [--print] view a guide in the browser (or the terminal)
26
26
  // passalong hub open your synced guides in the browser
27
- // passalong take <id> you are doing it — said before the work, so the sender stops guessing
28
- // passalong pass <id> <why> it is not yours — hands it back to its author with your reason
27
+ // passalong take <id> [--save] you are doing it: nobody else can take it while you hold it
28
+ // passalong pass <id> <why> it is not yours, or you are stuck — hands it back with your reason
29
29
  // passalong assign <id> <to> give it to @handle (or several: @ada @bola), a #group, or "team" — yours, or assigned to you
30
30
  // passalong recall <id> take back something you sent: private again, gone from their inboxes
31
31
  // passalong admin create <email> make an account that runs Passalong (owner only)
@@ -35,16 +35,15 @@
35
35
  // give Solo to @handle, or Team to team/slug, until a date (operator only)
36
36
  // passalong gifts what has been given away, and what is still running
37
37
  // passalong ungift <gift id> take one back now
38
- // passalong works <id> <shot.png...> you tried it and it holds up — with a screenshot of it working
39
- // passalong broken <id> <why> you tried it and it does not — the author is told, with your reason
40
38
  // passalong archive <id> off your board and out of the free tier's count (was `done`)
41
39
  // passalong rm <id> delete a guide locally and from sync
42
40
  // passalong export [dir] dump every guide as plain markdown (default ./passalong-export)
43
- // passalong login [token] sign in from the browser, or attach this machine with a token
41
+ // passalong login sign in from the browser
42
+ // passalong login --with-token read a token from stdin instead (CI, scripts)
44
43
  // passalong login --new add another account to this machine
45
44
  // passalong accounts the accounts signed in here, and which is the default
46
45
  // passalong use <name> make one of them the default
47
- // passalong logout <name> forget one of them
46
+ // passalong logout [name] forget one of them (default: the current one)
48
47
  // passalong me [--handle H] [--name N] [--email E] who you are to teammates
49
48
  // passalong team current team and its members
50
49
  // passalong team create <name> start a team (you become its owner)
@@ -65,7 +64,7 @@ import { dirname, join, resolve } from "node:path";
65
64
  import { fileURLToPath } from "node:url";
66
65
  import * as api from "../src/api.js";
67
66
  import { scaffold, taskScaffold } from "../src/capture.js";
68
- import { serialize } from "../src/guide.js";
67
+ import { parse, serialize } from "../src/guide.js";
69
68
  import * as passalong from "../src/passalong.js";
70
69
  import * as store from "../src/store.js";
71
70
 
@@ -167,75 +166,20 @@ async function browserLogin() {
167
166
  const got = await api.devicePoll(start.id, verifier).catch((e) => die(e.message));
168
167
  if (!got) continue;
169
168
  await keepLogin(got.token);
170
- console.error("Attach other machines with:");
171
- console.error(` passalong login ${got.token}`);
172
- console.error("(keep it private — it is a credential, not a password)");
173
169
  console.error("Give teammates a name to address you by: passalong me --handle <you>");
174
170
  return;
175
171
  }
176
172
  die("nobody approved it in time. Run `passalong login` again.");
177
173
  }
178
174
 
179
- /** The old way, kept for anyone who asks for it with `--email`: an email and a password, typed here. */
180
- async function passwordLogin(f) {
181
- // An account you cannot sign in to is one that dies with this file. This used to mint one
182
- // silently and then suggest adding a password afterwards; it asks up front instead.
183
- if (!tty)
184
- die(
185
- "no terminal to ask on. Sign in at " +
186
- `${api.baseUrl()}/hub, then attach this machine with \`passalong login <token>\``,
187
- );
188
-
189
- const email = f.email || (await ask("Email: "));
190
- if (!email) die("an email is needed to sign in");
191
- const password = await ask("Password: ", { hidden: true });
192
- if (!password) die("a password is needed to sign in");
193
-
194
- const label = `${hostname()} cli`;
195
- let who = await api.signIn(email, password, { label }).catch((e) => e);
196
- if (who instanceof Error) {
197
- // 401 is "no such account, or the wrong password", said the same way on purpose so the API
198
- // does not confirm which addresses exist. The CLI has to ask rather than guess, because
199
- // creating an account for a mistyped address is worse than being told to try again.
200
- if (who.status !== 401) die(who.message);
201
- const yes = (await ask(`No account signed in as ${email}. Create one? [y/N] `)).toLowerCase();
202
- if (yes !== "y" && yes !== "yes")
203
- die("not signed in. If the password was wrong, try again, or reset it at " + `${api.baseUrl()}/hub`);
204
- const again = await ask("Choose a password: ", { hidden: true });
205
- if (again !== password) die("those did not match");
206
- who = await api.signIn(email, password, { make: true, label }).catch((e) => die(e.message));
207
- }
208
-
209
- await keepLogin(who.token);
210
- console.error("Attach other machines with:");
211
- console.error(` passalong login ${who.token}`);
212
- console.error("(keep it private — it is a credential, not a password)");
213
- console.error("Give teammates a name to address you by: passalong me --handle <you>");
214
- }
215
-
216
- async function ask(question, { hidden = false } = {}) {
217
- const { createInterface } = await import("node:readline");
218
- const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: true });
219
- if (hidden) {
220
- // readline still writes the prompt itself; everything after it is swallowed. The newline the
221
- // user's Return would have echoed is put back by hand, or the next line starts mid-prompt.
222
- rl.output.write(question);
223
- rl._writeToOutput = () => {};
224
- }
225
- try {
226
- // `close` as well as an answer: stdin reaching EOF part-way through — a pipe, a closed pty, a
227
- // ^D — never calls the question's callback, and without this the promise is simply never
228
- // settled and the process exits on a warning about it rather than saying what happened.
229
- const answer = await new Promise((done) => {
230
- rl.on("close", () => done(null));
231
- rl.question(hidden ? "" : question, done);
232
- });
233
- if (hidden) process.stdout.write("\n");
234
- if (answer === null) die("nothing to read from — sign in at the terminal, or pass a token");
235
- return answer.trim();
236
- } finally {
237
- rl.close();
238
- }
175
+ /** A token piped in, for machines with no browser: `passalong login --with-token < token.txt`. */
176
+ async function stdinLogin() {
177
+ if (process.stdin.isTTY) die("--with-token reads the token from stdin: passalong login --with-token < token.txt");
178
+ let raw = "";
179
+ for await (const chunk of process.stdin) raw += chunk;
180
+ const token = raw.trim();
181
+ if (!token) die("no token on stdin");
182
+ await keepLogin(token);
239
183
  }
240
184
 
241
185
  function edit(path) {
@@ -360,7 +304,7 @@ const commands = {
360
304
  if (tty && !f["no-edit"]) edit(path);
361
305
  const to = typeof f.to === "string" ? f.to : undefined;
362
306
  const follows = typeof f.follows === "string" ? f.follows : undefined;
363
- const { guide, url, synced, notified, path: saved } = await passalong
307
+ const { guide, url, notified } = await passalong
364
308
  .share(readFileSync(path, "utf8"), { to, follows })
365
309
  .catch((e) => die(e.message));
366
310
  // Any draft that went out is spent, however it was named — a task that failed validation is
@@ -368,14 +312,13 @@ const commands = {
368
312
  // removeDraft() touches nothing outside the drafts directory.
369
313
  store.removeDraft(resolve(path));
370
314
  console.log(guide.meta.id);
371
- console.error(`shared "${guide.meta.title}" → ${saved}`);
315
+ console.error(`shared "${guide.meta.title}"`);
372
316
  if (guide.meta.parent) console.error(`follows ${guide.meta.parent}`);
373
317
  if (guide.meta.team)
374
318
  console.error(
375
319
  `handed to ${guide.meta.team}${guide.meta.to ? `/@${guide.meta.to}${notified ? " (emailed)" : " (in their inbox)"}` : " (team inbox)"}`,
376
320
  );
377
- if (synced) console.error(`link: ${url}`);
378
- else console.error("not synced (run `passalong login` to sync across machines)");
321
+ console.error(`link: ${url}`);
379
322
  },
380
323
 
381
324
  /**
@@ -387,7 +330,7 @@ const commands = {
387
330
  const path = store.draftPath(`task-${new Date().toISOString().replace(/[:.]/g, "-")}`);
388
331
  writeFileSync(path, taskScaffold(sentence));
389
332
  if (tty && !f["no-edit"]) edit(path);
390
- const { guide, synced } = await passalong
333
+ const { guide } = await passalong
391
334
  .share(readFileSync(path, "utf8"))
392
335
  .catch((e) => die(`${e.message}\ndraft kept at ${path} — fix it and run \`passalong share ${path}\``));
393
336
  store.removeDraft(path);
@@ -397,7 +340,6 @@ const commands = {
397
340
  console.error(`task "${guide.meta.title}" is in Draft${target ? ` for ${target}` : " (no repo)"}`);
398
341
  if (guide.meta.team) console.error(`handed to ${guide.meta.team} (team inbox)`);
399
342
  console.error(`when an agent may take it: passalong ready ${id}`);
400
- if (!synced) console.error("not synced (run `passalong login` — agents elsewhere cannot see it)");
401
343
  },
402
344
 
403
345
  async tasks() {
@@ -480,31 +422,30 @@ const commands = {
480
422
 
481
423
  async pull(f) {
482
424
  const ref = f._[0] || die("usage: passalong pull <id|link>");
483
- const { guide, markdown, path, from } = await passalong.pull(ref).catch((e) => die(e.message));
425
+ const { guide, markdown, path } = await passalong
426
+ .pull(ref, { save: Boolean(f.save) })
427
+ .catch((e) => die(e.message));
484
428
  process.stdout.write(markdown);
485
- console.error(`\npulled from ${from} → ${path}`);
429
+ if (path) console.error(`\nsaved to ${path}`);
486
430
  await listFollowUps(guide.meta);
487
431
  },
488
432
 
489
433
  /**
490
- * Pull and take in one command. `pull` says it arrived, `take` says somebody is doing it, and
491
- * they stay separate because reading a guide to decide it is not yours is what `pass` is for.
492
- * This is the pair for the case where you already know: you are picking this up.
434
+ * Take it: you are doing it, and nobody else can take it while you hold it. The guide is printed,
435
+ * and `--save` also writes it to ./.passalong/. Every kind is taken the same way, and `start` is
436
+ * the same command by its older name, which also takes a share link.
493
437
  */
494
438
  async start(f) {
495
- const ref = f._[0] || die("usage: passalong start <id|link>");
496
- const r = await passalong.start(ref).catch((e) => die(e.message));
439
+ const ref = f._[0] || die("usage: passalong take <id|link>");
440
+ const r = await passalong.start(ref, { save: Boolean(f.save) }).catch((e) => die(e.message));
441
+ if (!r.guide?.markdown) return console.error(r.say || "nothing to take");
497
442
  process.stdout.write(r.markdown);
498
- console.error(`\npulled from ${r.from} → ${r.path}`);
499
- if (r.took) console.error("you are on it, and the author has been told");
500
- else if (r.own) console.error("your own guide — nothing to take");
501
- else if (r.ack_error) console.error(`not taken (${r.ack_error}) — run \`passalong take ${r.guide.meta.id}\``);
502
- // Saying nothing here would be the silence the whole hop exists to end: you would have the
503
- // guide, believe you had answered for it, and the sender would still be waiting.
504
- else console.error("not taken — sync is off, so nobody was told (run `passalong login`)");
505
- await listFollowUps(r.guide.meta);
443
+ if (r.path) console.error(`\nsaved to ${r.path}`);
444
+ console.error(`\nyou hold ${r.guide.id} — hand it in when it is done, or pass it with the reason`);
445
+ await listFollowUps(parse(r.markdown).meta);
506
446
  },
507
447
 
448
+
508
449
  /**
509
450
  * Upload an image and print the markdown that points at it. Evidence goes *in* the document:
510
451
  * a guide travels as markdown to whoever holds its link, so a file beside it does not travel.
@@ -612,7 +553,6 @@ const commands = {
612
553
  async list(f) {
613
554
  const scope = typeof f.team === "string" ? f.team : f.mine ? "mine" : "";
614
555
  const rows = await passalong.list(f._.join(" "), { scope });
615
- if (rows.warning) console.error(`warning: ${rows.warning}`);
616
556
  if (!rows.length)
617
557
  return console.error(
618
558
  "no guides yet — run `passalong share` after your next finished piece of work",
@@ -644,17 +584,8 @@ const commands = {
644
584
  } else console.log(url);
645
585
  },
646
586
 
647
- /**
648
- * The first word back, before any of the work.
649
- *
650
- * Between a handoff and a pull the sender had no signal at all: an unanswered guide looks
651
- * exactly like one nobody has noticed. These two are the hop the MCP tool and the hub already
652
- * had and a terminal did not.
653
- */
654
587
  async take(f) {
655
- const id = f._[0] || die("usage: passalong take <id>");
656
- const r = await passalong.ack(id, true).catch((e) => die(e.message));
657
- console.error(`${r.id} — you are on it, and the author has been told`);
588
+ return commands.start(f);
658
589
  },
659
590
 
660
591
  async pass(f) {
@@ -663,10 +594,11 @@ const commands = {
663
594
  // Required, and the reason is the whole point: "not me" without "why" leaves the sender where
664
595
  // the silence did, still guessing whether to re-address it, wait, or go and ask.
665
596
  if (!why) die("say why you are passing: passalong pass <id> <why it is not yours>");
666
- const r = await passalong.ack(id, false, why).catch((e) => die(e.message));
667
- console.error(`${r.id} handed back — the author has been told: "${r.note}"`);
597
+ await passalong.pass(id, why).catch((e) => die(e.message));
598
+ console.error(`${id} handed back with your reason: "${why}"`);
668
599
  },
669
600
 
601
+
670
602
  async assign(f) {
671
603
  const id = f._[0] || die('usage: passalong assign <id> <@handle | #group | team>');
672
604
  const to = f._.slice(1).join(",") || die('say who: @handle (or several), #group, or "team" for everyone');
@@ -701,7 +633,7 @@ const commands = {
701
633
  console.log(`token ${r.token}`);
702
634
  console.log(`password link ${r.password_url}`);
703
635
  console.error(
704
- `\n${r.email} is an admin. The token is for \`passalong login\`, the link sets their ` +
636
+ `\n${r.email} is an admin. The token is for \`passalong login --with-token\`, the link sets their ` +
705
637
  `password for the hub, and it expires ${r.expires.slice(0, 10)}. Neither is shown again.`,
706
638
  );
707
639
  return;
@@ -755,23 +687,6 @@ const commands = {
755
687
  console.error(`${id} taken back — that plan is lapsed as of now`);
756
688
  },
757
689
 
758
- async works(f) {
759
- const id = f._[0] || die("usage: passalong works <id> <screenshot.png...> [what you checked]");
760
- const { images, note } = passalong.proofArgs(f._.slice(1));
761
- const r = await passalong.verdict(id, true, note, { images }).catch((e) => die(e.message));
762
- console.error(
763
- `${r.id} verified with ${images.length || "your"} screenshot${images.length === 1 ? "" : "s"} — the author has been told`,
764
- );
765
- },
766
-
767
- async broken(f) {
768
- const id = f._[0] || die("usage: passalong broken <id> <what happened>");
769
- const why = f._.slice(1).join(" ");
770
- if (!why) die("say what went wrong: passalong broken <id> <what happened>");
771
- const r = await passalong.verdict(id, false, why).catch((e) => die(e.message));
772
- console.error(`${r.id} reported as not working — the author has been told: "${r.note}"`);
773
- },
774
-
775
690
  /**
776
691
  * Archive a guide: off your board, and out of what the free tier counts.
777
692
  *
@@ -806,29 +721,26 @@ const commands = {
806
721
 
807
722
  async export(f) {
808
723
  const dir = resolve(f._[0] || "passalong-export");
809
- const paths = passalong.exportAll(dir);
724
+ const paths = await passalong.exportAll(dir).catch((e) => die(e.message));
810
725
  console.error(`exported ${paths.length} guide${paths.length === 1 ? "" : "s"} → ${dir}`);
811
726
  },
812
727
 
813
728
  async login(f) {
814
- const given = f._[0];
815
- if (given) {
816
- await keepLogin(given);
817
- return;
818
- }
729
+ // A token on the command line lands in shell history, so it is only read from stdin.
730
+ if (f._[0]) die("pass a token on stdin instead: passalong login --with-token < token.txt");
731
+ if (f.email || f.password)
732
+ die("password sign-in is gone: run `passalong login` and approve it in the browser");
733
+ if (f["with-token"]) return stdinLogin();
819
734
  if (api.loggedIn() && !f.new) {
820
735
  const who = await api.me().catch(() => null);
821
736
  if (who) {
822
- console.error(`already logged in (account ${who.account}). Attach another machine with:`);
823
- console.error(` passalong login ${api.token()}`);
737
+ console.error(`already logged in as ${who.handle ? `@${who.handle}` : who.email || who.account}.`);
824
738
  console.error("Add another account to this machine with `passalong login --new`.");
825
739
  return;
826
740
  }
827
741
  }
828
- // Passwords are for someone who asks: `--email` keeps the prompts. Everyone else is sent to the
829
- // browser, which works with no terminal at all — an agent can start it, and the person it is
742
+ // The browser works with no terminal at all — an agent can start it, and the person it is
830
743
  // working for clicks once.
831
- if (f.email || f.password) return passwordLogin(f);
832
744
  return browserLogin();
833
745
  },
834
746
 
@@ -865,7 +777,7 @@ const commands = {
865
777
 
866
778
  /** Forget one of them on this machine. The account itself is untouched: only its token here goes. */
867
779
  async logout(f) {
868
- const name = f._[0] || die("usage: passalong logout <name> (see `passalong accounts`)");
780
+ const name = f._[0] || store.readConfig().active || die("not logged in (see `passalong accounts`)");
869
781
  const r = store.removeAccount(name);
870
782
  if (!r.removed) die(`no account called "${name}" here`);
871
783
  console.error(
@@ -1135,6 +1047,8 @@ else if (!commands[cmd])
1135
1047
  "if you expected this command, `npm i -g passalong` for the newest one.",
1136
1048
  );
1137
1049
  else {
1050
+ // Once per machine; a failure leaves it for the next command rather than failing this one.
1051
+ await passalong.tidy().catch(() => {});
1138
1052
  await commands[cmd](argv).catch((e) => die(e.message));
1139
1053
  // After the command, never before it: a version check is not worth a millisecond of somebody's
1140
1054
  // first impression, and it must never be the reason a command felt slow. `mcp` is a server that
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "passalong",
3
- "version": "0.17.0",
3
+ "version": "0.18.0",
4
4
  "description": "Hand tasks, bugs and finished work to AI coding agents, and get back a write-up with evidence.",
5
5
  "mcpName": "io.github.codellyson/passalong",
6
6
  "keywords": [
package/skill/SKILL.md CHANGED
@@ -66,13 +66,13 @@ machine, another agent session, or a teammate without your branch.
66
66
  whoever opens the original, person or agent. It travels the other way too — whoever opens the
67
67
  follow-up gets the guide it came out of, and where that guide has got to — so write it as a
68
68
  note on that work rather than as a standalone brief. What you did or found while working on a
69
- guide belongs in its `hand_in`, not a follow-up. Whether a guide worked is its own answer:
70
- `passalong works <id> <screenshot>` or `passalong broken <id> <why>`.
71
- 6. **Report** the id (and link if synced) and one line on how to use it on the other side:
72
- `passalong start <id>` in the target repo, or "start passalong <id>" to an agent with the
73
- Passalong MCP server. `start` rather than `pull` on purpose: it takes the handoff as well as
74
- fetching it, and until somebody says they are on it the sender cannot tell a guide nobody
75
- noticed from one somebody is deep in.
69
+ guide belongs in its `hand_in`, not a follow-up.
70
+ 6. **Report** the id (and link) and say it is a **draft**: it reaches nobody until the person reads
71
+ it and marks it Ready (`passalong ready <id>`, or Ready in the hub). Then one line on how to use
72
+ it on the other side: `passalong take <id>` in the target repo, or "take passalong <id>" to an
73
+ agent with the Passalong MCP server. `take` rather than `pull` on purpose: it holds the work as
74
+ well as fetching it, and until somebody takes it the sender cannot tell a guide nobody noticed
75
+ from one somebody is deep in.
76
76
 
77
77
  If the `passalong` command is missing, tell the user to run `npm i -g passalong && passalong setup` and
78
78
  still write the draft file so nothing is lost.
package/src/api.js CHANGED
@@ -337,10 +337,17 @@ export const renameFolderDocument = (folderId, documentId, name) =>
337
337
  method: "PATCH",
338
338
  body: { name },
339
339
  });
340
+ export const moveFolderDocument = (folderId, documentId, to, name = "") =>
341
+ call(`/v1/folders/${encodeURIComponent(folderId)}/documents/${encodeURIComponent(documentId)}`, {
342
+ method: "PATCH",
343
+ body: name ? { folder: to, name } : { folder: to },
344
+ });
340
345
  export const folderDocumentHistory = (folderId, documentId, version = 0) =>
341
346
  call(
342
347
  `/v1/folders/${encodeURIComponent(folderId)}/documents/${encodeURIComponent(documentId)}/revisions${version ? `/${version}` : ""}`,
343
348
  );
349
+ export const shareFolder = (folderId) =>
350
+ call(`/v1/folders/${encodeURIComponent(folderId)}/share`, { method: "POST" });
344
351
  export const linkFolderGuide = (folderId, guide) =>
345
352
  call(`/v1/folders/${encodeURIComponent(folderId)}/guides`, { method: "POST", body: { guide } });
346
353
  export const unlinkFolderGuide = (folderId, guide) =>
@@ -372,7 +379,10 @@ export const inbox = () => call("/v1/inbox");
372
379
  export const createReport = (body) => call("/v1/reports", { method: "POST", body });
373
380
  /** One report and the issues filed under it, grouped by product area. */
374
381
  export const report = (id) => call(`/v1/reports/${encodeURIComponent(id)}`);
375
- export const get = (id) => call(`/v1/guides/${id}`, { raw: true });
382
+ export const get = (id, { peek = false } = {}) =>
383
+ call(`/v1/guides/${id}${q({ peek: peek ? "1" : "" })}`, { raw: true });
384
+ /** One page of every guide this account can read, for `export`. Records no pull. */
385
+ export const exportPage = (after = "") => call(`/v1/export${q({ after })}`);
376
386
  /**
377
387
  * A guide's follow-ups, one level down. `markdown` adds each one's content, oldest first and
378
388
  * capped server-side; neither form counts as opening them.
@@ -419,6 +429,11 @@ export const approveTask = (id) =>
419
429
  call(`/v1/tasks/${encodeURIComponent(id)}/approve`, { method: "POST", body: {} });
420
430
  export const rejectTask = (id, why) =>
421
431
  call(`/v1/tasks/${encodeURIComponent(id)}/reject`, { method: "POST", body: { why } });
432
+ /** A handoff's or bug's approve and send-back. No `place` names the only hand-in there is. */
433
+ export const closeGuide = (id) =>
434
+ call(`/v1/guides/${encodeURIComponent(id)}/close`, { method: "POST", body: {} });
435
+ export const sendBack = (id, why) =>
436
+ call(`/v1/guides/${encodeURIComponent(id)}/send_back`, { method: "POST", body: { why } });
422
437
  /** Take work back from whoever holds it, of any kind. `/v1/tasks/…/release` is the old path. */
423
438
  export const release = (id) =>
424
439
  call(`/v1/guides/${encodeURIComponent(id)}/release`, { method: "POST", body: {} });
package/src/guide.js CHANGED
@@ -525,7 +525,8 @@ export function bugGuide({
525
525
  area = "",
526
526
  report = "",
527
527
  environment = "",
528
- status = "published",
528
+ // A draft like every new guide: it reaches people when a person marks it Ready.
529
+ status = "draft",
529
530
  evidence = [],
530
531
  } = {}) {
531
532
  /**
package/src/mcp.js CHANGED
@@ -297,11 +297,13 @@ export function buildServer() {
297
297
  "to act. It is what the person sees first in their hub; the document is behind it. A guide " +
298
298
  "with no summary is refused.\n" +
299
299
  "SAY WHICH KIND IT IS. There is no default: a guide with no `kind:` line is refused, and so " +
300
- "is a spelling that is not one of the three. " +
300
+ "is a spelling that is not one of the three. EVERY NEW GUIDE IS A DRAFT, whatever its kind: " +
301
+ "it reaches nobody until a person reads it and marks it Ready, and every kind then moves the " +
302
+ "same way — Ready, taken, handed in, approved. " +
301
303
  `kind: task is work nobody has done yet. Sections: ${TASK_SECTIONS.join(", ")}. ` +
302
304
  "It has no Steps: work out how to reach Goal within Constraints, leave Out of scope " +
303
- "alone, and treat Acceptance as the definition of done. New tasks say `status: draft`; " +
304
- "a proposal awaiting a team decision stays Draft until a person makes it Ready. hand_in with `markdown`: a " +
305
+ "alone, and treat Acceptance as the definition of done. A proposal awaiting a team decision " +
306
+ "stays Draft until a person makes it Ready. hand_in with `markdown`: a " +
305
307
  "transfer guide about what you did (guide_template kind transfer shows the shape) — its " +
306
308
  "author reviews it against Acceptance.\n" +
307
309
  `kind: transfer (or no kind: line) is a finished implementation to repeat here. Sections: ${SECTIONS.join(", ")}. ` +
@@ -346,6 +348,18 @@ export function buildServer() {
346
348
  "the user asks what they have been working on, or wants a standup or a summary of a " +
347
349
  "period, call log — but say that it holds what they passed along and not everything they " +
348
350
  "did. Gotchas are the highest-value section: record what failed and why.\n" +
351
+ "A FOLDER IS A PLACE; ITS DOCUMENTS ARE WHAT TO KNOW; GUIDES ARE WHAT TO DO. Sort every " +
352
+ "item before writing it into a folder. If someone still has to act on it — a defect that " +
353
+ "is still open, work not yet done, a check nobody has run — it is a guide: kind bug for " +
354
+ "something broken, kind task for work to do. Publish it and link_folder_guide it into the " +
355
+ "folder; a guide has an owner, a state and an answer. A document has none of those and " +
356
+ "tells nobody, so open work recorded only as a document is lost. If it is what someone " +
357
+ "needs to know — an overview, a decision, a finding, something already fixed with its " +
358
+ "commit — it is a document. An incident is both: one overview document, a document per " +
359
+ "resolved finding, and a bug or task guide per open item, all in the incident's folder. " +
360
+ "WHERE A FOLDER LIVES is in the `space` field of create_folder and get_folder: private, " +
361
+ "or a named team. Tell the person that, never what you meant to do; a folder made without " +
362
+ "`team` is private, and a subfolder takes its parent's space.\n" +
349
363
  "FOLDERS ARE REUSABLE PROJECT CONTEXT, NOT GUIDES. When the user asks for a folder for a " +
350
364
  "script, brief, assets or work spanning several guides, call list_folders first to avoid " +
351
365
  "making a duplicate. If none fits, call create_folder with a title and short description. " +
@@ -366,8 +380,11 @@ export function buildServer() {
366
380
  "unlink_folder_guide takes it out again. Delete a document or file with " +
367
381
  "delete_folder_document or delete_folder_asset only when the person asked for it to go: " +
368
382
  "neither can be undone. No tool deletes a whole folder; a person does that in the hub. " +
383
+ "share_folder returns a link anyone can read the folder by, like a guide's; use it only " +
384
+ "when the person asked to share the folder. " +
369
385
  "list_folders with query finds folders and documents by their words, and with guide the " +
370
- "folders a guide is in. rename_folder_document renames a document; " +
386
+ "folders a guide is in. rename_folder_document renames a document and move_folder_document " +
387
+ "moves it to another folder in the same space, keeping its history; " +
371
388
  "get_folder_document_history reads earlier versions, so a bad edit can be put back.\n" +
372
389
  "A FOLLOW-UP IS MORE CONTEXT FOR A GUIDE, WRITTEN AS ITS OWN GUIDE. When a guide needs " +
373
390
  "more context — a missing detail, a step that needed explaining, what changed since, what " +
@@ -404,15 +421,6 @@ export function buildServer() {
404
421
  async function doTake({ id, cwd, any }) {
405
422
  const dir = cwd || process.cwd();
406
423
  try {
407
- // Without sync there is no claim to make; a guide by id can still be read and worked from.
408
- if (!api.loggedIn() && id) {
409
- const r = await passalong.pull(id, { cwd: dir });
410
- return text(
411
- `${leadFor(parse(r.markdown).meta)}${r.markdown}\n\n<!-- passalong: not logged in, so ` +
412
- "nobody was told you took it; written to " +
413
- `${r.path} -->`,
414
- );
415
- }
416
424
  const r = await passalong.take(id, { cwd: dir, any: Boolean(any) });
417
425
  if (!r.guide) return text(nextNote(r) || "Nothing is waiting for this agent here.");
418
426
  const meta = parse(r.guide.markdown).meta;
@@ -421,8 +429,7 @@ export function buildServer() {
421
429
  const from = await passalong.parentGuide(meta);
422
430
  const held =
423
431
  `<!-- passalong: ${r.guide.id} is yours` +
424
- `${r.guide.resumed ? " (you already held it — carry on from where it was left)" : ""}; ` +
425
- `written to ${r.path}. -->`;
432
+ `${r.guide.resumed ? " (you already held it — carry on from where it was left)" : ""}. -->`;
426
433
  const said = repliesNote(r.replies);
427
434
  return text(
428
435
  `${said ? `${said}\n\n` : ""}${from}${leadFor(meta)}${r.guide.markdown}${siblings}${context ? `\n\n${context}` : ""}` +
@@ -559,7 +566,7 @@ export function buildServer() {
559
566
  title: "Create project folder",
560
567
  annotations: ADDS,
561
568
  description:
562
- "Create a folder or nested subfolder after checking list_folders. Pass parent to nest it; omit team for a private root, and pass a team slug only when sharing was requested. A subfolder inherits its parent's space.",
569
+ "Create a folder or nested subfolder after checking list_folders. Pass parent to nest it; omit team for a private root, and pass a team slug only when sharing was requested. A subfolder inherits its parent's space. The answer's `space` says where it landed: report that to the person.",
563
570
  inputSchema: {
564
571
  title: z.string().describe("Name of the project or body of work"),
565
572
  description: z.string().optional().describe("Short purpose of the folder"),
@@ -702,7 +709,8 @@ export function buildServer() {
702
709
  {
703
710
  title: "Write new folder document",
704
711
  annotations: ADDS,
705
- description: "Add Markdown such as a brief or script to a folder.",
712
+ description:
713
+ "Add a Markdown document to a folder: reference to read, such as a brief, script, overview or a finding already resolved. It has no owner and no state and notifies nobody, so anything someone must still act on (an open bug, work to do) belongs in a bug or task guide linked to the folder instead.",
706
714
  inputSchema: { folder: z.string(), name: z.string(), body: z.string() },
707
715
  },
708
716
  async ({ folder, name, body }) => {
@@ -752,6 +760,45 @@ export function buildServer() {
752
760
  }
753
761
  },
754
762
  );
763
+ server.registerTool(
764
+ "share_folder",
765
+ {
766
+ title: "Share folder link",
767
+ annotations: ADDS,
768
+ description:
769
+ "Get the folder's share link, making one if it has none: like a guide's link, anyone holding it can read the folder and the folders inside it and download its files, with no account. Linked guides are not included. Call it only when the person asked to share the folder or for its link; stopping sharing is done in the hub.",
770
+ inputSchema: { folder: z.string() },
771
+ },
772
+ async ({ folder }) => {
773
+ try {
774
+ return json(await api.shareFolder(folder));
775
+ } catch (err) {
776
+ return fail(err);
777
+ }
778
+ },
779
+ );
780
+ server.registerTool(
781
+ "move_folder_document",
782
+ {
783
+ title: "Move folder document",
784
+ annotations: ADDS,
785
+ description:
786
+ "Move a folder document into another folder in the same space, private or the same team. It keeps its id, text and history; pass name too to rename it on the way. Refused if the other folder already has a document by that name.",
787
+ inputSchema: {
788
+ folder: z.string().describe("The folder the document is in now"),
789
+ document: z.string(),
790
+ to: z.string().describe("The folder to move it into"),
791
+ name: z.string().optional().describe("A new name, if it should change too"),
792
+ },
793
+ },
794
+ async ({ folder, document, to, name }) => {
795
+ try {
796
+ return json(await api.moveFolderDocument(folder, document, to, name || ""));
797
+ } catch (err) {
798
+ return fail(err);
799
+ }
800
+ },
801
+ );
755
802
  server.registerTool(
756
803
  "get_folder_document_history",
757
804
  {
@@ -852,7 +899,7 @@ export function buildServer() {
852
899
  annotations: READS,
853
900
  description:
854
901
  "Search transfer guides by words in the title, tags, stack, or body: the user's own " +
855
- "(local and synced) plus every team they belong to. Empty query lists everything, newest first.",
902
+ "plus every team they belong to. Empty query lists everything, newest first.",
856
903
  inputSchema: {
857
904
  query: z.string().default(""),
858
905
  scope: z.string().optional().describe('"mine", "all" (default), or a team slug'),
@@ -861,7 +908,7 @@ export function buildServer() {
861
908
  async ({ query, scope }) => {
862
909
  try {
863
910
  const rows = await passalong.list(query, { scope: scope || "" });
864
- return json({ guides: rows, warning: rows.warning });
911
+ return json({ guides: rows });
865
912
  } catch (err) {
866
913
  return fail(err);
867
914
  }
@@ -1288,18 +1335,13 @@ export function buildServer() {
1288
1335
  description:
1289
1336
  "READ a transfer guide by passalong id or share link and return its markdown. A guide over " +
1290
1337
  "20,000 characters comes back as an outline (its headings and their sizes) unless you ask " +
1291
- "for `section`, for one part, or `full`, for all of it. Also " +
1292
- "writes it to .passalong/<id>.md in the working directory so it survives the session. " +
1338
+ "for `section`, for one part, or `full`, for all of it. Nothing is written to disk. " +
1293
1339
  "Pulling a teammate's guide tells them the transfer landed. Use this to look at a guide " +
1294
1340
  "you have not committed to. If you are about to DO the work, call take instead: " +
1295
1341
  "it does this and takes it in one call, which is the only way the sender learns " +
1296
1342
  "somebody picked it up.",
1297
1343
  inputSchema: {
1298
1344
  ref: z.string().describe("passalong id (e.g. k3mq2xa7) or share URL"),
1299
- cwd: z
1300
- .string()
1301
- .optional()
1302
- .describe("directory to write .passalong/<id>.md into; default is the server's cwd"),
1303
1345
  section: z
1304
1346
  .string()
1305
1347
  .optional()
@@ -1314,23 +1356,23 @@ export function buildServer() {
1314
1356
  .describe("the whole document, even one over 20,000 characters"),
1315
1357
  },
1316
1358
  },
1317
- async ({ ref, cwd, section, outline, full }) => {
1359
+ async ({ ref, section, outline, full }) => {
1318
1360
  try {
1319
- const { markdown, path, from } = await passalong.pull(ref, { cwd: cwd || process.cwd() });
1361
+ const { markdown, from } = await passalong.pull(ref);
1320
1362
  const meta = parse(markdown).meta;
1321
1363
  if (section) {
1322
1364
  const part = sectionOf(markdown, section);
1323
1365
  if (!part)
1324
1366
  return text(
1325
- `No heading matching "${section}".\n\n${outlineText(markdown, { id: meta.id || ref, path })}`,
1367
+ `No heading matching "${section}".\n\n${outlineText(markdown, { id: meta.id || ref })}`,
1326
1368
  );
1327
1369
  return text(
1328
- `${part.text}\n\n<!-- passalong: ${meta.id || ref}, "${part.heading}" only; the whole guide is at ${path} -->`,
1370
+ `${part.text}\n\n<!-- passalong: ${meta.id || ref}, "${part.heading}" only; ask with full: true for the whole guide -->`,
1329
1371
  );
1330
1372
  }
1331
1373
  if (outline || (!full && markdown.length > GUIDE_WARN))
1332
1374
  return text(
1333
- `${outlineText(markdown, { id: meta.id || ref, path })}\n\n<!-- passalong: ${from}; written to ${path} -->`,
1375
+ `${outlineText(markdown, { id: meta.id || ref })}\n\n<!-- passalong: ${from} -->`,
1334
1376
  );
1335
1377
  const lead = leadFor(meta);
1336
1378
  const siblings = await related(meta);
@@ -1347,7 +1389,7 @@ export function buildServer() {
1347
1389
  // warning against executing the document, so being read first is its whole job.
1348
1390
  return text(
1349
1391
  `${follows}${lead}${markdown}${siblings}${context ? `\n\n${context}` : ""}` +
1350
- `\n\n<!-- passalong: ${from}; written to ${path} -->` +
1392
+ `\n\n<!-- passalong: ${from} -->` +
1351
1393
  `\n${passalong.handoffNudge(meta)}` +
1352
1394
  `\n${passalong.followUpNote(meta)}`,
1353
1395
  );
@@ -1517,7 +1559,7 @@ export function buildServer() {
1517
1559
  },
1518
1560
  async ({ markdown, to, cwd, parent }) => {
1519
1561
  try {
1520
- const { guide, url, synced, notified, path } = await passalong.share(markdown, {
1562
+ const { guide, url, synced, notified } = await passalong.share(markdown, {
1521
1563
  cwd: cwd || process.cwd(),
1522
1564
  to,
1523
1565
  follows: parent,
@@ -1531,11 +1573,10 @@ export function buildServer() {
1531
1573
  to: guide.meta.to || "",
1532
1574
  parent: guide.meta.parent || "",
1533
1575
  notified,
1534
- path,
1535
1576
  ...(sizeWarning(markdown.length) ? { warning: sizeWarning(markdown.length) } : {}),
1536
- // A task does not go live on publish, and an agent that reports "queued it" has told
1537
- // the user something untrue. Said in the result so it is said to them.
1538
- ...(guide.meta.kind === "task" && guide.meta.status === "draft"
1577
+ // Nothing goes live on publish, and an agent that reports "sent it" or "queued it" has
1578
+ // told the user something untrue. Said in the result so it is said to them.
1579
+ ...(guide.meta.status === "draft"
1539
1580
  ? {
1540
1581
  status: "draft",
1541
1582
  next: `in Draft: ask the user to read it, then run \`passalong ready ${guide.meta.id}\``,
@@ -1636,7 +1677,11 @@ export function buildServer() {
1636
1677
  { title: title || "", environment: environment || "", issues },
1637
1678
  { cwd: cwd || process.cwd(), to },
1638
1679
  );
1639
- return json(filed);
1680
+ return json({
1681
+ ...filed,
1682
+ // Filed is not sent: every bug is a draft until a person reads it.
1683
+ next: "all in Draft: ask the user to read them, then run `passalong ready --all`",
1684
+ });
1640
1685
  } catch (err) {
1641
1686
  return fail(err);
1642
1687
  }
@@ -1745,5 +1790,6 @@ export function buildServer() {
1745
1790
 
1746
1791
  /** The stdio server: `passalong mcp`, and what `passalong setup` registers with a client. */
1747
1792
  export async function serve() {
1793
+ passalong.tidy().catch(() => {});
1748
1794
  await buildServer().connect(new StdioServerTransport());
1749
1795
  }
package/src/passalong.js CHANGED
@@ -78,26 +78,19 @@ export async function share(markdown, { cwd = process.cwd(), to, follows } = {})
78
78
  ];
79
79
  if (errors.length)
80
80
  throw new PassalongError(`guide is not ready to share:\n - ${errors.join("\n - ")}`);
81
- // A task stays a draft when it is shared. Draft is the column a task waits in until a person
82
- // says it is ready for an agent — `ready()` — and an agent that drafts one over MCP comes
83
- // through here too, so publishing on share would let a task queue itself with nobody reading it.
84
- if (guide.meta.status === "draft" && guide.meta.kind !== "task")
85
- guide = { ...guide, meta: { ...guide.meta, status: "published" } };
86
-
87
- let url = guide.meta.url || null;
88
- let synced = false;
89
- let notified = false;
90
- if (api.loggedIn()) {
91
- const res = await api.publish(guide.meta.id, serialize(guide));
92
- url = res.url;
93
- synced = true;
94
- notified = Boolean(res.notified);
95
- guide = { ...guide, meta: { ...guide.meta, url } };
96
- } else if (guide.meta.team) {
97
- throw new PassalongError("sharing to a team needs sync — run `passalong login` first");
98
- }
99
- const path = store.save(guide);
100
- return { guide, path, url, synced, notified };
81
+ // Every guide is a draft when it is first shared, whatever its kind: Draft is where it waits
82
+ // until a person says it is ready — `ready()` — and an agent that writes one over MCP comes
83
+ // through here too, so publishing on share would let it reach people with nobody reading it.
84
+ // Sharing *is* putting it in Draft, so a new guide that names no status says so here; one already
85
+ // published keeps what its document says.
86
+ if (!guide.meta.status) guide = { ...guide, meta: { ...guide.meta, status: "draft" } };
87
+
88
+ // The server is where a guide lives; nothing is kept on this machine.
89
+ needsSync("sharing a guide");
90
+ const res = await api.publish(guide.meta.id, serialize(guide));
91
+ const url = res.url;
92
+ guide = { ...guide, meta: { ...guide.meta, url } };
93
+ return { guide, url, synced: true, notified: Boolean(res.notified) };
101
94
  }
102
95
 
103
96
  /**
@@ -151,8 +144,11 @@ export async function fileBugs(
151
144
  return { report: { id: report.id, title, environment, team }, issues: filed };
152
145
  }
153
146
 
154
- /** Resolve a reference (id, share URL, or local file path) to a guide. */
155
- export async function resolve(ref) {
147
+ /**
148
+ * Resolve a reference (id, share URL, or local file path) to a guide. An id is the server's to
149
+ * answer: nothing keeps a copy on this machine. `peek` reads without recording a pull.
150
+ */
151
+ export async function resolve(ref, { peek = false } = {}) {
156
152
  if (/^https?:\/\//.test(ref)) {
157
153
  const md = await api.fetchShared(ref);
158
154
  return { guide: parse(md), from: "link" };
@@ -162,21 +158,13 @@ export async function resolve(ref) {
162
158
  }
163
159
  if (!ID_RE.test(ref))
164
160
  throw new PassalongError(`"${ref}" is not a passalong id, share link, or .md file`);
165
- const local = store.get(ref);
166
- if (local) return { guide: local, from: "local" };
167
- if (api.loggedIn()) {
168
- try {
169
- const md = await api.get(ref);
170
- const guide = parse(md);
171
- store.save(guide); // cache so the next pull is offline
172
- return { guide, from: "sync" };
173
- } catch (err) {
174
- if (err.status !== 404) throw err;
175
- }
161
+ needsSync("reading a guide by id");
162
+ try {
163
+ return { guide: parse(await api.get(ref, { peek })), from: "sync" };
164
+ } catch (err) {
165
+ if (err.status === 404) throw new PassalongError(`no guide "${ref}"`);
166
+ throw err;
176
167
  }
177
- throw new PassalongError(
178
- `no guide "${ref}" (not local${api.loggedIn() ? " or synced" : "; not logged in, so sync was not checked"})`,
179
- );
180
168
  }
181
169
 
182
170
  /**
@@ -201,22 +189,21 @@ export async function resolveId(ref) {
201
189
  }
202
190
 
203
191
  /**
204
- * Pull a guide into a working directory: writes .passalong/<id>.md there and returns the guide
205
- * so the caller can put the text straight into an agent's context. A pull of someone else's
206
- * guide is also recorded server-side, which is how the sender sees the transfer landed.
192
+ * Pull a guide and return it, so the caller can put the text straight into an agent's context. A
193
+ * pull of someone else's guide is recorded server-side, which is how the sender sees the transfer
194
+ * landed. Nothing is written unless `save` asks: then it lands in `.passalong/<id>.md` here.
207
195
  */
208
- export async function pull(ref, { cwd = process.cwd(), write = true } = {}) {
196
+ export async function pull(ref, { cwd = process.cwd(), save = false } = {}) {
209
197
  const { guide, from } = await resolve(ref);
210
- // A locally cached copy of a teammate's guide still counts as a pull for them.
211
- if (from === "local" && api.loggedIn() && guide.meta.team) {
212
- api.get(guide.meta.id).catch(() => {});
213
- }
214
- let path = null;
215
- if (write) {
216
- path = join(localDir(cwd), `${guide.meta.id}.md`);
217
- writeFileSync(path, serialize(guide));
218
- }
219
- return { guide, from, path, markdown: serialize(guide) };
198
+ const markdown = serialize(guide);
199
+ return { guide, from, path: save ? saveTo(cwd, guide.meta.id, markdown) : null, markdown };
200
+ }
201
+
202
+ /** Write a guide into `.passalong/<id>.md` under `cwd`, when somebody asked for a file. */
203
+ function saveTo(cwd, id, markdown) {
204
+ const path = join(localDir(cwd), `${id}.md`);
205
+ writeFileSync(path, markdown);
206
+ return path;
220
207
  }
221
208
 
222
209
  /**
@@ -284,17 +271,13 @@ export async function tasks() {
284
271
 
285
272
  /**
286
273
  * Take the next task for this worktree's repo, or get back the one this worktree already holds.
287
- * `null` when there is nothing to do. The guide is written to `.passalong/<id>.md` like a pull.
274
+ * `null` when there is nothing to do. Written to `.passalong/<id>.md` only when `save` asks.
288
275
  */
289
- export async function nextTask({ cwd = process.cwd(), any = false } = {}) {
276
+ export async function nextTask({ cwd = process.cwd(), any = false, save = false } = {}) {
290
277
  needsSync("taking a task");
291
278
  const { task } = await api.nextTask({ ...agent(cwd), any });
292
279
  if (!task) return null;
293
- const guide = parse(task.markdown);
294
- const path = join(localDir(agent(cwd).worktree), `${task.id}.md`);
295
- writeFileSync(path, task.markdown);
296
- store.save(guide);
297
- return { ...task, path };
280
+ return { ...task, path: save ? saveTo(agent(cwd).worktree, task.id, task.markdown) : null };
298
281
  }
299
282
 
300
283
  /** Word from the agent holding a task: renews its lease, and `note` becomes the board's line. */
@@ -397,18 +380,15 @@ export async function finishTask(
397
380
  * `next`, the calls that make sense from here, and `say` when the move is to stop.
398
381
  *
399
382
  * take: this worktree's agent takes guide `id`, or with no id the next one waiting for it. The
400
- * document is written to `.passalong/<id>.md`, like a pull, so the agent works from a file.
383
+ * document comes back in the answer, and is written to `.passalong/<id>.md` only when `save` asks.
401
384
  */
402
- export async function take(id, { cwd = process.cwd(), any = false } = {}) {
385
+ export async function take(id, { cwd = process.cwd(), any = false, save = false } = {}) {
403
386
  needsSync("taking work");
404
387
  const who = agent(cwd);
405
388
  const r = await api.take({ ...who, ...(id ? { id: await resolveId(id) } : {}), any });
406
389
  if (!r.guide?.markdown) return r;
407
390
  rememberFence(r.guide.id, r.guide.fence, cwd);
408
- const path = join(localDir(who.worktree), `${r.guide.id}.md`);
409
- writeFileSync(path, r.guide.markdown);
410
- store.save(parse(r.guide.markdown));
411
- return { ...r, path };
391
+ return { ...r, path: save ? saveTo(who.worktree, r.guide.id, r.guide.markdown) : null };
412
392
  }
413
393
 
414
394
  /** Word from the agent that holds it: the lease starts again, and `note` is what the hub shows. */
@@ -613,16 +593,19 @@ export async function now({ cwd = process.cwd() } = {}) {
613
593
  * line the next reader needs.
614
594
  */
615
595
  export async function approveTask(id) {
616
- needsSync("approving a task");
617
- const r = await api.approveTask(id);
596
+ needsSync("approving work");
597
+ const { guide } = await resolve(id, { peek: true });
598
+ const r = await (guide.meta.kind === "task" ? api.approveTask(id) : api.closeGuide(id));
618
599
  store.remove(id);
619
600
  return r;
620
601
  }
621
602
 
622
603
  export async function rejectTask(id, why) {
623
- needsSync("rejecting a task");
624
- if (!String(why || "").trim()) throw new PassalongError("say why: the next agent reads it");
625
- const r = await api.rejectTask(id, why);
604
+ needsSync("sending work back");
605
+ if (!String(why || "").trim())
606
+ throw new PassalongError("say why: whoever takes it next reads it");
607
+ const { guide } = await resolve(id, { peek: true });
608
+ const r = await (guide.meta.kind === "task" ? api.rejectTask(id, why) : api.sendBack(id, why));
626
609
  store.remove(id);
627
610
  return r;
628
611
  }
@@ -662,17 +645,10 @@ export async function release(id) {
662
645
  * - anything else is reported in `ack_error` rather than swallowed, because an ack that quietly
663
646
  * did not land leaves the sender in the silence the whole feature exists to end.
664
647
  */
665
- export async function start(ref, { cwd = process.cwd(), write = true } = {}) {
666
- const pulled = await pull(ref, { cwd, write });
667
- const id = pulled.guide.meta.id;
668
- if (!api.loggedIn()) return { ...pulled, took: false, ack_error: "", own: false };
669
- try {
670
- await api.ack(id, true, "");
671
- return { ...pulled, took: true, ack_error: "", own: false };
672
- } catch (err) {
673
- if (err.status === 403) return { ...pulled, took: false, ack_error: "", own: true };
674
- return { ...pulled, took: false, ack_error: err.message, own: false };
675
- }
648
+ export async function start(ref, { cwd = process.cwd(), save = false } = {}) {
649
+ const r = await take(await resolveId(ref), { cwd, save });
650
+ if (!r.guide?.markdown) return r;
651
+ return { ...r, markdown: r.guide.markdown };
676
652
  }
677
653
 
678
654
  /**
@@ -949,24 +925,11 @@ export async function parentGuide(
949
925
  }
950
926
  }
951
927
 
952
- /** Local guides merged with synced ones (by id), newest first, optionally filtered. */
953
- export async function list(query = "", { remote = true, scope = "" } = {}) {
954
- const local = scope && scope !== "all" && scope !== "mine" ? [] : store.search(query);
955
- const seen = new Set(local.map((g) => g.meta.id));
956
- const rows = local.map((g) => ({ ...summary(g), where: "local" }));
957
- if (remote && api.loggedIn()) {
958
- try {
959
- const res = await api.list(query, scope);
960
- for (const r of res.guides) {
961
- const row = seen.has(r.id) ? rows.find((x) => x.id === r.id) : null;
962
- if (row) Object.assign(row, r, { where: "synced" });
963
- else rows.push({ ...r, where: "synced" });
964
- }
965
- } catch (err) {
966
- rows.warning = `sync unavailable: ${err.message}`;
967
- }
968
- }
969
- return rows.sort((a, b) => (b.created || "").localeCompare(a.created || ""));
928
+ /** Guides on the server this account can see, newest first, optionally filtered. */
929
+ export async function list(query = "", { scope = "" } = {}) {
930
+ needsSync("listing guides");
931
+ const res = await api.list(query, scope);
932
+ return res.guides.map((r) => ({ ...r, where: "synced" }));
970
933
  }
971
934
 
972
935
  /** Guides handed to you (or your teams) that you have not pulled yet. Needs sync. */
@@ -1007,64 +970,6 @@ export async function activity({ all = false, limit = 50 } = {}) {
1007
970
  * `done` says "I implemented it", a verdict says "I tried it and it holds up" — or does not.
1008
971
  * A failing verdict needs a note; "it doesn't work" without a reason helps nobody.
1009
972
  */
1010
- /**
1011
- * Say whether a guide held, and why at the length that takes.
1012
- *
1013
- * The reason used to be one field, capped at 280 characters by the row it was written for — so an
1014
- * agent that had run the Verification and found it did not hold published a guide called
1015
- * "Correction: …" instead, because measurements, commands and commit ids do not fit in a tweet.
1016
- * The first line is the summary a row shows; the whole thing is what the guide shows.
1017
- */
1018
- export async function verdict(id, ok, note = "", { images = [] } = {}) {
1019
- if (!api.loggedIn()) throw new PassalongError("verdicts need sync — run `passalong login` first");
1020
- const said = note.trim();
1021
- if (!ok && !said)
1022
- throw new PassalongError("say what went wrong: passalong broken <id> <what happened>");
1023
- // "It works" is shown, not said: the server refuses one with no screenshot of it working, so
1024
- // the refusal is said here before anything is uploaded, in the words of the command that fixes it.
1025
- if (ok && !images.length && !/\/v1\/shots\/[a-z0-9]{6,16}/.test(said))
1026
- throw new PassalongError(
1027
- "show it working: passalong works <id> <screenshot.png> [what you checked]",
1028
- );
1029
- const guide = await resolveId(id);
1030
- const shots = [];
1031
- for (const file of images) shots.push((await attach(file)).markdown);
1032
- const line = said.split("\n")[0].slice(0, 280);
1033
- const detail = [said.length > line.length || shots.length ? said : "", ...shots]
1034
- .filter(Boolean)
1035
- .join("\n");
1036
- return api.verdict(guide, ok, line, detail);
1037
- }
1038
-
1039
- /**
1040
- * The words after `passalong works <id>`, split into the screenshots and the note: an argument is a
1041
- * screenshot when it names an image file that exists, and everything else is what was checked.
1042
- */
1043
- export function proofArgs(args) {
1044
- const images = [];
1045
- const words = [];
1046
- for (const a of args) {
1047
- const ext = a.slice(a.lastIndexOf(".")).toLowerCase();
1048
- if (SHOT_TYPES[ext] && existsSync(a)) images.push(a);
1049
- else words.push(a);
1050
- }
1051
- return { images, note: words.join(" ") };
1052
- }
1053
-
1054
- /**
1055
- * Say whether you are taking a guide someone handed you — before doing any of it.
1056
- *
1057
- * The hop the product had no signal for: until this, a handoff nobody had answered looked exactly
1058
- * like one nobody had noticed. Passing needs a reason for the same cause a failing verdict does —
1059
- * "not me" without "why" leaves the sender where the silence did.
1060
- */
1061
- export async function ack(id, taken, note = "") {
1062
- if (!api.loggedIn()) throw new PassalongError("acks need sync — run `passalong login` first");
1063
- if (!taken && !note.trim())
1064
- throw new PassalongError("say why you are passing: pass a note with taken=false");
1065
- return api.ack(await resolveId(id), taken, note.trim());
1066
- }
1067
-
1068
973
  /** Mark notifications seen. No ids means everything unread. */
1069
974
  export const seen = (ids = []) => api.markRead(ids);
1070
975
 
@@ -1088,25 +993,20 @@ export function summary(g) {
1088
993
  export async function setStatus(id, status) {
1089
994
  if (!STATUSES.includes(status))
1090
995
  throw new PassalongError(`status must be one of ${STATUSES.join(", ")}`);
1091
- const { guide } = await resolve(id);
996
+ const { guide } = await resolve(id, { peek: true });
1092
997
  const next = { ...guide, meta: { ...guide.meta, status } };
1093
998
  // The server first. Saved the other way round, a refusal left the local copy saying what the
1094
999
  // server had just refused — a teammate's `ready` on someone else's task read as done here.
1095
- if (api.loggedIn() && guide.meta.url) await api.setStatus(id, status);
1096
- store.save(next);
1000
+ await api.setStatus(id, status);
1097
1001
  return next;
1098
1002
  }
1099
1003
 
1100
1004
  /**
1101
- * Move a task from Draft to Ready: a person has read it and an agent may take it.
1102
- *
1103
- * Only tasks. Every other kind is published the moment it is shared, so "ready" on one would
1104
- * either do nothing or reopen something archived under a name that does not say so.
1005
+ * Move a guide from Draft to Ready: a person has read it, and whoever it is for may take it now.
1006
+ * Every kind: a handoff or bug is a draft until somebody says so, as a task always was.
1105
1007
  */
1106
1008
  export async function ready(id) {
1107
- const { guide } = await resolve(id);
1108
- if (guide.meta.kind !== "task")
1109
- throw new PassalongError(`${guide.meta.id || id} is not a task — ready is for tasks in Draft`);
1009
+ const { guide } = await resolve(id, { peek: true });
1110
1010
  if (guide.meta.status !== "draft")
1111
1011
  throw new PassalongError(`${guide.meta.id} is not in Draft (status: ${guide.meta.status})`);
1112
1012
  return setStatus(guide.meta.id, "published");
@@ -1162,8 +1062,7 @@ export async function planTasks(steps, { cwd = process.cwd() } = {}) {
1162
1062
  /** What an agent started by `work()` is told. The task itself goes in too, so it cannot start blind. */
1163
1063
  export function workPrompt(t) {
1164
1064
  return [
1165
- `You hold passalong task ${t.id}, and no other agent can take it while you do.`,
1166
- `It is written to ${t.path}; here it is:`,
1065
+ `You hold passalong task ${t.id}, and no other agent can take it while you do. Here it is:`,
1167
1066
  "",
1168
1067
  t.markdown.trim(),
1169
1068
  "",
@@ -1216,7 +1115,7 @@ export async function work({
1216
1115
  const run = spawnSync(command, [...args, workPrompt(t)], {
1217
1116
  cwd,
1218
1117
  stdio: "inherit",
1219
- env: { ...process.env, PASSALONG_TASK: t.id, PASSALONG_TASK_PATH: t.path },
1118
+ env: { ...process.env, PASSALONG_TASK: t.id },
1220
1119
  });
1221
1120
  const state = (await tasks()).find((x) => x.id === t.id)?.state;
1222
1121
  if (state !== "review")
@@ -1233,14 +1132,14 @@ export async function work({
1233
1132
  }
1234
1133
 
1235
1134
  /**
1236
- * Make every task you wrote that is still in Draft ready, and return their ids. For the moment a
1237
- * planner has written several and you have read them all. Only your own: moving a task into the
1238
- * queue is its author's call.
1135
+ * Make every guide you wrote that is still in Draft ready, and return their ids. For the moment a
1136
+ * planner has written several and you have read them all. Only your own: sending a guide on is its
1137
+ * author's call.
1239
1138
  */
1240
1139
  export async function readyDrafts() {
1241
- const drafts = (await tasks()).filter((t) => t.mine && t.state === "draft");
1242
- for (const t of drafts) await ready(t.id);
1243
- return drafts.map((t) => t.id);
1140
+ const drafts = (await list("", { scope: "mine" })).filter((g) => g.status === "draft");
1141
+ for (const g of drafts) await ready(g.id);
1142
+ return drafts.map((g) => g.id);
1244
1143
  }
1245
1144
 
1246
1145
  export async function remove(id) {
@@ -1255,14 +1154,49 @@ export async function remove(id) {
1255
1154
  store.remove(id);
1256
1155
  }
1257
1156
 
1258
- /** Write every local guide to a directory as plain markdown. Returns the paths written. */
1259
- export function exportAll(dir) {
1157
+ /**
1158
+ * Write every guide to a directory as plain markdown: signed in, every one the server lets this
1159
+ * account read, and the local store's own besides. Returns the paths written.
1160
+ */
1161
+ export async function exportAll(dir) {
1260
1162
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
1261
- const paths = [];
1262
- for (const g of store.list()) {
1263
- const p = join(dir, `${g.meta.id}.md`);
1264
- writeFileSync(p, serialize(g));
1265
- paths.push(p);
1163
+ const paths = new Map();
1164
+ if (api.loggedIn()) {
1165
+ let after = "";
1166
+ do {
1167
+ const page = await api.exportPage(after);
1168
+ for (const g of page.guides) paths.set(g.id, g.markdown);
1169
+ after = page.next;
1170
+ } while (after);
1171
+ }
1172
+ for (const g of store.list()) if (!paths.has(g.meta.id)) paths.set(g.meta.id, serialize(g));
1173
+ return [...paths].map(([id, md]) => {
1174
+ const p = join(dir, `${id}.md`);
1175
+ writeFileSync(p, md);
1176
+ return p;
1177
+ });
1178
+ }
1179
+
1180
+ /**
1181
+ * Once per machine, signed in: drop the local copies the server says it holds. They were written
1182
+ * by every share, pull and take before `keep()`, and nothing ever removed one. What the server
1183
+ * does not return — shared while signed out, or from another account — is left where it is.
1184
+ */
1185
+ export async function tidy() {
1186
+ if (!api.loggedIn() || store.readConfig().tidied) return 0;
1187
+ const local = new Set(store.list().map((g) => g.meta.id));
1188
+ let removed = 0;
1189
+ let after = "";
1190
+ while (local.size) {
1191
+ const page = await api.exportPage(after);
1192
+ for (const g of page.guides)
1193
+ if (local.delete(g.id)) {
1194
+ store.remove(g.id);
1195
+ removed++;
1196
+ }
1197
+ after = page.next;
1198
+ if (!after) break;
1266
1199
  }
1267
- return paths;
1200
+ store.writeConfig({ tidied: true });
1201
+ return removed;
1268
1202
  }