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 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
- const nudge = r.mine && r.status === "published" && r.pulls >= 3 ? " ★ promote?" : "";
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
- console.log(`guides ${who.guides} synced (${who.limit} active on the free tier)`);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "passalong",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "Hand a finished implementation from one context to another, in a form an AI agent can act on.",
5
5
  "keywords": [
6
6
  "mcp",
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. `consumed` and `promoted` are legacy: they were an author
9
- * lifecycle laid over a transfer that already reports itself — `consumed` duplicated the verdict,
10
- * and `promoted` was a pull count with a name. Nothing sets them any more.
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
- * They stay in this list because they are *accepted*, not produced. The value lives in frontmatter
13
- * inside markdown files in other people's repositories, and `validate()` rejects a status it does
14
- * not know — so removing them here would make a guide shared a month ago fail to re-share today.
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("![") ? s : `![evidence](${s})`));
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
- 'author, and source_context are filled in. `to` addresses it to a team ("khaime") or a ' +
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. Needs sync (`passalong login`).",
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
- "Deprecated — prefer verify_guide. Marks a guide consumed or promoted, an author lifecycle " +
531
- "nothing reads any more: the board reports where a transfer is and the verdict reports " +
532
- "whether it worked. Kept so existing callers do not break. Only the author can promote.",
533
- inputSchema: { id: z.string(), status: z.enum(["published", "consumed", "promoted"]) },
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
- /** Move a guide through its lifecycle: published → consumed → promoted (any order allowed). */
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(", ")}`);