passalong 0.3.0 → 0.3.2

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 CHANGED
@@ -48,6 +48,7 @@ passalong team join <link> accept an invite
48
48
  passalong team use <slug> switch the current team
49
49
  passalong setup install the Claude Code capture skill + register the MCP server
50
50
  passalong mcp run the MCP server over stdio
51
+ passalong version print the installed version (also -v, --version)
51
52
  ```
52
53
 
53
54
  ## Teams
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
@@ -31,6 +30,7 @@
31
30
  // passalong team use <slug> switch the current team
32
31
  // passalong setup install the Claude Code capture skill + register the MCP server
33
32
  // passalong mcp run the MCP server over stdio
33
+ // passalong version print the installed version (also -v, --version)
34
34
  import { execFileSync, spawnSync } from "node:child_process";
35
35
  import { cpSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
36
36
  import { homedir } from "node:os";
@@ -93,8 +93,7 @@ function guideLine(r) {
93
93
  const p = r.pulled_by[0];
94
94
  pulled = ` ← ${p.handle ? `@${p.handle}` : "link"} ${relDate(p.at)}${r.pulled_by.length > 1 ? ` +${r.pulled_by.length - 1}` : ""}`;
95
95
  }
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}`;
96
+ return `${r.id} ${r.status.padEnd(9)} ${relDate(r.created).padEnd(10)} ${r.title}${who}${tags}${pulled}`;
98
97
  }
99
98
 
