passalong 0.3.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/passalong +14 -16
- package/package.json +1 -1
- package/src/guide.js +67 -7
- package/src/mcp.js +35 -7
- package/src/passalong.js +6 -2
package/bin/passalong
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// Passalong CLI. Few flags on purpose: the defaults are the product.
|
|
3
3
|
//
|
|
4
|
-
// passalong share [file] [--to TARGET] publish a guide → id + link
|
|
4
|
+
// passalong share [file] [--to TARGET] [--follows ID] publish a guide → id + link
|
|
5
5
|
// TARGET is a team, team/@handle for one person, or "team/#group" for the people who do a
|
|
6
6
|
// thing — quote that one, since a shell may read the # as a comment
|
|
7
7
|
// passalong attach <image> upload evidence and print the markdown that points at it
|
|
@@ -19,7 +19,6 @@
|
|
|
19
19
|
// passalong works <id> you tried it and it holds up
|
|
20
20
|
// passalong broken <id> <why> you tried it and it does not — the author is told, with your reason
|
|
21
21
|
// passalong done <id> archive it: off your board and out of the free tier's count
|
|
22
|
-
// passalong promote <id> deprecated: mark promoted
|
|
23
22
|
// passalong rm <id> delete a guide locally and from sync
|
|
24
23
|
// passalong export [dir] dump every guide as plain markdown (default ./passalong-export)
|
|
25
24
|
// passalong login [token] create an account, or attach this machine with a token from your hub
|
|
@@ -93,8 +92,7 @@ function guideLine(r) {
|
|
|
93
92
|
const p = r.pulled_by[0];
|
|
94
93
|
pulled = ` ← ${p.handle ? `@${p.handle}` : "link"} ${relDate(p.at)}${r.pulled_by.length > 1 ? ` +${r.pulled_by.length - 1}` : ""}`;
|
|
95
94
|
}
|
|
96
|
-
|
|
97
|
-
return `${r.id} ${r.status.padEnd(9)} ${relDate(r.created).padEnd(10)} ${r.title}${who}${tags}${pulled}${nudge}`;
|
|
95
|
+
return `${r.id} ${r.status.padEnd(9)} ${relDate(r.created).padEnd(10)} ${r.title}${who}${tags}${pulled}`;
|
|
98
96
|
}
|
|
99
97
|
|
|
100
98
|
function activityLine(n) {
|
|
@@ -127,12 +125,14 @@ const commands = {
|
|
|
127
125
|
if (!existsSync(path)) die(`no such file: ${path}`);
|
|
128
126
|
if (tty && !f["no-edit"]) edit(path);
|
|
129
127
|
const to = typeof f.to === "string" ? f.to : undefined;
|
|
128
|
+
const follows = typeof f.follows === "string" ? f.follows : undefined;
|
|
130
129
|
const { guide, url, synced, notified, path: saved } = await passalong
|
|
131
|
-
.share(readFileSync(path, "utf8"), { to })
|
|
130
|
+
.share(readFileSync(path, "utf8"), { to, follows })
|
|
132
131
|
.catch((e) => die(e.message));
|
|
133
132
|
if (fromDrafts) store.removeDraft(path);
|
|
134
133
|
console.log(guide.meta.id);
|
|
135
134
|
console.error(`shared "${guide.meta.title}" → ${saved}`);
|
|
135
|
+
if (guide.meta.parent) console.error(`follows ${guide.meta.parent}`);
|
|
136
136
|
if (guide.meta.team)
|
|
137
137
|
console.error(
|
|
138
138
|
`handed to ${guide.meta.team}${guide.meta.to ? `/@${guide.meta.to}${notified ? " (emailed)" : " (in their inbox)"}` : " (team inbox)"}`,
|
|
@@ -334,16 +334,6 @@ const commands = {
|
|
|
334
334
|
console.error(`${g.meta.id} archived — off your board, and it no longer counts against sync`);
|
|
335
335
|
},
|
|
336
336
|
|
|
337
|
-
// Still on the way out: it sets an author lifecycle nothing reads, and the board reports pull
|
|
338
|
-
// counts on its own. It stays, and warns, because removing a command from a published package
|
|
339
|
-
// is a breaking change.
|
|
340
|
-
async promote(f) {
|
|
341
|
-
const id = f._[0] || die("usage: passalong promote <id>");
|
|
342
|
-
const g = await passalong.setStatus(id, "promoted").catch((e) => die(e.message));
|
|
343
|
-
console.error(`${g.meta.id} marked promoted`);
|
|
344
|
-
console.error("note: `promote` is deprecated — the board reports pull counts on their own");
|
|
345
|
-
},
|
|
346
|
-
|
|
347
337
|
async rm(f) {
|
|
348
338
|
const id = f._[0] || die("usage: passalong rm <id>");
|
|
349
339
|
await passalong.remove(id).catch((e) => die(e.message));
|
|
@@ -392,7 +382,15 @@ const commands = {
|
|
|
392
382
|
console.log(`handle ${who.handle ? `@${who.handle}` : "(none — set one: passalong me --handle <you>)"}`);
|
|
393
383
|
if (who.name) console.log(`name ${who.name}`);
|
|
394
384
|
if (who.email) console.log(`email ${who.email}`);
|
|
395
|
-
|
|
385
|
+
// The server sends the state by name, because a number cannot tell "no ceiling" apart from
|
|
386
|
+
// "may sync nothing" — both of which arrive as a falsy limit.
|
|
387
|
+
const ceiling =
|
|
388
|
+
who.sync === "free"
|
|
389
|
+
? ` (${who.limit} active on your plan)`
|
|
390
|
+
: who.sync === "none"
|
|
391
|
+
? " (no plan — nothing is syncing)"
|
|
392
|
+
: " (no limit on your plan)";
|
|
393
|
+
console.log(`guides ${who.guides} synced${ceiling}`);
|
|
396
394
|
const current = store.readConfig().team;
|
|
397
395
|
console.log(
|
|
398
396
|
`teams ${who.teams.length ? who.teams.map((t) => `${t.slug}${t.slug === current ? "*" : ""} (${t.role})`).join(", ") : "(none — passalong team create <name>)"}`,
|
package/package.json
CHANGED
package/src/guide.js
CHANGED
|
@@ -5,16 +5,30 @@
|
|
|
5
5
|
import { randomBytes } from "node:crypto";
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
|
-
* A guide is a draft or it is published
|
|
9
|
-
*
|
|
10
|
-
*
|
|
8
|
+
* A guide is a draft or it is published, and `consumed` is the author's shelf — off the board, out
|
|
9
|
+
* of the free tier's count, reversible. `promoted` is the one true legacy: an author lifecycle laid
|
|
10
|
+
* over a transfer that already reports itself, since a pull count says how travelled a guide is
|
|
11
|
+
* without anyone having to grade it.
|
|
11
12
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
13
|
+
* This list is what is **accepted**, and it is not what may be **set** — that is `SETTABLE` below.
|
|
14
|
+
* The distinction is load-bearing. A status lives in frontmatter, inside markdown files in other
|
|
15
|
+
* people's repositories, and `validate()` rejects a status it does not know, so dropping a value
|
|
16
|
+
* from here would make a guide shared a month ago fail to re-share today.
|
|
15
17
|
*/
|
|
16
18
|
export const STATUSES = ["draft", "published", "consumed", "promoted"];
|
|
17
19
|
|
|
20
|
+
/**
|
|
21
|
+
* What a write may choose. `promoted` is absent: it cannot be set on a guide that does not already
|
|
22
|
+
* carry it, from any surface.
|
|
23
|
+
*
|
|
24
|
+
* Two reasons, and the second is the sharper one. A status that can still be set reads as a
|
|
25
|
+
* feature to whoever meets it next, however many deprecation notices sit around it. And
|
|
26
|
+
* `quota.ts` counts `published` and `promoted` as the statuses that occupy room — so while
|
|
27
|
+
* `promoted` remains settable, promoting every guide is an unlimited free tier. Closing the write
|
|
28
|
+
* closes that, and does it without touching what the free tier counts, which still has to include
|
|
29
|
+
* the guides already carrying the status.
|
|
30
|
+
*/
|
|
31
|
+
export const SETTABLE = ["draft", "published", "consumed"];
|
|
18
32
|
/**
|
|
19
33
|
* What a guide is for. The receiving agent behaves completely differently depending on the
|
|
20
34
|
* answer, so it is a field rather than something inferred from the body.
|
|
@@ -291,6 +305,24 @@ export function sections(body) {
|
|
|
291
305
|
return out;
|
|
292
306
|
}
|
|
293
307
|
|
|
308
|
+
/**
|
|
309
|
+
* Image targets no reader will ever load. Mirrors `unreachableImages` in apps/api/src/guide.ts,
|
|
310
|
+
* which is the authority and refuses the publish; this lets a local share fail before the network.
|
|
311
|
+
*
|
|
312
|
+
* Only a foreign scheme counts — `attachment://`, `blob:`, `file:`, `data:`. `https:` is left alone
|
|
313
|
+
* because embedding an image you host is legitimate, and a relative path is left alone because it
|
|
314
|
+
* has always been accepted.
|
|
315
|
+
*/
|
|
316
|
+
export function unreachableImages(markdown) {
|
|
317
|
+
const bad = [...String(markdown).matchAll(/!\[[^\]]*\]\(\s*([^)\s]+)/g)]
|
|
318
|
+
.map((m) => m[1].trim())
|
|
319
|
+
.filter((target) => {
|
|
320
|
+
const scheme = /^([a-z][a-z0-9+.\-]*):/i.exec(target);
|
|
321
|
+
return Boolean(scheme) && !/^https?$/i.test(scheme[1]);
|
|
322
|
+
});
|
|
323
|
+
return [...new Set(bad)];
|
|
324
|
+
}
|
|
325
|
+
|
|
294
326
|
/** Problems that make a guide unfit to publish. Empty array means it is fine. */
|
|
295
327
|
export function validate({ meta, body }) {
|
|
296
328
|
const errors = [];
|
|
@@ -321,6 +353,12 @@ export function validate({ meta, body }) {
|
|
|
321
353
|
errors.push('a bug uses "## Reproduce", not "## Steps" — an agent executes Steps');
|
|
322
354
|
}
|
|
323
355
|
if (body.includes("<!-- passalong:")) errors.push("template placeholders are still in the body");
|
|
356
|
+
// An agent that has a file id but never called attach_screenshot writes the id as if it were a
|
|
357
|
+
// link. It publishes, and stores a guide whose screenshot was never uploaded anywhere.
|
|
358
|
+
for (const target of unreachableImages(body))
|
|
359
|
+
errors.push(
|
|
360
|
+
`${target} is not a URL a reader can load — attach the image first and use the URL it returns`,
|
|
361
|
+
);
|
|
324
362
|
return errors;
|
|
325
363
|
}
|
|
326
364
|
|
|
@@ -354,7 +392,26 @@ export function bugGuide({
|
|
|
354
392
|
report = "",
|
|
355
393
|
environment = "",
|
|
356
394
|
status = "published",
|
|
395
|
+
evidence = [],
|
|
357
396
|
} = {}) {
|
|
397
|
+
/**
|
|
398
|
+
* Screenshots, as markdown inside the document.
|
|
399
|
+
*
|
|
400
|
+
* They go under Problem rather than a heading of their own, because for a visual defect the
|
|
401
|
+
* picture *is* the problem statement and a bug's sections are a fixed set — a seventh heading
|
|
402
|
+
* would change the shape every reader keys on to add a line that already belongs in the first
|
|
403
|
+
* one.
|
|
404
|
+
*
|
|
405
|
+
* Naming them in the body is also the only thing that binds them: `claimShots` claims whatever
|
|
406
|
+
* the markdown points at, scoped to the author, so a URL listed anywhere else would upload
|
|
407
|
+
* evidence that no guide owns and the nightly sweep deletes.
|
|
408
|
+
*/
|
|
409
|
+
const shots = (Array.isArray(evidence) ? evidence : [evidence])
|
|
410
|
+
.map((s) => String(s ?? "").trim())
|
|
411
|
+
.filter(Boolean)
|
|
412
|
+
// A caller that already has the markdown line from `attach_screenshot` passes it through
|
|
413
|
+
// whole; one holding just the URL gets it wrapped. Both reach the same document.
|
|
414
|
+
.map((s) => (s.startsWith("`));
|
|
358
415
|
const body = [
|
|
359
416
|
// Written into the document, not added by whatever served it.
|
|
360
417
|
//
|
|
@@ -367,10 +424,13 @@ export function bugGuide({
|
|
|
367
424
|
"",
|
|
368
425
|
"## Problem",
|
|
369
426
|
problem.trim() || "_No description given._",
|
|
427
|
+
];
|
|
428
|
+
if (shots.length) body.push("", ...shots);
|
|
429
|
+
body.push(
|
|
370
430
|
"",
|
|
371
431
|
"## Reproduce",
|
|
372
432
|
reproduce.trim() || "_Not recorded — the description above is what there is._",
|
|
373
|
-
|
|
433
|
+
);
|
|
374
434
|
// Both are optional and both are worse than absent when empty: a Verification heading with
|
|
375
435
|
// nothing under it says nobody knows what fixed looks like, on the section a fixer reads first.
|
|
376
436
|
if (verification.trim()) body.push("", "## Verification", verification.trim());
|
package/src/mcp.js
CHANGED
|
@@ -81,6 +81,11 @@ export async function serve() {
|
|
|
81
81
|
"because you reproduced it. " +
|
|
82
82
|
"When you find defects you are not fixing — a test run, a QA pass, a review — call " +
|
|
83
83
|
"file_bugs with all of them at once; each becomes a guide someone can take on its own. " +
|
|
84
|
+
"AN IMAGE THE USER SHOWED YOU IS EVIDENCE, NOT CONTEXT. Before filing or publishing, " +
|
|
85
|
+
"attach it with attach_screenshot and pass what it returns as `evidence` — a screenshot " +
|
|
86
|
+
"you described instead of attaching is the most useful thing in the report, thrown away. " +
|
|
87
|
+
"A guide already filed without one is not stuck: get_guide it, add the markdown line to " +
|
|
88
|
+
"the body, and publish_guide the same id — publishing claims whatever the markdown names. " +
|
|
84
89
|
"When the user asks to pass along, hand off, or " +
|
|
85
90
|
"share what was just done, distill the session into a guide (guide_template shows the " +
|
|
86
91
|
"shape) and call publish_guide, with `to` as team, team/@handle for one teammate, or " +
|
|
@@ -394,7 +399,10 @@ export async function serve() {
|
|
|
394
399
|
description:
|
|
395
400
|
"Publish a guide from markdown (frontmatter + sections) — a transfer guide by default, or " +
|
|
396
401
|
"a single bug with `kind: bug`; use file_bugs for more than one. Missing id, created, " +
|
|
397
|
-
|
|
402
|
+
"author, and source_context are filled in. A screenshot belongs in the markdown: attach it " +
|
|
403
|
+
"with attach_screenshot and put the line it returns in the body, because publishing claims " +
|
|
404
|
+
"whatever the markdown names. " +
|
|
405
|
+
'`to` addresses it to a team ("khaime") or a ' +
|
|
398
406
|
'teammate ("khaime/lukman"), who is notified. Returns the id and share link.',
|
|
399
407
|
inputSchema: {
|
|
400
408
|
markdown: z.string().describe("full guide markdown; start from guide_template"),
|
|
@@ -406,13 +414,21 @@ export async function serve() {
|
|
|
406
414
|
.string()
|
|
407
415
|
.optional()
|
|
408
416
|
.describe("directory the work happened in, used to infer source_context"),
|
|
417
|
+
parent: z
|
|
418
|
+
.string()
|
|
419
|
+
.optional()
|
|
420
|
+
.describe(
|
|
421
|
+
"id or share link of the guide this one came out of — set it when this is what you " +
|
|
422
|
+
"learned doing someone else's guide, so theirs lists it as a follow-up",
|
|
423
|
+
),
|
|
409
424
|
},
|
|
410
425
|
},
|
|
411
|
-
async ({ markdown, to, cwd }) => {
|
|
426
|
+
async ({ markdown, to, cwd, parent }) => {
|
|
412
427
|
try {
|
|
413
428
|
const { guide, url, synced, notified, path } = await passalong.share(markdown, {
|
|
414
429
|
cwd: cwd || process.cwd(),
|
|
415
430
|
to,
|
|
431
|
+
follows: parent,
|
|
416
432
|
});
|
|
417
433
|
return json({
|
|
418
434
|
id: guide.meta.id,
|
|
@@ -421,6 +437,7 @@ export async function serve() {
|
|
|
421
437
|
synced,
|
|
422
438
|
team: guide.meta.team || "",
|
|
423
439
|
to: guide.meta.to || "",
|
|
440
|
+
parent: guide.meta.parent || "",
|
|
424
441
|
notified,
|
|
425
442
|
path,
|
|
426
443
|
});
|
|
@@ -439,7 +456,10 @@ export async function serve() {
|
|
|
439
456
|
"publishes each issue as its own guide — its own id, share link, and verdict — so a " +
|
|
440
457
|
"reviewer can hand any one of them to whoever fixes it. Use this after a test run, a QA " +
|
|
441
458
|
"pass, or a review that turned up defects; use publish_guide instead for work you " +
|
|
442
|
-
"finished and want repeated elsewhere.
|
|
459
|
+
"finished and want repeated elsewhere. If there is a screenshot of any of this, it is " +
|
|
460
|
+
"evidence: call attach_screenshot first and pass what it returns as that issue's " +
|
|
461
|
+
"`evidence`. Describing a screenshot instead of attaching it throws away the most useful " +
|
|
462
|
+
"thing in the report. Needs sync (`passalong login`).",
|
|
443
463
|
inputSchema: {
|
|
444
464
|
title: z
|
|
445
465
|
.string()
|
|
@@ -456,6 +476,13 @@ export async function serve() {
|
|
|
456
476
|
issues: z
|
|
457
477
|
.array(
|
|
458
478
|
z.object({
|
|
479
|
+
evidence: z
|
|
480
|
+
.array(z.string())
|
|
481
|
+
.default([])
|
|
482
|
+
.describe(
|
|
483
|
+
"screenshot URLs from attach_screenshot, or the markdown lines it returned; " +
|
|
484
|
+
"they go under Problem, where a reader looks first",
|
|
485
|
+
),
|
|
459
486
|
title: z.string().describe("what is broken, in one line"),
|
|
460
487
|
problem: z
|
|
461
488
|
.string()
|
|
@@ -527,10 +554,11 @@ export async function serve() {
|
|
|
527
554
|
{
|
|
528
555
|
title: "Set guide status",
|
|
529
556
|
description:
|
|
530
|
-
"
|
|
531
|
-
"
|
|
532
|
-
"
|
|
533
|
-
|
|
557
|
+
"Archive a guide (`consumed`) or put it back on the board (`published`). Archiving is the " +
|
|
558
|
+
"author's shelf: off the board, out of the free tier's count, reversible — it is not a " +
|
|
559
|
+
"judgement that the work landed, which is what verify_guide reports. `promoted` was " +
|
|
560
|
+
"retired and the server refuses it.",
|
|
561
|
+
inputSchema: { id: z.string(), status: z.enum(["published", "consumed"]) },
|
|
534
562
|
},
|
|
535
563
|
async ({ id, status }) => {
|
|
536
564
|
try {
|
package/src/passalong.js
CHANGED
|
@@ -37,7 +37,7 @@ export function parseTarget(to) {
|
|
|
37
37
|
* logged in. `to` addresses it: "team", "team/@handle", or "team/#group". Returns
|
|
38
38
|
* { guide, path, url, synced }.
|
|
39
39
|
*/
|
|
40
|
-
export async function share(markdown, { cwd = process.cwd(), to } = {}) {
|
|
40
|
+
export async function share(markdown, { cwd = process.cwd(), to, follows } = {}) {
|
|
41
41
|
const c = context(cwd);
|
|
42
42
|
const raw = parse(markdown);
|
|
43
43
|
let guide = stamp(
|
|
@@ -50,6 +50,10 @@ export async function share(markdown, { cwd = process.cwd(), to } = {}) {
|
|
|
50
50
|
const target = parseTarget(to);
|
|
51
51
|
if (target.team) guide.meta.team = target.team;
|
|
52
52
|
if (target.to) guide.meta.to = target.to;
|
|
53
|
+
// `follows` names the guide this one came out of: an id, a share link, or a pulled .md file. An id
|
|
54
|
+
// is used as it is; a link or a file is read for the id in its own frontmatter. `validate()` below
|
|
55
|
+
// is what refuses a guide that names itself.
|
|
56
|
+
if (follows) guide.meta.parent = await resolveId(String(follows).trim());
|
|
53
57
|
if (guide.meta.to && !guide.meta.team)
|
|
54
58
|
throw new PassalongError("`to:` needs a team — address a handoff as team/handle");
|
|
55
59
|
const errors = validate(guide);
|
|
@@ -390,7 +394,7 @@ export function summary(g) {
|
|
|
390
394
|
};
|
|
391
395
|
}
|
|
392
396
|
|
|
393
|
-
/**
|
|
397
|
+
/** Archive a guide (`consumed`) or put it back (`published`). The server refuses `promoted`. */
|
|
394
398
|
export async function setStatus(id, status) {
|
|
395
399
|
if (!STATUSES.includes(status))
|
|
396
400
|
throw new PassalongError(`status must be one of ${STATUSES.join(", ")}`);
|