passalong 0.20.0 → 1.0.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/bin/passalong CHANGED
@@ -1,32 +1,30 @@
1
1
  #!/usr/bin/env node
2
2
  // Passalong CLI. Few flags on purpose: the defaults are the product.
3
3
  //
4
- // passalong share [file] [--to TARGET] [--follows ID] publish a guide → id + link; --follows adds it as context to guide ID
5
- // --follows ID publishes it as a follow-up: more context for guide ID, listed under it and
6
- // handed to whoever pulls or starts that guide
4
+ // passalong new "<what needs doing>" [--to TARGET] [--parent ID] [--draft] write work down
5
+ // passalong new <file.md> [--to TARGET] [--parent ID] [--draft] the same, from a file
7
6
  // TARGET is a team, team/@handle for one person, or "team/#group" for the people who do a
8
7
  // thing — quote that one, since a shell may read the # as a comment
9
- // passalong task "<what needs doing>" write a task for an agent; Ready at once
10
- // passalong ready <id...|--all> guides you have read are ready — whoever they are for may take them now
11
- // passalong tasks every task and where it is: draft, ready, claimed, stalled, review
12
- // passalong work [--once] [--agent CMD] run an agent on each ready task here, one after another
8
+ // passalong board what needs you, who is on what, and what is ready
9
+ // passalong list [query] work you and your teams can see, newest first
10
+ // passalong open <id> [--print] view work in the browser (or the terminal)
11
+ // passalong take [id] [--save] you are doing it: nobody else can take it while you hold it
12
+ // passalong progress <id> <note> still on it, with a one-line note the hub shows
13
+ // passalong hand-in <id> <note> --evidence TEXT [--writeup TEXT] [--pr LINK] it is done
14
+ // passalong pass <id> <why> it is not yours, or you are stuck — hands it back with your reason
15
+ // passalong ask <id> <question> ask the person, keep it, and wait for their answer
16
+ // passalong reply <id> <text> write to whoever holds it (--file PATH to attach); or leave a note
17
+ // passalong assign <id> <to> give it to @handle (or several: @ada @bola), a #group, or "team"
18
+ // passalong release <id> take work back from whoever holds it
13
19
  // passalong reopen <id> <why> finished work isn't right: back to To do, and whoever takes it next reads why
14
- // passalong release <id> take work back from whoever holds it, of any kind
20
+ // passalong ready <id...|--all> work you have read is Ready — whoever it is for may take it now
21
+ // passalong archive <id> off the board and out of the free tier's count
22
+ // passalong rm <id> delete it
15
23
  // passalong attach <file> upload a picture or a file and print the markdown that points at it
16
- // passalong reply <id> <text> write to whoever holds it (--file PATH to attach); or leave a note
17
- // passalong pull <id|link> [--save] print a guide; --save also writes ./.passalong/<id>.md
18
- // passalong start <id|link> [--save] the same as take, by link as well as id
19
- // passalong inbox guides handed to you that you have not pulled yet
20
- // passalong board waiting on you, in flight, landed, worth keeping
21
- // passalong activity [--all] what happened while you were away; clears unless --all
22
- // passalong log [repo] what you did, newest first, by month
23
- // passalong list [query] your guides and your teams'
24
- // passalong open <id> [--print] view a guide in the browser (or the terminal)
25
- // passalong hub open your synced guides in the browser
26
- // passalong take <id> [--save] you are doing it: nobody else can take it while you hold it
27
- // passalong pass <id> <why> it is not yours, or you are stuck — hands it back with your reason
28
- // passalong assign <id> <to> give it to @handle (or several: @ada @bola), a #group, or "team" — yours, or assigned to you
29
- // passalong recall <id> take back something you sent: private again, gone from their inboxes
24
+ // passalong work [--once] [--agent CMD] run an agent on each piece of ready work here, one after another
25
+ // passalong work --watch [--every SECS] the runner: keep taking work marked safe to run alone
26
+ // passalong hub open the hub in the browser
27
+ // passalong export [dir] dump every piece of work as plain markdown (default ./passalong-export)
30
28
  // passalong admin create <email> make an account that runs Passalong (owner only)
31
29
  // passalong admin list who runs it
32
30
  // passalong admin grant|revoke <@handle> make an existing account an admin, or stop it being one
@@ -34,9 +32,6 @@
34
32
  // give Solo to @handle, or Team to team/slug, until a date (operator only)
35
33
  // passalong gifts what has been given away, and what is still running
36
34
  // passalong ungift <gift id> take one back now
37
- // passalong archive <id> off your board and out of the free tier's count (was `done`)
38
- // passalong rm <id> delete a guide locally and from sync
39
- // passalong export [dir] dump every guide as plain markdown (default ./passalong-export)
40
35
  // passalong login sign in from the browser
41
36
  // passalong login --with-token read a token from stdin instead (CI, scripts)
42
37
  // passalong login --new add another account to this machine
@@ -62,8 +57,8 @@ import { homedir, hostname } from "node:os";
62
57
  import { dirname, join, resolve } from "node:path";
63
58
  import { fileURLToPath } from "node:url";
64
59
  import * as api from "../src/api.js";
65
- import { scaffold, taskScaffold } from "../src/capture.js";
66
- import { parse, serialize } from "../src/guide.js";
60
+ import { workScaffold } from "../src/capture.js";
61
+ import { parse, serialize } from "../src/work.js";
67
62
  import * as passalong from "../src/passalong.js";
