passalong 0.21.0 → 1.1.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 +63 -72
- package/bin/passalong +241 -378
- package/package.json +4 -4
- package/skill/SKILL.md +55 -167
- package/src/api.js +86 -188
- package/src/capture.js +15 -51
- package/src/checks.js +5 -5
- package/src/hooks.js +13 -22
- package/src/mcp.js +724 -1483
- package/src/passalong.js +602 -691
- package/src/runner.js +243 -0
- package/src/store.js +18 -28
- package/src/update.js +1 -1
- package/src/work.js +416 -0
- package/src/guide.js +0 -811
package/bin/passalong
CHANGED
|
@@ -1,32 +1,31 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// Passalong CLI. Few flags on purpose: the defaults are the product.
|
|
3
3
|
//
|
|
4
|
-
// passalong
|
|
5
|
-
//
|
|
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
|
|
10
|
-
// passalong
|
|
11
|
-
// passalong
|
|
12
|
-
// passalong
|
|
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
|
|
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
|
|
17
|
-
// passalong
|
|
18
|
-
//
|
|
19
|
-
// passalong
|
|
20
|
-
// passalong
|
|
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
|
+
// up to 3 at once and 10 a day, each in its own worktree and branch, finished as a pull request
|
|
27
|
+
// passalong hub open the hub in the browser
|
|
28
|
+
// passalong export [dir] dump every piece of work as plain markdown (default ./passalong-export)
|
|
30
29
|
// passalong admin create <email> make an account that runs Passalong (owner only)
|
|
31
30
|
// passalong admin list who runs it
|
|
32
31
|
// passalong admin grant|revoke <@handle> make an existing account an admin, or stop it being one
|
|
@@ -34,9 +33,6 @@
|
|
|
34
33
|
// give Solo to @handle, or Team to team/slug, until a date (operator only)
|
|
35
34
|
// passalong gifts what has been given away, and what is still running
|
|
36
35
|
// 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
36
|
// passalong login sign in from the browser
|
|
41
37
|
// passalong login --with-token read a token from stdin instead (CI, scripts)
|
|
42
38
|
// passalong login --new add another account to this machine
|
|
@@ -62,8 +58,8 @@ import { homedir, hostname } from "node:os";
|
|
|
62
58
|
import { dirname, join, resolve } from "node:path";
|
|
63
59
|
import { fileURLToPath } from "node:url";
|
|
64
60
|
import * as api from "../src/api.js";
|
|
65
|
-
import {
|
|
66
|
-
import { parse, serialize } from "../src/
|
|
61
|
+
import { workScaffold } from "../src/capture.js";
|
|
62
|
+
import { parse, serialize } from "../src/work.js";
|
|
67
63
|
import * as passalong from "../src/passalong.js";
|
|
68
64
|
import * as store from "../src/store.js";
|
|
69
65
|
|
|
@@ -202,41 +198,12 @@ function relDate(iso) {
|
|
|
202
198
|
return iso.slice(0, 10);
|
|
203
199
|
}
|
|
204
200
|
|
|
205
|
-
function
|
|
206
|
-
const who = r.team ? ` ${r.team}${r.to ?
|
|
201
|
+
function workLine(r) {
|
|
202
|
+
const who = r.team ? ` ${r.team}${r.to ? `/${r.to}` : ""}${r.mine ? "" : ` from @${r.from || "?"}`}` : "";
|
|
207
203
|
const tags = r.tags?.length ? ` #${r.tags.join(" #")}` : "";
|
|
208
|
-
|
|
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}`;
|
|
204
|
+
return `${r.id} ${r.status.padEnd(9)} ${relDate(r.created).padEnd(10)} ${r.title}${who}${tags}`;
|
|
219
205
|
}
|
|
220
206
|
|
|
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
207
|
// ---- the status line cache --------------------------------------------------------------------------
|
|
241
208
|
|
|
242
209
|
/** One small file per working directory, so two sessions in two repos each see their own. */
|
|
@@ -284,112 +251,189 @@ async function statusLine() {
|
|
|
284
251
|
}
|
|
285
252
|
}
|
|
286
253
|
|
|
254
|
+
/**
|
|
255
|
+
* `passalong work --watch`: the runner (src/runner.js). Each piece of work marked safe to run alone
|
|
256
|
+
* gets its own worktree, branch and Claude Code; what it does arrives as a pull request.
|
|
257
|
+
*/
|
|
258
|
+
async function runner(f) {
|
|
259
|
+
const every = Number(f.every) || 60;
|
|
260
|
+
console.error(
|
|
261
|
+
"Runner: taking work marked safe to run alone, up to 3 at once and 10 a day, each finished as a " +
|
|
262
|
+
"pull request. The hub's Work board shows what it is on. Stop it with Ctrl-C: agents running " +
|
|
263
|
+
"then stop too, and what they held goes back to the queue once it goes quiet.",
|
|
264
|
+
);
|
|
265
|
+
let idle = false;
|
|
266
|
+
await passalong
|
|
267
|
+
.watch({
|
|
268
|
+
agent: typeof f.agent === "string" ? f.agent : undefined,
|
|
269
|
+
every,
|
|
270
|
+
cli: fileURLToPath(import.meta.url),
|
|
271
|
+
signals: true,
|
|
272
|
+
onTask: (t, { dir, branch }) => {
|
|
273
|
+
idle = false;
|
|
274
|
+
console.error(`\n→ ${t.id} ${t.title}\n on ${branch} in ${dir}`);
|
|
275
|
+
},
|
|
276
|
+
onIdle: (repo) => {
|
|
277
|
+
if (!idle) console.error(`waiting for work marked safe to run alone for ${repo || "this repo"}…`);
|
|
278
|
+
idle = true;
|
|
279
|
+
},
|
|
280
|
+
onCapped: (n) =>
|
|
281
|
+
console.error(`${n} runs today: that's the day's limit. Picking up again tomorrow.`),
|
|
282
|
+
onFinished: (t, { branch, log }) => console.error(`✓ ${t.id} finished on ${branch} (log: ${log})`),
|
|
283
|
+
onWaiting: (t, { question }) =>
|
|
284
|
+
console.error(`? ${t.id} is waiting on you: "${question.split("\n")[0]}" (answer it in the hub)`),
|
|
285
|
+
onResumed: (t) => console.error(`\n→ ${t.id} ${t.title} (answered: carrying on)`),
|
|
286
|
+
onPassed: (t, { dir, log }) =>
|
|
287
|
+
console.error(`✗ ${t.id} went back to its author, unfinished. Its worktree: ${dir} (log: ${log})`),
|
|
288
|
+
onTrouble: (err, t) => console.error(`! ${t ? `${t.id}: ` : ""}${err?.message || err}`),
|
|
289
|
+
})
|
|
290
|
+
.catch((e) => die(e.message));
|
|
291
|
+
}
|
|
292
|
+
|
|
287
293
|
const commands = {
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
}
|
|
294
|
+
/**
|
|
295
|
+
* Write work down: a sentence becomes its title, summary and text, or a file is sent as it is. At
|
|
296
|
+
* a terminal it opens in $EDITOR first, so whoever wrote it can add to it before it goes.
|
|
297
|
+
*/
|
|
298
|
+
async new(f) {
|
|
299
|
+
const arg =
|
|
300
|
+
f._.join(" ").trim() ||
|
|
301
|
+
die('usage: passalong new "<what needs doing>" | <file.md> [--to TARGET] [--parent ID] [--draft]');
|
|
302
|
+
let path = arg;
|
|
303
|
+
if (!(arg.endsWith(".md") && existsSync(arg))) {
|
|
304
|
+
path = store.draftPath(`work-${new Date().toISOString().replace(/[:.]/g, "-")}`);
|
|
305
|
+
writeFileSync(path, workScaffold(arg));
|
|
301
306
|
}
|
|
302
|
-
if (!existsSync(path)) die(`no such file: ${path}`);
|
|
303
307
|
if (tty && !f["no-edit"]) edit(path);
|
|
304
|
-
const
|
|
305
|
-
|
|
306
|
-
const {
|
|
307
|
-
.
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
308
|
+
const g = parse(readFileSync(path, "utf8"));
|
|
309
|
+
if (f.draft) g.meta.status = "draft";
|
|
310
|
+
const { work, url } = await passalong
|
|
311
|
+
.publish(serialize(g), {
|
|
312
|
+
to: typeof f.to === "string" ? f.to : undefined,
|
|
313
|
+
parent: typeof f.parent === "string" ? f.parent : undefined,
|
|
314
|
+
})
|
|
315
|
+
.catch((e) => die(`${e.message}\nkept at ${path} — fix it and run \`passalong new ${path}\``));
|
|
316
|
+
// A draft that went out is spent. removeDraft() touches nothing outside the drafts directory.
|
|
312
317
|
store.removeDraft(resolve(path));
|
|
313
|
-
console.log(
|
|
314
|
-
|
|
315
|
-
|
|
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
|
-
);
|
|
318
|
+
console.log(work.meta.id);
|
|
319
|
+
const where = work.meta.team ? ` for ${work.meta.team}${work.meta.to ? `/${work.meta.to}` : ""}` : "";
|
|
320
|
+
console.error(`"${work.meta.title}" is ${work.meta.status === "draft" ? "in Draft" : "Ready"}${where}`);
|
|
320
321
|
console.error(`link: ${url}`);
|
|
321
322
|
},
|
|
322
323
|
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
324
|
+
async board() {
|
|
325
|
+
const b = await passalong.board().catch((e) => die(e.message));
|
|
326
|
+
const section = (title, rows, line) => {
|
|
327
|
+
if (!rows.length) return;
|
|
328
|
+
console.log(`\n${title} · ${rows.length}`);
|
|
329
|
+
for (const r of rows) console.log(` ${r.id} ${line(r)}`);
|
|
330
|
+
};
|
|
331
|
+
section("Needs you", b.needs, (n) => `${n.title} — ${n.why}`);
|
|
332
|
+
section("Being worked on", b.working, (w) =>
|
|
333
|
+
`${w.title} ${w.who}${w.stalled ? " (went quiet)" : ""}${w.note ? ` "${w.note}"` : ""}`);
|
|
334
|
+
section("Ready", b.ready, (t) => `${t.title}${t.target ? ` [${t.target}]` : ""}${t.to ? ` for ${t.to}` : ""}`);
|
|
335
|
+
if (!b.needs.length && !b.working.length && !b.ready.length) console.error("nothing open");
|
|
336
|
+
console.error(`\n${b.counts.done} done. The board: ${b.hub}/work`);
|
|
337
|
+
},
|
|
338
|
+
|
|
339
|
+
async list(f) {
|
|
340
|
+
const scope = typeof f.team === "string" ? f.team : f.mine ? "mine" : "";
|
|
341
|
+
const rows = await passalong.list(f._.join(" "), { scope }).catch((e) => die(e.message));
|
|
342
|
+
if (!rows.length) return console.error('no work yet — write some with: passalong new "<what needs doing>"');
|
|
343
|
+
for (const r of rows) console.log(workLine(r));
|
|
341
344
|
},
|
|
342
345
|
|
|
343
|
-
async
|
|
344
|
-
const
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
if (t.state === "review" && c?.report)
|
|
353
|
-
console.log(` → ${c.report} ${c.report_title || "(untitled)"}${c.pr ? ` ${c.pr}` : ""}`);
|
|
346
|
+
async open(f) {
|
|
347
|
+
const id = f._[0] || die("usage: passalong open <id>");
|
|
348
|
+
const { work } = await passalong.resolve(id).catch((e) => die(e.message));
|
|
349
|
+
if (work.meta.url && tty && !f.print) {
|
|
350
|
+
openInBrowser(work.meta.url);
|
|
351
|
+
console.error(work.meta.url);
|
|
352
|
+
} else {
|
|
353
|
+
process.stdout.write(serialize(work));
|
|
354
|
+
if (work.meta.url) console.error(`\nlink: ${work.meta.url}`);
|
|
354
355
|
}
|
|
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
356
|
},
|
|
358
357
|
|
|
359
358
|
/**
|
|
360
|
-
*
|
|
361
|
-
*
|
|
362
|
-
* files without asking — which is yours to decide, not this command's.
|
|
359
|
+
* Take it: you are doing it, and nobody else can take it while you hold it. It is printed, and
|
|
360
|
+
* `--save` also writes it to ./.passalong/. With no id, the next piece of work waiting here.
|
|
363
361
|
*/
|
|
364
|
-
async
|
|
365
|
-
const
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
362
|
+
async take(f) {
|
|
363
|
+
const r = await passalong.take(f._[0], { save: Boolean(f.save) }).catch((e) => die(e.message));
|
|
364
|
+
if (!r.work?.markdown) return console.error(r.say || "nothing to take");
|
|
365
|
+
process.stdout.write(r.work.markdown);
|
|
366
|
+
if (r.path) console.error(`\nsaved to ${r.path}`);
|
|
367
|
+
console.error(`\nyou hold ${r.work.id} — hand it in when it is done, or pass it with the reason`);
|
|
368
|
+
},
|
|
369
|
+
|
|
370
|
+
async progress(f) {
|
|
371
|
+
const id = f._[0] || die("usage: passalong progress <id> <note>");
|
|
372
|
+
await passalong.progress(id, f._.slice(1).join(" ")).catch((e) => die(e.message));
|
|
373
|
+
console.error(`${id} — still yours`);
|
|
374
|
+
},
|
|
375
|
+
|
|
376
|
+
async ["hand-in"](f) {
|
|
377
|
+
const id = f._[0] || die("usage: passalong hand-in <id> <note> --evidence TEXT");
|
|
378
|
+
const note = f._.slice(1).join(" ") || die("say in one sentence what you did: passalong hand-in <id> <note>");
|
|
379
|
+
const r = await passalong
|
|
380
|
+
.handIn(id, {
|
|
381
|
+
note,
|
|
382
|
+
evidence: typeof f.evidence === "string" ? f.evidence : "",
|
|
383
|
+
writeup: typeof f.writeup === "string" ? f.writeup : "",
|
|
384
|
+
pr: typeof f.pr === "string" ? f.pr : "",
|
|
371
385
|
})
|
|
372
386
|
.catch((e) => die(e.message));
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
387
|
+
console.error(`${id} is ${r.state === "done" ? "Done" : "handed in here; others still hold it"}`);
|
|
388
|
+
},
|
|
389
|
+
|
|
390
|
+
async pass(f) {
|
|
391
|
+
const id = f._[0] || die("usage: passalong pass <id> <why it is not yours>");
|
|
392
|
+
const why = f._.slice(1).join(" ");
|
|
393
|
+
// Required, and the reason is the whole point: "not me" without "why" leaves the sender where
|
|
394
|
+
// the silence did, still guessing whether to re-address it, wait, or go and ask.
|
|
395
|
+
if (!why) die("say why you are passing: passalong pass <id> <why it is not yours>");
|
|
396
|
+
await passalong.pass(id, why).catch((e) => die(e.message));
|
|
397
|
+
console.error(`${id} handed back with your reason: "${why}"`);
|
|
383
398
|
},
|
|
384
399
|
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
400
|
+
async ask(f) {
|
|
401
|
+
const id = f._[0] || die("usage: passalong ask <id> <question>");
|
|
402
|
+
await passalong.ask(id, f._.slice(1).join(" ")).catch((e) => die(e.message));
|
|
403
|
+
console.error(`${id} — asked. It is still yours; take it again once they have replied`);
|
|
404
|
+
},
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Write to whoever holds a piece of work, or leave a note on one nobody holds yet. The person's
|
|
408
|
+
* side of `ask`: an agent that stopped to wait hears this when it takes the work again.
|
|
409
|
+
*/
|
|
410
|
+
async reply(f) {
|
|
411
|
+
const id = f._[0] || die("usage: passalong reply <id> <message> [--file PATH ...]");
|
|
412
|
+
const files = [f.file].flat().filter((x) => typeof x === "string");
|
|
413
|
+
const said = f._.slice(1).join(" ");
|
|
414
|
+
if (!said && !files.length) die("say something, or attach a file: passalong reply <id> <message>");
|
|
415
|
+
const r = await passalong.reply(id, said, { files }).catch((e) => die(e.message));
|
|
388
416
|
console.error(
|
|
389
|
-
|
|
417
|
+
r?.noted
|
|
418
|
+
? `${id} — nobody holds it yet, so this is a note: whoever takes it is handed it first`
|
|
419
|
+
: `${id} — sent. Whoever holds it reads it the next time it checks in`,
|
|
390
420
|
);
|
|
391
421
|
},
|
|
392
422
|
|
|
423
|
+
async assign(f) {
|
|
424
|
+
const id = f._[0] || die('usage: passalong assign <id> <@handle | #group | team>');
|
|
425
|
+
const to = f._.slice(1).join(",") || die('say who: @handle (or several), #group, or "team" for everyone');
|
|
426
|
+
const r = await passalong.assign(id, to).catch((e) => die(e.message));
|
|
427
|
+
const back = r.taken_back ? ` — taken back from ${r.taken_back} who had it, and they were told` : "";
|
|
428
|
+
console.error(`${r.id} is now for ${r.to || "everyone in the team"}${back}`);
|
|
429
|
+
},
|
|
430
|
+
|
|
431
|
+
async release(f) {
|
|
432
|
+
const id = f._[0] || die("usage: passalong release <id>");
|
|
433
|
+
await passalong.release(id).catch((e) => die(e.message));
|
|
434
|
+
console.error(`${id} is back in Ready — whoever had it has been told`);
|
|
435
|
+
},
|
|
436
|
+
|
|
393
437
|
/** Finished work back to To do, with why at its top; whoever finished it is told. */
|
|
394
438
|
async reopen(f) {
|
|
395
439
|
const id = f._[0] || die("usage: passalong reopen <id> <why>");
|
|
@@ -399,189 +443,70 @@ const commands = {
|
|
|
399
443
|
console.error(`${id} is back in To do — whoever takes it next reads why`);
|
|
400
444
|
},
|
|
401
445
|
|
|
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
446
|
async ready(f) {
|
|
417
447
|
if (f.all) {
|
|
418
448
|
const ids = await passalong.readyDrafts().catch((e) => die(e.message));
|
|
419
449
|
if (!ids.length) return console.error("no drafts of yours to make ready");
|
|
420
450
|
for (const id of ids) console.log(id);
|
|
421
|
-
return console.error(`${ids.length}
|
|
451
|
+
return console.error(`${ids.length} Ready — whoever they are for may take them`);
|
|
422
452
|
}
|
|
423
453
|
if (!f._.length) die("usage: passalong ready <id...> | --all");
|
|
424
|
-
for (const
|
|
425
|
-
const
|
|
426
|
-
console.error(`${
|
|
454
|
+
for (const ref of f._) {
|
|
455
|
+
const id = await passalong.ready(ref).catch((e) => die(e.message));
|
|
456
|
+
console.error(`${id} is Ready — whoever it is for may take it`);
|
|
427
457
|
}
|
|
428
458
|
},
|
|
429
459
|
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
const
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
process.stdout.write(markdown);
|
|
436
|
-
if (path) console.error(`\nsaved to ${path}`);
|
|
437
|
-
await listFollowUps(guide.meta);
|
|
460
|
+
/** Archive: off the board, and out of what the free tier counts. It comes back to Draft. */
|
|
461
|
+
async archive(f) {
|
|
462
|
+
const id = f._[0] || die("usage: passalong archive <id>");
|
|
463
|
+
await passalong.setStatus(id, "done").catch((e) => die(e.message));
|
|
464
|
+
console.error(`${id} archived — off the board, and it no longer counts against your plan`);
|
|
438
465
|
},
|
|
439
466
|
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
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);
|
|
467
|
+
async rm(f) {
|
|
468
|
+
const id = f._[0] || die("usage: passalong rm <id>");
|
|
469
|
+
await passalong.remove(id).catch((e) => die(e.message));
|
|
470
|
+
console.error(`${id} removed`);
|
|
453
471
|
},
|
|
454
472
|
|
|
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
|
-
*/
|
|
473
|
+
/** Upload a picture or a file and print the markdown that points at it. */
|
|
460
474
|
async attach(f) {
|
|
461
475
|
const file = f._[0] || die("usage: passalong attach <file> [--name LABEL]");
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
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`);
|
|
476
|
+
const got = await passalong.attachAny(file, { name: f.name || "" }).catch((e) => die(e.message));
|
|
477
|
+
process.stdout.write(`${got.markdown}\n`);
|
|
478
|
+
console.error(`uploaded ${got.bytes} bytes → ${got.url}`);
|
|
511
479
|
},
|
|
512
480
|
|
|
513
481
|
/**
|
|
514
|
-
*
|
|
515
|
-
*
|
|
516
|
-
*
|
|
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.
|
|
482
|
+
* Work the queue from this worktree until it is empty. The agent is `claude -p` unless --agent or
|
|
483
|
+
* PASSALONG_AGENT says otherwise, e.g. "claude -p --permission-mode acceptEdits" so it can edit
|
|
484
|
+
* files without asking — which is yours to decide, not this command's.
|
|
522
485
|
*/
|
|
523
|
-
async
|
|
524
|
-
|
|
525
|
-
const
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
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",
|
|
486
|
+
async work(f) {
|
|
487
|
+
if (f.watch) return runner(f);
|
|
488
|
+
const run = await passalong
|
|
489
|
+
.work({
|
|
490
|
+
agent: typeof f.agent === "string" ? f.agent : undefined,
|
|
491
|
+
once: Boolean(f.once),
|
|
492
|
+
any: Boolean(f.any),
|
|
493
|
+
onTask: (t) =>
|
|
494
|
+
console.error(`\n→ ${t.id} ${t.title}${t.resumed ? " (resuming)" : ""}`),
|
|
495
|
+
})
|
|
496
|
+
.catch((e) => die(e.message));
|
|
497
|
+
for (const id of run.finished) console.log(id);
|
|
498
|
+
const n = run.finished.length;
|
|
499
|
+
const done = `${n} finished`;
|
|
500
|
+
if (run.stopped === "unfinished")
|
|
501
|
+
die(
|
|
502
|
+
`${done}. Stopped: the agent exited with ${run.task} still ${run.state || "open"} — ` +
|
|
503
|
+
`run again to resume it, or \`passalong release ${run.task}\``,
|
|
566
504
|
);
|
|
567
|
-
for
|
|
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
|
-
}
|
|
505
|
+
console.error(`${done}${run.stopped === "empty" ? " — nothing left for this repo" : ""}`);
|
|
580
506
|
},
|
|
581
507
|
|
|
582
508
|
async hub() {
|
|
583
|
-
if (!api.loggedIn())
|
|
584
|
-
die("not logged in — run `passalong login` first (the hub shows synced guides)");
|
|
509
|
+
if (!api.loggedIn()) die("not logged in — run `passalong login` first");
|
|
585
510
|
// The token rides in the URL fragment: the browser never sends it to the server, and the
|
|
586
511
|
// page moves it into localStorage and scrubs the address bar on load.
|
|
587
512
|
const url = `${api.baseUrl()}/hub#token=${encodeURIComponent(api.token())}`;
|
|
@@ -591,35 +516,10 @@ const commands = {
|
|
|
591
516
|
} else console.log(url);
|
|
592
517
|
},
|
|
593
518
|
|
|
594
|
-
async
|
|
595
|
-
|
|
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`);
|
|
519
|
+
async export(f) {
|
|
520
|
+
const dir = resolve(f._[0] || "passalong-export");
|
|
521
|
+
const paths = await passalong.exportAll(dir).catch((e) => die(e.message));
|
|
522
|
+
console.error(`exported ${paths.length} → ${dir}`);
|
|
623
523
|
},
|
|
624
524
|
|
|
625
525
|
// ---- who runs the product ---------------------------------------------------------------
|
|
@@ -694,44 +594,6 @@ const commands = {
|
|
|
694
594
|
console.error(`${id} taken back — that plan is lapsed as of now`);
|
|
695
595
|
},
|
|
696
596
|
|
|
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
597
|
async login(f) {
|
|
736
598
|
// A token on the command line lands in shell history, so it is only read from stdin.
|
|
737
599
|
if (f._[0]) die("pass a token on stdin instead: passalong login --with-token < token.txt");
|
|
@@ -812,7 +674,7 @@ const commands = {
|
|
|
812
674
|
: who.sync === "none"
|
|
813
675
|
? " (no plan — nothing is syncing)"
|
|
814
676
|
: " (no limit on your plan)";
|
|
815
|
-
console.log(`
|
|
677
|
+
console.log(`work ${who.work} active${ceiling}`);
|
|
816
678
|
const current = api.currentTeam();
|
|
817
679
|
console.log(
|
|
818
680
|
`teams ${who.teams.length ? who.teams.map((t) => `${t.slug}${t.slug === current ? "*" : ""} (${t.role})`).join(", ") : "(none — passalong team create <name>)"}`,
|
|
@@ -829,7 +691,7 @@ const commands = {
|
|
|
829
691
|
return console.error(`you are in ${teams.map((t) => t.slug).join(", ")}; pick one: passalong team use <slug>`);
|
|
830
692
|
}
|
|
831
693
|
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.
|
|
694
|
+
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
695
|
for (const m of t.members)
|
|
834
696
|
console.log(` ${m.handle ? `@${m.handle}` : "(no handle)"}${m.name ? ` ${m.name}` : ""} ${m.role === "owner" ? "admin" : m.role} joined ${relDate(m.joined)}`);
|
|
835
697
|
return;
|
|
@@ -929,9 +791,10 @@ const commands = {
|
|
|
929
791
|
} catch (err) {
|
|
930
792
|
console.error(`could not install Claude Code hooks (${err.message}); ${settingsPath} left as it was`);
|
|
931
793
|
}
|
|
932
|
-
if (!api.loggedIn())
|
|
933
|
-
|
|
934
|
-
|
|
794
|
+
if (!api.loggedIn()) console.error("not signed in yet: run `passalong login`");
|
|
795
|
+
console.error(
|
|
796
|
+
'done. In Claude Code, say "pass this along" to write what a session learned into the project\'s documents, or run /passalong-capture.',
|
|
797
|
+
);
|
|
935
798
|
},
|
|
936
799
|
|
|
937
800
|
/**
|
|
@@ -940,7 +803,7 @@ const commands = {
|
|
|
940
803
|
* nothing at all when anything goes wrong — a hook must never get in the way of a session.
|
|
941
804
|
*/
|
|
942
805
|
async now(f) {
|
|
943
|
-
const { nowText, statusText,
|
|
806
|
+
const { nowText, statusText, stopDecision } = await import("../src/hooks.js");
|
|
944
807
|
// The status line: printed from a cache, refreshed behind it. Claude Code cancels a status
|
|
945
808
|
// line still running when the next update comes, so this never waits on the network.
|
|
946
809
|
if (f.statusline) return statusLine(f);
|
|
@@ -948,7 +811,7 @@ const commands = {
|
|
|
948
811
|
const cwd = typeof f["statusline-refresh"] === "string" ? f["statusline-refresh"] : process.cwd();
|
|
949
812
|
try {
|
|
950
813
|
const r = await passalong.now({ cwd });
|
|
951
|
-
writeStatus(cwd, statusText(
|
|
814
|
+
writeStatus(cwd, statusText(r));
|
|
952
815
|
} catch {}
|
|
953
816
|
return;
|
|
954
817
|
}
|
|
@@ -997,7 +860,7 @@ const commands = {
|
|
|
997
860
|
}),
|
|
998
861
|
);
|
|
999
862
|
} else if (f.hook === "stop") {
|
|
1000
|
-
const v =
|
|
863
|
+
const v = stopDecision({ held: r.held, input });
|
|
1001
864
|
if (v) console.log(JSON.stringify(v));
|
|
1002
865
|
}
|
|
1003
866
|
} catch {}
|
|
@@ -1020,7 +883,7 @@ const commands = {
|
|
|
1020
883
|
async help() {
|
|
1021
884
|
const src = readFileSync(fileURLToPath(import.meta.url), "utf8");
|
|
1022
885
|
const lines = src.split("\n").filter((l) => l.startsWith("// passalong "));
|
|
1023
|
-
console.log("passalong —
|
|
886
|
+
console.log("passalong — work for people and agents, and what they know\n");
|
|
1024
887
|
console.log(lines.map((l) => l.slice(3)).join("\n"));
|
|
1025
888
|
},
|
|
1026
889
|
};
|