100
99
  function activityLine(n) {
@@ -127,12 +126,14 @@ const commands = {
127
126
  if (!existsSync(path)) die(`no such file: ${path}`);
128
127
  if (tty && !f["no-edit"]) edit(path);
129
128
  const to = typeof f.to === "string" ? f.to : undefined;
129
+ const follows = typeof f.follows === "string" ? f.follows : undefined;
130
130
  const { guide, url, synced, notified, path: saved } = await passalong
131
- .share(readFileSync(path, "utf8"), { to })
131
+ .share(readFileSync(path, "utf8"), { to, follows })
132
132
  .catch((e) => die(e.message));
133
133
  if (fromDrafts) store.removeDraft(path);
134
134
  console.log(guide.meta.id);
135
135
  console.error(`shared "${guide.meta.title}" → ${saved}`);
136
+ if (guide.meta.parent) console.error(`follows ${guide.meta.parent}`);
136
137
  if (guide.meta.team)
137
138
  console.error(
138
139
  `handed to ${guide.meta.team}${guide.meta.to ? `/@${guide.meta.to}${notified ? " (emailed)" : " (in their inbox)"}` : " (team inbox)"}`,
@@ -334,16 +335,6 @@ const commands = {
334
335
  console.error(`${g.meta.id} archived — off your board, and it no longer counts against sync`);
335
336
  },
336
337
 
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
338
  async rm(f) {
348
339
  const id = f._[0] || die("usage: passalong rm <id>");
349
340
  await passalong.remove(id).catch((e) => die(e.message));
@@ -392,7 +383,15 @@ const commands = {
392
383
  console.log(`handle ${who.handle ? `@${who.handle}` : "(none — set one: passalong me --handle <you>)"}`);
393
384
  if (who.name) console.log(`name ${who.name}`);
394
385
  if (who.email) console.log(`email ${who.email}`);
395
- console.log(`guides ${who.guides} synced (${who.limit} active on the free tier)`);
386
+ // The server sends the state by name, because a number cannot tell "no ceiling" apart from
387
+ // "may sync nothing" — both of which arrive as a falsy limit.
388
+ const ceiling =
389
+ who.sync === "free"
390
+ ? ` (${who.limit} active on your plan)`
391
+ : who.sync === "none"
392
+ ? " (no plan — nothing is syncing)"
393
+ : " (no limit on your plan)";
394
+ console.log(`guides ${who.guides} synced${ceiling}`);
396
395
  const current = store.readConfig().team;
397
396
  console.log(
398
397
  `teams ${who.teams.length ? who.teams.map((t) => `${t.slug}${t.slug === current ? "*" : ""} (${t.role})`).join(", ") : "(none — passalong team create <name>)"}`,
@@ -492,5 +491,11 @@ const commands = {
492
491
 
493
492
  const argv = flags(process.argv.slice(2));
494
493
  const cmd = argv._.shift() || "help";
495
- if (cmd === "--help" || cmd === "-h" || !commands[cmd]) commands.help();
494
+ // The first thing anyone runs when something is off, and until now it printed the help page. The
495
+ // parser only knows `--x`, so `--version` arrives as a flag on an empty command and `-v` as a word;
496
+ // both are caught here, with `version`, before dispatch. Only the number goes to stdout, so
497
+ // `passalong -v` can be read by a script comparing it against the registry.
498
+ if (cmd === "-v" || cmd === "version" || (cmd === "help" && argv.version === true)) {
499
+ console.log(JSON.parse(readFileSync(join(PKG, "package.json"), "utf8")).version);
500
+ } else if (cmd === "--help" || cmd === "-h" || !commands[cmd]) commands.help();
496
501
  else await commands[cmd](argv).catch((e) => die(e.message));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "passalong",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
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/skill/SKILL.md CHANGED
@@ -33,6 +33,13 @@ session's history. Write for execution, not for permanence.
33
33
  team"), add `--to <team>` or `--to <team>/<handle>`; `passalong team` lists the current
34
34
  team's members and their handles. It prints the guide id on stdout and the share link (and
35
35
  who it was handed to) on stderr.
36
+
37
+ **If this session started from a passalong guide** — you ran `passalong start` or `pull` on an
38
+ id, or an agent opened one — and what you are capturing is what doing it taught you, add
39
+ `--follows <that id>`. The original then lists this one as a follow-up. That is the case when
40
+ you departed from it: changed or skipped a Step, adapted an `ASSUMES` for this stack, found the
41
+ fix a failing guide did not have, or hit a Gotcha it does not list. If it worked exactly as
42
+ written there is nothing to capture — say so with `passalong works <id>` instead.
36
43
  6. **Report** the id (and link if synced) and one line on how to use it on the other side:
37
44
  `passalong start <id>` in the target repo, or "start passalong <id>" to an agent with the
38
45
  Passalong MCP server. `start` rather than `pull` on purpose: it takes the handoff as well as
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 " +
@@ -90,7 +95,13 @@ export async function serve() {
90
95
  "shows whether the guides they handed off have landed. When the user asks what they have " +
91
96
  "been working on, or wants a standup or a summary of a period, call log — but say that it " +
92
97
  "holds what they passed along and not everything they did. Gotchas are " +
93
- "the highest-value section: record what failed and why.",
98
+ "the highest-value section: record what failed and why.\n" +
99
+ "A FOLLOW-UP IS A GUIDE, NOT A NOTE. When you worked from a guide and departed from it — " +
100
+ "changed or skipped a Step, adapted an ASSUMES for this stack, found the fix a failing " +
101
+ "guide did not have, or hit a Gotcha it does not list — publish what you learned as its " +
102
+ "own guide with publish_guide `parent` set to that guide's id; the original then lists it " +
103
+ "as a follow-up. If it worked exactly as written, do not: answer with verify_guide, or " +
104
+ "every guide collects copies that say nothing new.",
94
105
  },
95
106
  );
96
107
 
@@ -218,7 +229,10 @@ export async function serve() {
218
229
  description:
219
230
  "After following a guide's Verification section, report the result. This is the only way " +
220
231
  "the author learns their handoff did not land — `set_guide_status consumed` says it was " +
221
- "implemented, this says it actually works. A failing verdict must say what went wrong.",
232
+ "implemented, this says it actually works. A failing verdict must say what went wrong. " +
233
+ "If doing it taught you something the guide did not say — a flag it needed, a step that " +
234
+ "was wrong for this stack — publish that as its own guide with publish_guide `parent` set " +
235
+ "to this id. The author sees it as a follow-up; a verdict note is one line and gets lost.",
222
236
  inputSchema: {
223
237
  id: z.string().describe("passalong id"),
224
238
  ok: z.boolean().describe("true if the Verification steps passed"),
@@ -302,7 +316,8 @@ export async function serve() {
302
316
  const siblings = await related(meta);
303
317
  return text(
304
318
  `${lead}${r.markdown}${siblings}` +
305
- `\n\n<!-- passalong: ${r.from}; written to ${r.path}; ${took} -->`,
319
+ `\n\n<!-- passalong: ${r.from}; written to ${r.path}; ${took} -->` +
320
+ `\n${passalong.followUpNote(meta)}`,
306
321
  );
307
322
  } catch (err) {
308
323
  return fail(err);
@@ -351,7 +366,8 @@ export async function serve() {
351
366
  return text(
352
367
  `${lead}${markdown}${siblings}` +
353
368
  `\n\n<!-- passalong: ${from}; written to ${path} -->` +
354
- `\n${passalong.handoffNudge(meta)}`,
369
+ `\n${passalong.handoffNudge(meta)}` +
370
+ `\n${passalong.followUpNote(meta)}`,
355
371
  );
356
372
  } catch (err) {
357
373
  return fail(err);
@@ -394,7 +410,10 @@ export async function serve() {
394
410
  description:
395
411
  "Publish a guide from markdown (frontmatter + sections) — a transfer guide by default, or " +
396
412
  "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 ' +
413
+ "author, and source_context are filled in. A screenshot belongs in the markdown: attach it " +
414
+ "with attach_screenshot and put the line it returns in the body, because publishing claims " +
415
+ "whatever the markdown names. " +
416
+ '`to` addresses it to a team ("khaime") or a ' +
398
417
  'teammate ("khaime/lukman"), who is notified. Returns the id and share link.',
399
418
  inputSchema: {
400
419
  markdown: z.string().describe("full guide markdown; start from guide_template"),
@@ -406,13 +425,21 @@ export async function serve() {
406
425
  .string()
407
426
  .optional()
408
427
  .describe("directory the work happened in, used to infer source_context"),
428
+ parent: z
429
+ .string()
430
+ .optional()
431
+ .describe(
432
+ "id or share link of the guide this one came out of — set it when this is what you " +
433
+ "learned doing someone else's guide, so theirs lists it as a follow-up",
434
+ ),
409
435
  },
410
436
  },
411
- async ({ markdown, to, cwd }) => {
437
+ async ({ markdown, to, cwd, parent }) => {
412
438
  try {
413
439
  const { guide, url, synced, notified, path } = await passalong.share(markdown, {
414
440
  cwd: cwd || process.cwd(),
415
441
  to,
442
+ follows: parent,
416
443
  });
417
444
  return json({
418
445
  id: guide.meta.id,
@@ -421,6 +448,7 @@ export async function serve() {
421
448
  synced,
422
449
  team: guide.meta.team || "",
423
450
  to: guide.meta.to || "",
451
+ parent: guide.meta.parent || "",
424
452
  notified,
425
453
  path,
426
454
  });
@@ -439,7 +467,10 @@ export async function serve() {
439
467
  "publishes each issue as its own guide — its own id, share link, and verdict — so a " +
440
468
  "reviewer can hand any one of them to whoever fixes it. Use this after a test run, a QA " +
441
469
  "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`).",
470
+ "finished and want repeated elsewhere. If there is a screenshot of any of this, it is " +
471
+ "evidence: call attach_screenshot first and pass what it returns as that issue's " +
472
+ "`evidence`. Describing a screenshot instead of attaching it throws away the most useful " +
473
+ "thing in the report. Needs sync (`passalong login`).",
443
474
  inputSchema: {
444
475
  title: z
445
476
  .string()
@@ -456,6 +487,13 @@ export async function serve() {
456
487
  issues: z
457
488
  .array(
458
489
  z.object({
490
+ evidence: z
491
+ .array(z.string())
492
+ .default([])
493
+ .describe(
494
+ "screenshot URLs from attach_screenshot, or the markdown lines it returned; " +
495
+ "they go under Problem, where a reader looks first",
496
+ ),
459
497
  title: z.string().describe("what is broken, in one line"),
460
498
  problem: z
461
499
  .string()
@@ -527,10 +565,11 @@ export async function serve() {
527
565
  {
528
566
  title: "Set guide status",
529
567
  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"]) },
568
+ "Archive a guide (`consumed`) or put it back on the board (`published`). Archiving is the " +
569
+ "author's shelf: off the board, out of the free tier's count, reversible — it is not a " +
570
+ "judgement that the work landed, which is what verify_guide reports. `promoted` was " +
571
+ "retired and the server refuses it.",
572
+ inputSchema: { id: z.string(), status: z.enum(["published", "consumed"]) },
534
573
  },
535
574
  async ({ id, status }) => {
536
575
  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);
@@ -292,6 +296,33 @@ export async function attach(file, { name = "" } = {}) {
292
296
  return { ...shot, markdown: `![${name || basename(file)}](${shot.url})` };
293
297
  }
294
298
 
299
+ /**
300
+ * The line a guide arrives with, saying when what you do next deserves to be a guide of its own.
301
+ *
302
+ * Trailing, like the handoff nudge, so it never sits in front of the frontmatter. It is here and
303
+ * not only in the server's instructions because an agent reads the payload it is working from and
304
+ * skims everything else — the same reason the bug lead is inside the document.
305
+ *
306
+ * It says when *not* to as well. A follow-up for a guide that worked exactly as written is a copy
307
+ * that says nothing new, and a guide collecting those is worse than one with none; that answer is a
308
+ * verdict. For a bug the question is different — nothing to depart from, but a fix worth repeating
309
+ * elsewhere is a transfer guide, and the bug is where it came from.
310
+ */
311
+ export function followUpNote(meta = {}) {
312
+ if (!meta.id) return "";
313
+ if (meta.kind === "bug")
314
+ return (
315
+ "<!-- passalong: once this is fixed, if the fix is worth repeating somewhere else, publish " +
316
+ `it as a transfer guide with publish_guide parent=${meta.id} so this bug lists it. -->`
317
+ );
318
+ return (
319
+ "<!-- passalong: if you depart from this guide — change or skip a Step, adapt an ASSUMES for " +
320
+ "this stack, find a fix it did not have, hit a Gotcha it does not list — publish what you " +
321
+ `learned as its own guide with publish_guide parent=${meta.id}. If it worked exactly as ` +
322
+ "written, answer with verify_guide instead. -->"
323
+ );
324
+ }
325
+
295
326
  /** Local guides merged with synced ones (by id), newest first, optionally filtered. */
296
327
  export async function list(query = "", { remote = true, scope = "" } = {}) {
297
328
  const local = scope && scope !== "all" && scope !== "mine" ? [] : store.search(query);
@@ -390,7 +421,7 @@ export function summary(g) {
390
421
  };
391
422
  }
392
423
 
393
- /** Move a guide through its lifecycle: published → consumed → promoted (any order allowed). */
424
+ /** Archive a guide (`consumed`) or put it back (`published`). The server refuses `promoted`. */
394
425
  export async function setStatus(id, status) {
395
426
  if (!STATUSES.includes(status))
396
427
  throw new PassalongError(`status must be one of ${STATUSES.join(", ")}`);