68
63
  import * as store from "../src/store.js";
69
64
 
@@ -202,41 +197,12 @@ function relDate(iso) {
202
197
  return iso.slice(0, 10);
203
198
  }
204
199
 
205
- function guideLine(r) {
206
- const who = r.team ? ` ${r.team}${r.to ? `/@${r.to}` : ""}${r.mine ? "" : ` from @${r.from || "?"}`}` : "";
200
+ function workLine(r) {
201
+ const who = r.team ? ` ${r.team}${r.to ? `/${r.to}` : ""}${r.mine ? "" : ` from @${r.from || "?"}`}` : "";
207
202
  const tags = r.tags?.length ? ` #${r.tags.join(" #")}` : "";
208
- let pulled = "";
209
- if (r.mine && r.pulled_by?.length) {
210
- const p = r.pulled_by[0];
211
- pulled = ` ← ${p.handle ? `@${p.handle}` : "link"} ${relDate(p.at)}${r.pulled_by.length > 1 ? ` +${r.pulled_by.length - 1}` : ""}`;
212
- }
213
- return `${r.id} ${r.status.padEnd(9)} ${relDate(r.created).padEnd(10)} ${r.title}${who}${tags}${pulled}`;
214
- }
215
-
216
- function activityLine(n) {
217
- const dot = n.read ? " " : "•";
218
- return `${dot} ${(n.guide || "").padEnd(8)} ${relDate(n.at).padEnd(10)} ${n.text}`;
203
+ return `${r.id} ${r.status.padEnd(9)} ${relDate(r.created).padEnd(10)} ${r.title}${who}${tags}`;
219
204
  }
220
205
 
221
- /**
222
- * After a pulled guide, the follow-ups written for it: more context, each its own guide. Listed on
223
- * stderr rather than printed in full, so stdout stays exactly the document; nothing at all when
224
- * there are none or sync is off.
225
- */
226
- async function listFollowUps(meta) {
227
- const guides = await passalong.followUpGuides(meta);
228
- if (!guides.length) return;
229
- console.error(
230
- `\n${guides.length} follow-up${guides.length === 1 ? "" : "s"} — more context for this guide:`,
231
- );
232
- for (const g of guides) console.error(` ${g.id} ${g.title || "untitled"}`);
233
- console.error("Read one with: passalong pull <id>");
234
- }
235
-
236
- const MONTHS = "January February March April May June July August September October November December".split(" ");
237
- /** "September 2026" from an ISO timestamp, without pulling in a date library for one heading. */
238
- const monthOf = (iso) => `${MONTHS[Number(iso.slice(5, 7)) - 1]} ${iso.slice(0, 4)}`;
239
-
240
206
  // ---- the status line cache --------------------------------------------------------------------------
241
207
 
242
208
  /** One small file per working directory, so two sessions in two repos each see their own. */
@@ -285,111 +251,149 @@ async function statusLine() {
285
251
  }
286
252
 
287
253
  const commands = {
288
- async share(f) {
289
- let path = f._[0];
290
- if (!path) {
291
- const [latest] = store.drafts();
292
- if (latest) {
293
- path = latest;
294
- console.error(`using draft ${path}`);
295
- } else {
296
- path = store.draftPath(new Date().toISOString().replace(/[:.]/g, "-"));
297
- writeFileSync(path, scaffold());
298
- if (!tty)
299
- die(`no draft to share; scaffolded ${path} — fill it in and run \`passalong share ${path}\``);
300
- }
254
+ /**
255
+ * Write work down: a sentence becomes its title, summary and text, or a file is sent as it is. At
256
+ * a terminal it opens in $EDITOR first, so whoever wrote it can add to it before it goes.
257
+ */
258
+ async new(f) {
259
+ const arg =
260
+ f._.join(" ").trim() ||
261
+ die('usage: passalong new "<what needs doing>" | <file.md> [--to TARGET] [--parent ID] [--draft]');
262
+ let path = arg;
263
+ if (!(arg.endsWith(".md") && existsSync(arg))) {
264
+ path = store.draftPath(`work-${new Date().toISOString().replace(/[:.]/g, "-")}`);
265
+ writeFileSync(path, workScaffold(arg));
301
266
  }
302
- if (!existsSync(path)) die(`no such file: ${path}`);
303
267
  if (tty && !f["no-edit"]) edit(path);
304
- const to = typeof f.to === "string" ? f.to : undefined;
305
- const follows = typeof f.follows === "string" ? f.follows : undefined;
306
- const { guide, url, notified } = await passalong
307
- .share(readFileSync(path, "utf8"), { to, follows })
308
- .catch((e) => die(e.message));
309
- // Any draft that went out is spent, however it was named — a task that failed validation is
310
- // re-shared by its path, and leaving it would have the next bare `share` publish it again.
311
- // removeDraft() touches nothing outside the drafts directory.
268
+ const g = parse(readFileSync(path, "utf8"));
269
+ if (f.draft) g.meta.status = "draft";
270
+ const { work, url } = await passalong
271
+ .publish(serialize(g), {
272
+ to: typeof f.to === "string" ? f.to : undefined,
273
+ parent: typeof f.parent === "string" ? f.parent : undefined,
274
+ })
275
+ .catch((e) => die(`${e.message}\nkept at ${path} — fix it and run \`passalong new ${path}\``));
276
+ // A draft that went out is spent. removeDraft() touches nothing outside the drafts directory.
312
277
  store.removeDraft(resolve(path));
313
- console.log(guide.meta.id);
314
- console.error(`shared "${guide.meta.title}"`);
315
- if (guide.meta.parent) console.error(`follows ${guide.meta.parent}`);
316
- if (guide.meta.team)
317
- console.error(
318
- `handed to ${guide.meta.team}${guide.meta.to ? `/@${guide.meta.to}${notified ? " (emailed)" : " (in their inbox)"}` : " (team inbox)"}`,
319
- );
278
+ console.log(work.meta.id);
279
+ const where = work.meta.team ? ` for ${work.meta.team}${work.meta.to ? `/${work.meta.to}` : ""}` : "";
280
+ console.error(`"${work.meta.title}" is ${work.meta.status === "draft" ? "in Draft" : "Ready"}${where}`);
320
281
  console.error(`link: ${url}`);
321
282
  },
322
283
 
323
- /**
324
- * Write a task. It lands in Draft and stays there until `passalong ready`: the gap between the
325
- * two is the one moment a person reads what an agent is about to be asked to do.
326
- */
327
- async task(f) {
328
- const sentence = f._.join(" ").trim() || die('usage: passalong task "<what needs doing>"');
329
- const path = store.draftPath(`task-${new Date().toISOString().replace(/[:.]/g, "-")}`);
330
- writeFileSync(path, taskScaffold(sentence));
331
- if (tty && !f["no-edit"]) edit(path);
332
- const { guide } = await passalong
333
- .share(readFileSync(path, "utf8"))
334
- .catch((e) => die(`${e.message}\ndraft kept at ${path} — fix it and run \`passalong share ${path}\``));
335
- store.removeDraft(path);
336
- const id = guide.meta.id;
337
- console.log(id);
338
- const target = guide.meta.target_context;
339
- console.error(`task "${guide.meta.title}" is Ready${target ? ` for ${target}` : " (no repo)"}`);
340
- if (guide.meta.team) console.error(`handed to ${guide.meta.team} (team inbox)`);
284
+ async board() {
285
+ const b = await passalong.board().catch((e) => die(e.message));
286
+ const section = (title, rows, line) => {
287
+ if (!rows.length) return;
288
+ console.log(`\n${title} · ${rows.length}`);
289
+ for (const r of rows) console.log(` ${r.id} ${line(r)}`);
290
+ };
291
+ section("Needs you", b.needs, (n) => `${n.title} — ${n.why}`);
292
+ section("Being worked on", b.working, (w) =>
293
+ `${w.title} ${w.who}${w.stalled ? " (went quiet)" : ""}${w.note ? ` "${w.note}"` : ""}`);
294
+ section("Ready", b.ready, (t) => `${t.title}${t.target ? ` [${t.target}]` : ""}${t.to ? ` for ${t.to}` : ""}`);
295
+ if (!b.needs.length && !b.working.length && !b.ready.length) console.error("nothing open");
296
+ console.error(`\n${b.counts.done} done. The board: ${b.hub}/work`);
341
297
  },
342
298
 
343
- async tasks() {
344
- const rows = await passalong.tasks().catch((e) => die(e.message));
345
- if (!rows.length) return console.error('no tasks yet — write one with: passalong task "<what needs doing>"');
346
- for (const t of rows) {
347
- const c = t.claim;
348
- const who = c ? ` ${c.host || "?"}:${c.worktree.split("/").pop() || "?"}` : "";
349
- const said = c?.note ? ` "${c.note}"` : "";
350
- console.log(`${t.id} ${t.state.padEnd(8)} ${t.title}${t.target ? ` [${t.target}]` : ""}${who}${said}`);
351
- // What came back, under the task it answers: the thing a reviewer reads before deciding.
352
- if (t.state === "review" && c?.report)
353
- console.log(` → ${c.report} ${c.report_title || "(untitled)"}${c.pr ? ` ${c.pr}` : ""}`);
299
+ async list(f) {
300
+ const scope = typeof f.team === "string" ? f.team : f.mine ? "mine" : "";
301
+ const rows = await passalong.list(f._.join(" "), { scope }).catch((e) => die(e.message));
302
+ if (!rows.length) return console.error('no work yet — write some with: passalong new "<what needs doing>"');
303
+ for (const r of rows) console.log(workLine(r));
304
+ },
305
+
306
+ async open(f) {
307
+ const id = f._[0] || die("usage: passalong open <id>");
308
+ const { work } = await passalong.resolve(id).catch((e) => die(e.message));
309
+ if (work.meta.url && tty && !f.print) {
310
+ openInBrowser(work.meta.url);
311
+ console.error(work.meta.url);
312
+ } else {
313
+ process.stdout.write(serialize(work));
314
+ if (work.meta.url) console.error(`\nlink: ${work.meta.url}`);
354
315
  }
355
- if (rows.some((t) => t.state === "review"))
356
- console.error("\nread a write-up with: passalong open <id> — then approve or reject the task");
357
316
  },
358
317
 
359
318
  /**
360
- * Work the queue from this worktree until it is empty. The agent is `claude -p` unless --agent or
361
- * PASSALONG_AGENT says otherwise, e.g. "claude -p --permission-mode acceptEdits" so it can edit
362
- * files without asking — which is yours to decide, not this command's.
319
+ * Take it: you are doing it, and nobody else can take it while you hold it. It is printed, and
320
+ * `--save` also writes it to ./.passalong/. With no id, the next piece of work waiting here.
363
321
  */
364
- async work(f) {
365
- const run = await passalong
366
- .work({
367
- agent: typeof f.agent === "string" ? f.agent : undefined,
368
- once: Boolean(f.once),
369
- any: Boolean(f.any),
370
- onTask: (t) => console.error(`\n→ ${t.id} ${t.title}${t.resumed ? " (resuming)" : ""}`),
322
+ async take(f) {
323
+ const r = await passalong.take(f._[0], { save: Boolean(f.save) }).catch((e) => die(e.message));
324
+ if (!r.work?.markdown) return console.error(r.say || "nothing to take");
325
+ process.stdout.write(r.work.markdown);
326
+ if (r.path) console.error(`\nsaved to ${r.path}`);
327
+ console.error(`\nyou hold ${r.work.id} — hand it in when it is done, or pass it with the reason`);
328
+ },
329
+
330
+ async progress(f) {
331
+ const id = f._[0] || die("usage: passalong progress <id> <note>");
332
+ await passalong.progress(id, f._.slice(1).join(" ")).catch((e) => die(e.message));
333
+ console.error(`${id} — still yours`);
334
+ },
335
+
336
+ async ["hand-in"](f) {
337
+ const id = f._[0] || die("usage: passalong hand-in <id> <note> --evidence TEXT");
338
+ const note = f._.slice(1).join(" ") || die("say in one sentence what you did: passalong hand-in <id> <note>");
339
+ const r = await passalong
340
+ .handIn(id, {
341
+ note,
342
+ evidence: typeof f.evidence === "string" ? f.evidence : "",
343
+ writeup: typeof f.writeup === "string" ? f.writeup : "",
344
+ pr: typeof f.pr === "string" ? f.pr : "",
371
345
  })
372
346
  .catch((e) => die(e.message));
373
- for (const id of run.finished) console.log(id);
374
- const n = run.finished.length;
375
- const done = `${n} task${n === 1 ? "" : "s"} finished and waiting for review`;
376
- if (run.stopped === "unfinished")
377
- die(
378
- `${done}. Stopped: the agent exited with ${run.task} still ${run.state || "open"} — ` +
379
- `run again to resume it, or \`passalong release ${run.task}\``,
380
- );
381
- console.error(`${done}${run.stopped === "empty" ? " — nothing left for this repo" : ""}`);
382
- if (n) console.error("review them with: passalong tasks");
347
+ console.error(`${id} is ${r.state === "done" ? "Done" : "handed in here; others still hold it"}`);
348
+ },
349
+
350
+ async pass(f) {
351
+ const id = f._[0] || die("usage: passalong pass <id> <why it is not yours>");
352
+ const why = f._.slice(1).join(" ");
353
+ // Required, and the reason is the whole point: "not me" without "why" leaves the sender where
354
+ // the silence did, still guessing whether to re-address it, wait, or go and ask.
355
+ if (!why) die("say why you are passing: passalong pass <id> <why it is not yours>");
356
+ await passalong.pass(id, why).catch((e) => die(e.message));
357
+ console.error(`${id} handed back with your reason: "${why}"`);
383
358
  },
384
359
 
385
- // Nothing waits for approval now (docs/REVIEW.md): finishing is final. Kept so a script that
386
- // calls it is told, not refused.
387
- async approve() {
360
+ async ask(f) {
361
+ const id = f._[0] || die("usage: passalong ask <id> <question>");
362
+ await passalong.ask(id, f._.slice(1).join(" ")).catch((e) => die(e.message));
363
+ console.error(`${id} — asked. It is still yours; take it again once they have replied`);
364
+ },
365
+
366
+ /**
367
+ * Write to whoever holds a piece of work, or leave a note on one nobody holds yet. The person's
368
+ * side of `ask`: an agent that stopped to wait hears this when it takes the work again.
369
+ */
370
+ async reply(f) {
371
+ const id = f._[0] || die("usage: passalong reply <id> <message> [--file PATH ...]");
372
+ const files = [f.file].flat().filter((x) => typeof x === "string");
373
+ const said = f._.slice(1).join(" ");
374
+ if (!said && !files.length) die("say something, or attach a file: passalong reply <id> <message>");
375
+ const r = await passalong.reply(id, said, { files }).catch((e) => die(e.message));
388
376
  console.error(
389
- "nothing to approve: finished work is already Done. If it isn't right: passalong reopen <id> <why>",
377
+ r?.noted
378
+ ? `${id} — nobody holds it yet, so this is a note: whoever takes it is handed it first`
379
+ : `${id} — sent. Whoever holds it reads it the next time it checks in`,
390
380
  );
391
381
  },
392
382
 
383
+ async assign(f) {
384
+ const id = f._[0] || die('usage: passalong assign <id> <@handle | #group | team>');
385
+ const to = f._.slice(1).join(",") || die('say who: @handle (or several), #group, or "team" for everyone');
386
+ const r = await passalong.assign(id, to).catch((e) => die(e.message));
387
+ const back = r.taken_back ? ` — taken back from ${r.taken_back} who had it, and they were told` : "";
388
+ console.error(`${r.id} is now for ${r.to || "everyone in the team"}${back}`);
389
+ },
390
+
391
+ async release(f) {
392
+ const id = f._[0] || die("usage: passalong release <id>");
393
+ await passalong.release(id).catch((e) => die(e.message));
394
+ console.error(`${id} is back in Ready — whoever had it has been told`);
395
+ },
396
+
393
397
  /** Finished work back to To do, with why at its top; whoever finished it is told. */
394
398
  async reopen(f) {
395
399
  const id = f._[0] || die("usage: passalong reopen <id> <why>");
@@ -399,189 +403,86 @@ const commands = {
399
403
  console.error(`${id} is back in To do — whoever takes it next reads why`);
400
404
  },
401
405
 
402
- async reject(f) {
403
- return commands.reopen(f);
404
- },
405
-
406
- async release(f) {
407
- const id = f._[0] || die("usage: passalong release <id>");
408
- const r = await passalong.release(id).catch((e) => die(e.message));
409
- const from = Number(r?.from) || 1;
410
- console.error(
411
- `${id} is back in Ready${from > 1 ? `, taken back from ${from} places` : ""}` +
412
- " — whoever had it has been told",
413
- );
414
- },
415
-
416
406
  async ready(f) {
417
407
  if (f.all) {
418
408
  const ids = await passalong.readyDrafts().catch((e) => die(e.message));
419
409
  if (!ids.length) return console.error("no drafts of yours to make ready");
420
410
  for (const id of ids) console.log(id);
421
- return console.error(`${ids.length} task${ids.length === 1 ? "" : "s"} Ready — agents may take them`);
411
+ return console.error(`${ids.length} Ready — whoever they are for may take them`);
422
412
  }
423
413
  if (!f._.length) die("usage: passalong ready <id...> | --all");
424
- for (const id of f._) {
425
- const g = await passalong.ready(id).catch((e) => die(e.message));
426
- console.error(`${g.meta.id} is Ready — an agent may take it`);
414
+ for (const ref of f._) {
415
+ const id = await passalong.ready(ref).catch((e) => die(e.message));
416
+ console.error(`${id} is Ready — whoever it is for may take it`);
427
417
  }
428
418
  },
429
419
 
430
- async pull(f) {
431
- const ref = f._[0] || die("usage: passalong pull <id|link>");
432
- const { guide, markdown, path } = await passalong
433
- .pull(ref, { save: Boolean(f.save) })
434
- .catch((e) => die(e.message));
435
- process.stdout.write(markdown);
436
- if (path) console.error(`\nsaved to ${path}`);
437
- await listFollowUps(guide.meta);
420
+ /** Archive: off the board, and out of what the free tier counts. It comes back to Draft. */
421
+ async archive(f) {
422
+ const id = f._[0] || die("usage: passalong archive <id>");
423
+ await passalong.setStatus(id, "done").catch((e) => die(e.message));
424
+ console.error(`${id} archived — off the board, and it no longer counts against your plan`);
438
425
  },
439
426
 
440
- /**
441
- * Take it: you are doing it, and nobody else can take it while you hold it. The guide is printed,
442
- * and `--save` also writes it to ./.passalong/. Every kind is taken the same way, and `start` is
443
- * the same command by its older name, which also takes a share link.
444
- */
445
- async start(f) {
446
- const ref = f._[0] || die("usage: passalong take <id|link>");
447
- const r = await passalong.start(ref, { save: Boolean(f.save) }).catch((e) => die(e.message));
448
- if (!r.guide?.markdown) return console.error(r.say || "nothing to take");
449
- process.stdout.write(r.markdown);
450
- if (r.path) console.error(`\nsaved to ${r.path}`);
451
- console.error(`\nyou hold ${r.guide.id} — hand it in when it is done, or pass it with the reason`);
452
- await listFollowUps(parse(r.markdown).meta);
427
+ async rm(f) {
428
+ const id = f._[0] || die("usage: passalong rm <id>");
429
+ await passalong.remove(id).catch((e) => die(e.message));
430
+ console.error(`${id} removed`);
453
431
  },
454
432
 
455
-
456
- /**
457
- * Upload an image and print the markdown that points at it. Evidence goes *in* the document:
458
- * a guide travels as markdown to whoever holds its link, so a file beside it does not travel.
459
- */
433
+ /** Upload a picture or a file and print the markdown that points at it. */
460
434
  async attach(f) {
461
435
  const file = f._[0] || die("usage: passalong attach <file> [--name LABEL]");
462
- // A picture is evidence a page draws; anything else is a file somebody downloads.
463
- const isImage = Object.keys(passalong.SHOT_TYPES).some((x) => file.toLowerCase().endsWith(x));
464
- const shot = await (isImage ? passalong.attach : passalong.attachFile)(file, {
465
- name: f.name || "",
466
- }).catch((e) => die(e.message));
467
- process.stdout.write(`${shot.markdown}\n`);
468
- console.error(`uploaded ${shot.bytes} bytes → ${shot.url}`);
469
- },
470
-
471
- /**
472
- * Write to whoever holds a guide, or leave a note on one nobody holds yet. The person's side of
473
- * `ask`: an agent that stopped to wait hears this when it takes the guide again.
474
- */
475
- async reply(f) {
476
- const id = f._[0] || die("usage: passalong reply <id> <message> [--file PATH ...]");
477
- const files = [f.file].flat().filter((x) => typeof x === "string");
478
- const said = f._.slice(1).join(" ");
479
- if (!said && !files.length) die("say something, or attach a file: passalong reply <id> <message>");
480
- const r = await passalong.reply(id, said, { files }).catch((e) => die(e.message));
481
- console.error(
482
- r?.noted
483
- ? `${id} — nobody holds it yet, so this is a note: whoever takes it is handed it first`
484
- : `${id} — sent. Whoever holds it reads it the next time it checks in`,
485
- );
486
- },
487
-
488
- async inbox() {
489
- const rows = await passalong.inbox().catch((e) => die(e.message));
490
- if (!rows.length) return console.error("inbox empty — nothing handed to you that you haven't pulled");
491
- for (const r of rows) console.log(guideLine(r));
492
- console.error(`\n${rows.length} waiting. Take one with: passalong start <id>`);
493
- },
494
-
495
- async board() {
496
- const b = await passalong.board().catch((e) => die(e.message));
497
- const queue = (title, note, rows, line) => {
498
- if (!rows.length) return;
499
- console.log(`\n${title} · ${rows.length} ${note}`);
500
- for (const g of rows) console.log(` ${g.id} ${relDate(g.created).padEnd(10)} ${line(g)}`);
501
- };
502
- queue("Waiting on you", "handed to you, not pulled yet", b.waiting, (g) => `${g.title} from @${g.from || "?"}`);
503
- queue("Not working", "someone tried it and it does not hold up", b.failing, (g) =>
504
- `${g.title} ${g.verdict?.by ? `@${g.verdict.by}` : "someone"}: ${g.verdict?.note || "no reason given"}`);
505
- queue("In flight", "handed over, nobody has taken it", b.in_flight, (g) =>
506
- `${g.title} to ${g.team}${g.to ? `/@${g.to}` : ""}${g.stale ? " ← not picked up" : ""}`);
507
- queue("Landed", "someone else has it", b.landed, (g) => `${g.title} ${g.pulls} pull${g.pulls === 1 ? "" : "s"}`);
508
- const total = b.waiting.length + b.failing.length + b.in_flight.length + b.landed.length;
509
- if (!total) console.error("nothing in flight — every transfer is closed");
510
- else if (b.unread) console.error(`\n${b.unread} new: passalong activity`);
436
+ const got = await passalong.attachAny(file, { name: f.name || "" }).catch((e) => die(e.message));
437
+ process.stdout.write(`${got.markdown}\n`);
438
+ console.error(`uploaded ${got.bytes} bytes → ${got.url}`);
511
439
  },
512
440
 
513
441
  /**
514
- * What you did, newest first, under a heading per month.
515
- *
516
- * The month headings are the whole point: every other list in this CLI is ordered by what needs
517
- * you — the board's queues, `list`'s state rank — and none of them can answer "what did I get
518
- * done in September". Time is the axis nothing else offers.
519
- *
520
- * A repo narrows it, positionally and without a flag, for the same reason `list` takes its query
521
- * that way: it is the one thing you are likely to say, and a flag would make you name it twice.
442
+ * Work the queue from this worktree until it is empty. The agent is `claude -p` unless --agent or
443
+ * PASSALONG_AGENT says otherwise, e.g. "claude -p --permission-mode acceptEdits" so it can edit
444
+ * files without asking — which is yours to decide, not this command's.
522
445
  */
523
- async log(f) {
524
- const repo = f._.join(" ");
525
- const rows = await passalong.log({ repo }).catch((e) => die(e.message));
526
- if (!rows.length)
527
- return console.error(
528
- repo
529
- ? `nothing passed along from anything matching "${repo}"`
530
- : "nothing yet — your log fills up as you share, pull and answer guides",
531
- );
532
- let month = "";
533
- for (const r of rows) {
534
- const m = monthOf(r.at);
535
- if (m !== month) {
536
- console.log(`\n${m}`);
537
- month = m;
538
- }
539
- console.log(` ${r.guide} ${r.at.slice(0, 10)} ${r.text}${r.repo && !repo ? ` ${r.repo}` : ""}`);
540
- }
541
- // Said every time, because the list looks like a work log and is not one. A month you shipped
542
- // without sharing looks identical here to a month you did nothing.
543
- console.error(`\n${rows.length} thing${rows.length === 1 ? "" : "s"}. Work that never became a guide is not here.`);
544
- },
545
-
546
- async activity(f) {
547
- const { notifications, unread } = await passalong.activity({ all: f.all }).catch((e) => die(e.message));
548
- if (!notifications.length)
549
- return console.error(
550
- f.all ? "nothing has happened yet" : "nothing new — your guides and handoffs are quiet",
551
- );
552
- for (const n of notifications) console.log(activityLine(n));
553
- const waiting = notifications.filter((n) => n.kind === "handoff" && !n.read);
554
- if (waiting.length) console.error(`\nPull one with: passalong pull ${waiting[0].guide}`);
555
- // Reading the feed is what marks it read: an unread count you have to clear by hand is a
556
- // chore, and `--all` is there for when you want to look without clearing.
557
- if (!f.all && unread) await passalong.seen().catch(() => {});
558
- },
559
-
560
- async list(f) {
561
- const scope = typeof f.team === "string" ? f.team : f.mine ? "mine" : "";
562
- const rows = await passalong.list(f._.join(" "), { scope });
563
- if (!rows.length)
564
- return console.error(
565
- "no guides yet — run `passalong share` after your next finished piece of work",
446
+ async work(f) {
447
+ // The runner: only work a person marked safe to run alone, waiting for more when there is none.
448
+ const watch = Boolean(f.watch);
449
+ let idle = false;
450
+ if (watch)
451
+ console.error("Runner: taking only work marked safe to run alone. Stop it with Ctrl-C.");
452
+ const run = await passalong
453
+ .work({
454
+ agent: typeof f.agent === "string" ? f.agent : undefined,
455
+ once: Boolean(f.once),
456
+ any: Boolean(f.any),
457
+ watch,
458
+ every: Number(f.every) || 60,
459
+ onTask: (t) => {
460
+ idle = false;
461
+ console.error(`\n→ ${t.id} ${t.title}${t.resumed ? " (resuming)" : ""}`);
462
+ },
463
+ onIdle: () => {
464
+ if (!idle) console.error("waiting for work marked safe to run alone…");
465
+ idle = true;
466
+ },
467
+ onUnfinished: (t, state) =>
468
+ console.error(
469
+ `✗ ${t.id} is still ${state || "open"}: left held here; the sweep puts it back if it goes quiet`,
470
+ ),
471
+ })
472
+ .catch((e) => die(e.message));
473
+ for (const id of run.finished) console.log(id);
474
+ const n = run.finished.length;
475
+ const done = `${n} finished`;
476
+ if (run.stopped === "unfinished")
477
+ die(
478
+ `${done}. Stopped: the agent exited with ${run.task} still ${run.state || "open"} — ` +
479
+ `run again to resume it, or \`passalong release ${run.task}\``,
566
480
  );
567
- for (const r of rows) console.log(guideLine(r));
568
- },
569
-
570
- async open(f) {
571
- const id = f._[0] || die("usage: passalong open <id>");
572
- const { guide } = await passalong.resolve(id).catch((e) => die(e.message));
573
- if (guide.meta.url && tty && !f.print) {
574
- openInBrowser(guide.meta.url);
575
- console.error(guide.meta.url);
576
- } else {
577
- process.stdout.write(serialize(guide));
578
- if (guide.meta.url) console.error(`\nlink: ${guide.meta.url}`);
579
- }
481
+ console.error(`${done}${run.stopped === "empty" ? " — nothing left for this repo" : ""}`);
580
482
  },
581
483
 
582
484
  async hub() {
583
- if (!api.loggedIn())
584
- die("not logged in — run `passalong login` first (the hub shows synced guides)");
485
+ if (!api.loggedIn()) die("not logged in — run `passalong login` first");
585
486
  // The token rides in the URL fragment: the browser never sends it to the server, and the
586
487
  // page moves it into localStorage and scrubs the address bar on load.
587
488
  const url = `${api.baseUrl()}/hub#token=${encodeURIComponent(api.token())}`;
@@ -591,35 +492,10 @@ const commands = {
591
492
  } else console.log(url);
592
493
  },
593
494
 
594
- async take(f) {
595
- return commands.start(f);
596
- },
597
-
598
- async pass(f) {
599
- const id = f._[0] || die("usage: passalong pass <id> <why it is not yours>");
600
- const why = f._.slice(1).join(" ");
601
- // Required, and the reason is the whole point: "not me" without "why" leaves the sender where
602
- // the silence did, still guessing whether to re-address it, wait, or go and ask.
603
- if (!why) die("say why you are passing: passalong pass <id> <why it is not yours>");
604
- await passalong.pass(id, why).catch((e) => die(e.message));
605
- console.error(`${id} handed back with your reason: "${why}"`);
606
- },
607
-
608
-
609
- async assign(f) {
610
- const id = f._[0] || die('usage: passalong assign <id> <@handle | #group | team>');
611
- const to = f._.slice(1).join(",") || die('say who: @handle (or several), #group, or "team" for everyone');
612
- const r = await passalong.assign(id, to).catch((e) => die(e.message));
613
- const back = r.taken_back ? ` — taken back from ${r.taken_back} who had it, and they were told` : "";
614
- console.error(`${r.id} is now for ${r.to || "everyone in the team"}${back}`);
615
- },
616
-
617
- async recall(f) {
618
- const id = f._[0] || die("usage: passalong recall <id>");
619
- const r = await passalong.recall(id).catch((e) => die(e.message));
620
- console.error(`${r.id} is private again, and gone from their inboxes`);
621
- if (r.already_opened_by)
622
- console.error(`${r.already_opened_by} ${r.already_opened_by === 1 ? "person" : "people"} had already opened it, and keep what they pulled`);
495
+ async export(f) {
496
+ const dir = resolve(f._[0] || "passalong-export");
497
+ const paths = await passalong.exportAll(dir).catch((e) => die(e.message));
498
+ console.error(`exported ${paths.length} → ${dir}`);
623
499
  },
624
500
 
625
501
  // ---- who runs the product ---------------------------------------------------------------
@@ -694,44 +570,6 @@ const commands = {
694
570
  console.error(`${id} taken back — that plan is lapsed as of now`);
695
571
  },
696
572
 
697
- /**
698
- * Archive a guide: off your board, and out of what the free tier counts.
699
- *
700
- * The command was `done`, and the status it sets is still `consumed`, because both once meant "I
701
- * implemented it". That meaning went to the verdict, where it belongs — it is the reader's
702
- * judgement, not the author's — and what was left is shelf space, which has a job nothing else
703
- * does: the only way under the sync limit that does not delete the work, and the 402 says so by
704
- * name. So the command says what it does now.
705
- *
706
- * `consumed` stays on disk. It is a status inside markdown files in other people's
707
- * repositories, and `validate()` refuses a status it does not know, so renaming the value would
708
- * make a guide shared a month ago fail to re-share today. The word people read and the token the
709
- * format stores are allowed to differ; what is not allowed is two words for people.
710
- */
711
- async archive(f) {
712
- const id = f._[0] || die("usage: passalong archive <id>");
713
- const g = await passalong.setStatus(id, "consumed").catch((e) => die(e.message));
714
- console.error(`${g.meta.id} archived — off your board, and it no longer counts against sync`);
715
- },
716
-
717
- /** What `archive` was called. Kept so a script written against it still runs, and says so. */
718
- async done(f) {
719
- console.error("`passalong done` is now `passalong archive` — it archives, it does not mark work done.");
720
- return commands.archive(f);
721
- },
722
-
723
- async rm(f) {
724
- const id = f._[0] || die("usage: passalong rm <id>");
725
- await passalong.remove(id).catch((e) => die(e.message));
726
- console.error(`${id} removed`);
727
- },
728
-
729
- async export(f) {
730
- const dir = resolve(f._[0] || "passalong-export");
731
- const paths = await passalong.exportAll(dir).catch((e) => die(e.message));
732
- console.error(`exported ${paths.length} guide${paths.length === 1 ? "" : "s"} → ${dir}`);
733
- },
734
-
735
573
  async login(f) {
736
574
  // A token on the command line lands in shell history, so it is only read from stdin.
737
575
  if (f._[0]) die("pass a token on stdin instead: passalong login --with-token < token.txt");
@@ -812,7 +650,7 @@ const commands = {
812
650
  : who.sync === "none"
813
651
  ? " (no plan — nothing is syncing)"
814
652
  : " (no limit on your plan)";
815
- console.log(`guides ${who.guides} synced${ceiling}`);
653
+ console.log(`work ${who.work} active${ceiling}`);
816
654
  const current = api.currentTeam();
817
655
  console.log(
818
656
  `teams ${who.teams.length ? who.teams.map((t) => `${t.slug}${t.slug === current ? "*" : ""} (${t.role})`).join(", ") : "(none — passalong team create <name>)"}`,
@@ -829,7 +667,7 @@ const commands = {
829
667
  return console.error(`you are in ${teams.map((t) => t.slug).join(", ")}; pick one: passalong team use <slug>`);
830
668
  }
831
669
  const t = await api.team(cfg.team).catch((e) => die(e.message));
832
- console.log(`${t.name} (${t.slug}) — ${t.members.length} member${t.members.length === 1 ? "" : "s"}, ${t.guides} guide${t.guides === 1 ? "" : "s"}`);
670
+ console.log(`${t.name} (${t.slug}) — ${t.members.length} member${t.members.length === 1 ? "" : "s"}, ${t.work} piece${t.work === 1 ? "" : "s"} of work`);
833
671
  for (const m of t.members)
834
672
  console.log(` ${m.handle ? `@${m.handle}` : "(no handle)"}${m.name ? ` ${m.name}` : ""} ${m.role === "owner" ? "admin" : m.role} joined ${relDate(m.joined)}`);
835
673
  return;
@@ -929,9 +767,10 @@ const commands = {
929
767
  } catch (err) {
930
768
  console.error(`could not install Claude Code hooks (${err.message}); ${settingsPath} left as it was`);
931
769
  }
932
- if (!api.loggedIn())
933
- console.error("sync is off; run `passalong login` when you want guides to follow you across machines");
934
- console.error('done. In Claude Code, say "pass this along" at the end of a task, or run /passalong-capture.');
770
+ if (!api.loggedIn()) console.error("not signed in yet: run `passalong login`");
771
+ console.error(
772
+ 'done. In Claude Code, say "pass this along" to write what a session learned into the project\'s documents, or run /passalong-capture.',
773
+ );
935
774
  },
936
775
 
937
776
  /**
@@ -940,7 +779,7 @@ const commands = {
940
779
  * nothing at all when anything goes wrong — a hook must never get in the way of a session.
941
780
  */
942
781
  async now(f) {
943
- const { nowText, statusText, stopVerdict } = await import("../src/hooks.js");
782
+ const { nowText, statusText, stopDecision } = await import("../src/hooks.js");
944
783
  // The status line: printed from a cache, refreshed behind it. Claude Code cancels a status
945
784
  // line still running when the next update comes, so this never waits on the network.
946
785
  if (f.statusline) return statusLine(f);
@@ -948,7 +787,7 @@ const commands = {
948
787
  const cwd = typeof f["statusline-refresh"] === "string" ? f["statusline-refresh"] : process.cwd();
949
788
  try {
950
789
  const r = await passalong.now({ cwd });
951
- writeStatus(cwd, statusText({ held: r.held, needs: r.needs, ready: r.waiting.ready }));
790
+ writeStatus(cwd, statusText(r));
952
791
  } catch {}
953
792
  return;
954
793
  }
@@ -997,7 +836,7 @@ const commands = {
997
836
  }),
998
837
  );
999
838
  } else if (f.hook === "stop") {
1000
- const v = stopVerdict({ held: r.held, input });
839
+ const v = stopDecision({ held: r.held, input });
1001
840
  if (v) console.log(JSON.stringify(v));
1002
841
  }
1003
842
  } catch {}
@@ -1020,7 +859,7 @@ const commands = {
1020
859
  async help() {
1021
860
  const src = readFileSync(fileURLToPath(import.meta.url), "utf8");
1022
861
  const lines = src.split("\n").filter((l) => l.startsWith("// passalong "));
1023
- console.log("passalong — hand finished work to another context\n");
862
+ console.log("passalong — work for people and agents, and what they know\n");
1024
863
  console.log(lines.map((l) => l.slice(3)).join("\n"));
1025
864
  },
1026
865
  };