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 +13 -15
- package/bin/passalong +54 -140
- package/package.json +1 -1
- package/skill/SKILL.md +7 -7
- package/src/api.js +16 -1
- package/src/guide.js +2 -1
- package/src/mcp.js +83 -37
- package/src/passalong.js +116 -182
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
|
|
32
|
-
passalong pull <id|link>
|
|
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'
|
|
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
|
|
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
|
|
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
|
-
|
|
78
|
-
`passalong
|
|
79
|
-
|
|
80
|
-
|
|
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
|
|
103
|
+
## Guides are plain markdown
|
|
106
104
|
|
|
107
|
-
Plain markdown with frontmatter
|
|
108
|
-
|
|
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>
|
|
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
|
|
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>
|
|
19
|
-
// passalong start <id|link>
|
|
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'
|
|
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>
|
|
28
|
-
// passalong pass <id> <why> it is not yours — hands it back
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
180
|
-
async function
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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,
|
|
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}"
|
|
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
|
-
|
|
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
|
|
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
|
|
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(`\
|
|
429
|
+
if (path) console.error(`\nsaved to ${path}`);
|
|
486
430
|
await listFollowUps(guide.meta);
|
|
487
431
|
},
|
|
488
432
|
|
|
489
433
|
/**
|
|
490
|
-
*
|
|
491
|
-
*
|
|
492
|
-
*
|
|
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
|
|
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(`\
|
|
499
|
-
|
|
500
|
-
|
|
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
|
-
|
|
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
|
-
|
|
667
|
-
console.error(`${
|
|
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
|
-
|
|
815
|
-
if (
|
|
816
|
-
|
|
817
|
-
|
|
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
|
|
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
|
-
//
|
|
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("
|
|
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
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.
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
`passalong
|
|
73
|
-
Passalong MCP server. `
|
|
74
|
-
fetching it, and until somebody
|
|
75
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
304
|
-
"
|
|
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:
|
|
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
|
-
"
|
|
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
|
|
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.
|
|
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,
|
|
1359
|
+
async ({ ref, section, outline, full }) => {
|
|
1318
1360
|
try {
|
|
1319
|
-
const { markdown,
|
|
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
|
|
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
|
|
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
|
|
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}
|
|
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
|
|
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
|
-
//
|
|
1537
|
-
// the user something untrue. Said in the result so it is said to them.
|
|
1538
|
-
...(guide.meta.
|
|
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(
|
|
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
|
-
//
|
|
82
|
-
// says it is ready
|
|
83
|
-
// through here too, so publishing on share would let
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
/**
|
|
155
|
-
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
|
205
|
-
*
|
|
206
|
-
*
|
|
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(),
|
|
196
|
+
export async function pull(ref, { cwd = process.cwd(), save = false } = {}) {
|
|
209
197
|
const { guide, from } = await resolve(ref);
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
617
|
-
const
|
|
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("
|
|
624
|
-
if (!String(why || "").trim())
|
|
625
|
-
|
|
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(),
|
|
666
|
-
const
|
|
667
|
-
|
|
668
|
-
|
|
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
|
-
/**
|
|
953
|
-
export async function list(query = "", {
|
|
954
|
-
|
|
955
|
-
const
|
|
956
|
-
|
|
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
|
-
|
|
1096
|
-
store.save(next);
|
|
1000
|
+
await api.setStatus(id, status);
|
|
1097
1001
|
return next;
|
|
1098
1002
|
}
|
|
1099
1003
|
|
|
1100
1004
|
/**
|
|
1101
|
-
* Move a
|
|
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
|
|
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
|
|
1237
|
-
* planner has written several and you have read them all. Only your own:
|
|
1238
|
-
*
|
|
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
|
|
1242
|
-
for (const
|
|
1243
|
-
return drafts.map((
|
|
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
|
-
/**
|
|
1259
|
-
|
|
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
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
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
|
-
|
|
1200
|
+
store.writeConfig({ tidied: true });
|
|
1201
|
+
return removed;
|
|
1268
1202
|
}
|