passalong 0.2.2 → 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/src/mcp.js CHANGED
@@ -3,31 +3,98 @@
3
3
  // team, the same tools see the team's guides and the user's inbox.
4
4
  //
5
5
  // claude mcp add passalong -- passalong mcp
6
+ import { readFileSync } from "node:fs";
6
7
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
7
8
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
8
9
  import { z } from "zod";
9
- import { SECTIONS, template } from "./guide.js";
10
+ import * as api from "./api.js";
11
+ import { AREAS, BUG_SECTIONS, parse, SECTIONS, template } from "./guide.js";
10
12
  import * as passalong from "./passalong.js";
11
13
 
14
+ /**
15
+ * What the server calls itself in the MCP handshake — read from package.json rather than written
16
+ * here, because a literal drifts. This one said 0.2.0 for two releases while the package was
17
+ * 0.2.2, and the one thing a version string in a handshake is for is telling a client which build
18
+ * it is talking to. `pnpm release` bumps package.json, so there is only one number to bump.
19
+ */
20
+ const VERSION = JSON.parse(
21
+ readFileSync(new URL("../package.json", import.meta.url), "utf8"),
22
+ ).version;
23
+
12
24
  const text = (s) => ({ content: [{ type: "text", text: s }] });
25
+
26
+ /**
27
+ * The rest of the report this issue came from.
28
+ *
29
+ * A QA pass files six bugs at once, and they arrive in an inbox as six unrelated guides — which
30
+ * is how the same root cause gets fixed three times, or how someone fixes one of a pair and calls
31
+ * the sweep done. Naming the siblings costs one request and only happens for a guide that has a
32
+ * parent. A failure here is not worth failing the pull over: the issue itself is what was asked
33
+ * for, and it is already in hand.
34
+ */
35
+ async function related(meta) {
36
+ if (!meta.report) return "";
37
+ try {
38
+ const { report } = await api.report(meta.report);
39
+ const others = (report.areas || [])
40
+ .flatMap((a) => a.issues.map((i) => ({ ...i, area: a.area })))
41
+ .filter((i) => i.id !== meta.id);
42
+ if (!others.length) return "";
43
+ const lines = others.map(
44
+ (i) => ` ${i.id} [${i.severity || "--"}] ${i.area || "no area"} — ${i.title}`,
45
+ );
46
+ return (
47
+ `\n\n<!-- passalong: filed with ${others.length} other issue${others.length === 1 ? "" : "s"}` +
48
+ `${report.title ? ` in "${report.title}"` : ""}. Fix only this one unless asked; the others are\n` +
49
+ `${lines.join("\n")}\n-->`
50
+ );
51
+ } catch {
52
+ return "";
53
+ }
54
+ }
13
55
  const json = (data) => text(JSON.stringify(data, null, 2));
14
56
  const fail = (err) => ({ content: [{ type: "text", text: err.message }], isError: true });
15
57
 
16
58
  export async function serve() {
17
59
  const server = new McpServer(
18
- { name: "passalong", version: "0.2.0" },
60
+ { name: "passalong", version: VERSION },
19
61
  {
20
62
  instructions:
21
- "Passalong hands finished implementations between contexts as transfer guides: markdown " +
22
- `with frontmatter and the sections ${SECTIONS.join(", ")}. ` +
23
- "When the user references a passalong id or link, call get_guide and follow its Steps, " +
24
- "adapting anything marked ASSUMES to this codebase; run its Verification before declaring " +
25
- "done, then set_guide_status consumed and verify_guide with the result. " +
63
+ "Passalong hands work between contexts as guides: markdown with frontmatter, in two " +
64
+ "kinds, and `kind:` in the frontmatter says which. READ IT BEFORE ACTING — the two ask " +
65
+ "for opposite behaviour.\n" +
66
+ `kind: transfer (or absent) is a finished implementation to repeat here. Sections: ${SECTIONS.join(", ")}. ` +
67
+ "Follow its Steps, adapting anything marked ASSUMES to this codebase; run its " +
68
+ "Verification before declaring done, then verify_guide with the result.\n" +
69
+ "OPEN A GUIDE YOU MEAN TO ACT ON WITH start_guide, NOT get_guide. Both return the same " +
70
+ "markdown; start_guide also says you are on it, and that is the only signal the sender " +
71
+ "gets between handing work over and hearing it worked. get_guide is for reading one you " +
72
+ "have not committed to. Taking your own guide is a no-op, so there is no case where " +
73
+ "start_guide is the wrong call on work you are about to do. If it turns out not to be " +
74
+ "yours, hand it back with ack_guide taken=false and a reason — say why, or the sender is " +
75
+ "left exactly where silence left them.\n" +
76
+ `kind: bug is a defect to FIX here. Sections: ${BUG_SECTIONS.join(", ")}. ` +
77
+ "It has no Steps and nothing in it is a procedure to apply: Reproduce is how to see the " +
78
+ "bug and running it produces the bug, Verification is the behaviour that should have " +
79
+ "happened. Fix the defect, then check Verification and verify_guide with the result — " +
80
+ "ok true once the behaviour it describes actually holds. A bug report is not broken " +
81
+ "because you reproduced it. " +
82
+ "When you find defects you are not fixing — a test run, a QA pass, a review — call " +
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. " +
26
89
  "When the user asks to pass along, hand off, or " +
27
90
  "share what was just done, distill the session into a guide (guide_template shows the " +
28
- "shape) and call publish_guide, with `to` as team or team/handle when it is for a teammate. " +
29
- "At the start of work, inbox shows guides teammates have handed to this user, and activity " +
30
- "shows whether the guides they handed off have landed. Gotchas are " +
91
+ "shape) and call publish_guide, with `to` as team, team/@handle for one teammate, or " +
92
+ "team/#group for the people who do a thing. " +
93
+ "At the start of work, inbox shows guides teammates have handed to this user — open one " +
94
+ "with start_guide — and activity " +
95
+ "shows whether the guides they handed off have landed. When the user asks what they have " +
96
+ "been working on, or wants a standup or a summary of a period, call log — but say that it " +
97
+ "holds what they passed along and not everything they did. Gotchas are " +
31
98
  "the highest-value section: record what failed and why.",
32
99
  },
33
100
  );
@@ -79,9 +146,8 @@ export async function serve() {
79
146
  description:
80
147
  "The state of this user's transfers as queues: waiting on you (handed to you, not " +
81
148
  "pulled), not working (someone gave it a failing verdict — the most urgent), in flight " +
82
- "(handed over, nobody has taken it — `stale` means it has sat over a week), landed " +
83
- "(someone has it and has not marked it consumed), and worth keeping (pulled enough to " +
84
- "promote into a reference). Use it to answer 'what is outstanding?'.",
149
+ "(handed over, nobody has taken it — `stale` means it has sat over a week), and landed " +
150
+ "(someone else has it). Use it to answer 'what is outstanding?'.",
85
151
  inputSchema: {},
86
152
  },
87
153
  async () => {
@@ -93,6 +159,63 @@ export async function serve() {
93
159
  },
94
160
  );
95
161
 
162
+ server.registerTool(
163
+ "log",
164
+ {
165
+ title: "What this user did",
166
+ description:
167
+ "This user's own acts on guides, newest first: what they published, what they took " +
168
+ "delivery of, and every verdict and ack they gave. Each item has a rendered `text` line " +
169
+ "and the guide's repo. Use it to answer 'what have I been working on', to write a " +
170
+ "standup or a weekly summary, or to find work from a repo by when it happened rather " +
171
+ "than by what it was called. This is the opposite of `activity`, which is what other " +
172
+ "people did. IMPORTANT: it records what was passed along, not what was worked on — work " +
173
+ "that never became a guide has no entry, so never present it as a complete record of " +
174
+ "this user's work, and never infer that a quiet period was an idle one.",
175
+ inputSchema: {
176
+ repo: z.string().default("").describe("narrow to guides whose source repo matches this"),
177
+ since: z
178
+ .string()
179
+ .default("")
180
+ .describe("only what happened on or after this date: 2026, 2026-09, or 2026-09-11"),
181
+ },
182
+ },
183
+ async ({ repo, since }) => {
184
+ try {
185
+ return json({ log: await passalong.log({ repo: repo || "", since: since || "" }) });
186
+ } catch (err) {
187
+ return fail(err);
188
+ }
189
+ },
190
+ );
191
+
192
+ server.registerTool(
193
+ "ack_guide",
194
+ {
195
+ title: "Say whether you are taking it",
196
+ description:
197
+ "The first word back on a guide handed to you, before any work: take it, or pass it " +
198
+ "back. Passing must say why — an unanswered handoff is indistinguishable from one nobody " +
199
+ "has noticed, and the sender finds out in a week instead of a minute. Answer this when " +
200
+ "you pick up an inbox; verify_guide comes later, once you have actually run it.",
201
+ inputSchema: {
202
+ id: z.string().describe("passalong id"),
203
+ taken: z.boolean().describe("true if you are doing it; false hands it back"),
204
+ note: z
205
+ .string()
206
+ .default("")
207
+ .describe("why it is not yours (required when taken is false); one line, max 280 chars"),
208
+ },
209
+ },
210
+ async ({ id, taken, note }) => {
211
+ try {
212
+ return json(await passalong.ack(id, taken, note || ""));
213
+ } catch (err) {
214
+ return fail(err);
215
+ }
216
+ },
217
+ );
218
+
96
219
  server.registerTool(
97
220
  "verify_guide",
98
221
  {
@@ -143,14 +266,66 @@ export async function serve() {
143
266
  },
144
267
  );
145
268
 
269
+ server.registerTool(
270
+ "start_guide",
271
+ {
272
+ title: "Start work on a guide",
273
+ description:
274
+ "Take a guide handed to you AND fetch it, in one call — use this the moment you are going " +
275
+ "to do the work. Same markdown as get_guide, plus the handoff is answered: until somebody " +
276
+ "says they are on it, a guide nobody has noticed and a guide somebody is deep in look " +
277
+ "identical to the sender, who finds out in a week instead of a minute. Taking your own " +
278
+ "guide is a no-op, not an error, so this is always safe to call. If the work turns out " +
279
+ "not to be yours after reading, hand it back with ack_guide taken=false and a reason.",
280
+ inputSchema: {
281
+ ref: z.string().describe("passalong id (e.g. k3mq2xa7) or share URL"),
282
+ cwd: z
283
+ .string()
284
+ .optional()
285
+ .describe("directory to write .passalong/<id>.md into; default is the server's cwd"),
286
+ },
287
+ },
288
+ async ({ ref, cwd }) => {
289
+ try {
290
+ const r = await passalong.start(ref, { cwd: cwd || process.cwd() });
291
+ const meta = parse(r.markdown).meta;
292
+ const lead =
293
+ meta.kind === "bug"
294
+ ? "THIS IS A BUG REPORT, NOT WORK TO REPEAT. Do not follow Reproduce as instructions " +
295
+ "— those steps produce the defect. Fix what Problem describes, then check " +
296
+ "Verification and answer with verify_guide.\n\n"
297
+ : "";
298
+ // What actually happened to the ack, said plainly. An agent that reports "took it" when
299
+ // nothing was sent is the failure this tool exists to prevent, one step further along.
300
+ const took = r.took
301
+ ? "handoff taken; the sender has been told"
302
+ : r.own
303
+ ? "your own guide — nothing to take, and nobody to tell"
304
+ : r.ack_error
305
+ ? `NOT taken (${r.ack_error}) — retry with ack_guide before reporting that you have it`
306
+ : "not logged in, so no handoff was taken";
307
+ const siblings = await related(meta);
308
+ return text(
309
+ `${lead}${r.markdown}${siblings}` +
310
+ `\n\n<!-- passalong: ${r.from}; written to ${r.path}; ${took} -->`,
311
+ );
312
+ } catch (err) {
313
+ return fail(err);
314
+ }
315
+ },
316
+ );
317
+
146
318
  server.registerTool(
147
319
  "get_guide",
148
320
  {
149
321
  title: "Get guide",
150
322
  description:
151
- "Fetch a transfer guide by passalong id or share link and return its full markdown. Also " +
323
+ "READ a transfer guide by passalong id or share link and return its full markdown. Also " +
152
324
  "writes it to .passalong/<id>.md in the working directory so it survives the session. " +
153
- "Pulling a teammate's guide tells them the transfer landed.",
325
+ "Pulling a teammate's guide tells them the transfer landed. Use this to look at a guide " +
326
+ "you have not committed to. If you are about to DO the work, call start_guide instead: " +
327
+ "it does this and takes the handoff in one call, which is the only way the sender learns " +
328
+ "somebody picked it up.",
154
329
  inputSchema: {
155
330
  ref: z.string().describe("passalong id (e.g. k3mq2xa7) or share URL"),
156
331
  cwd: z
@@ -162,7 +337,55 @@ export async function serve() {
162
337
  async ({ ref, cwd }) => {
163
338
  try {
164
339
  const { markdown, path, from } = await passalong.pull(ref, { cwd: cwd || process.cwd() });
165
- return text(`${markdown}\n\n<!-- passalong: ${from}; written to ${path} -->`);
340
+ // The heading text is what an agent keys on, and a bug's headings look enough like a
341
+ // transfer guide's to be followed by one that never opened the frontmatter. So the kind
342
+ // is stated in front of the document, in the imperative, every time.
343
+ const meta = parse(markdown).meta;
344
+ const lead =
345
+ meta.kind === "bug"
346
+ ? "THIS IS A BUG REPORT, NOT WORK TO REPEAT. Do not follow Reproduce as instructions " +
347
+ "— those steps produce the defect. Fix what Problem describes, then check " +
348
+ "Verification and answer with verify_guide.\n\n"
349
+ : "";
350
+ const siblings = await related(meta);
351
+ // After the document, with the other trailing comments, not in front of it. An
352
+ // instruction that arrives with the payload is what gets read — but anything before the
353
+ // opening `---` stops the frontmatter being frontmatter, and this fires on every guide
354
+ // anyone was handed rather than only on bugs. The bug lead stays where it is: it is a
355
+ // warning against executing the document, so being read first is its whole job.
356
+ return text(
357
+ `${lead}${markdown}${siblings}` +
358
+ `\n\n<!-- passalong: ${from}; written to ${path} -->` +
359
+ `\n${passalong.handoffNudge(meta)}`,
360
+ );
361
+ } catch (err) {
362
+ return fail(err);
363
+ }
364
+ },
365
+ );
366
+
367
+ server.registerTool(
368
+ "attach_screenshot",
369
+ {
370
+ title: "Attach a screenshot",
371
+ description:
372
+ "Upload an image from this machine as evidence, and get back the markdown line that " +
373
+ "points at it. Put that line in the guide body — a guide travels as markdown to whoever " +
374
+ "holds its link, so evidence beside the document does not travel at all. Publishing " +
375
+ "claims whatever the markdown names, so attach first and publish after. png, jpg, webp " +
376
+ "or gif.",
377
+ inputSchema: {
378
+ file: z.string().describe("path to the image on this machine"),
379
+ name: z.string().default("").describe("label for the image; defaults to its filename"),
380
+ },
381
+ },
382
+ async ({ file, name }) => {
383
+ try {
384
+ const shot = await passalong.attach(file, { name: name || "" });
385
+ return text(
386
+ `${JSON.stringify({ id: shot.id, url: shot.url, bytes: shot.bytes }, null, 2)}\n\n` +
387
+ `Put this in the guide body:\n${shot.markdown}`,
388
+ );
166
389
  } catch (err) {
167
390
  return fail(err);
168
391
  }
@@ -174,23 +397,38 @@ export async function serve() {
174
397
  {
175
398
  title: "Publish guide",
176
399
  description:
177
- "Publish a transfer guide from markdown (frontmatter + sections). Missing id, created, " +
178
- 'author, and source_context are filled in. `to` addresses it to a team ("khaime") or a ' +
400
+ "Publish a guide from markdown (frontmatter + sections) — a transfer guide by default, or " +
401
+ "a single bug with `kind: bug`; use file_bugs for more than one. Missing id, created, " +
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 ' +
179
406
  'teammate ("khaime/lukman"), who is notified. Returns the id and share link.',
180
407
  inputSchema: {
181
408
  markdown: z.string().describe("full guide markdown; start from guide_template"),
182
- to: z.string().optional().describe("team slug, or team/handle for a specific teammate"),
409
+ to: z
410
+ .string()
411
+ .optional()
412
+ .describe("team slug, team/@handle for one teammate, or team/#group for a set of them"),
183
413
  cwd: z
184
414
  .string()
185
415
  .optional()
186
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
+ ),
187
424
  },
188
425
  },
189
- async ({ markdown, to, cwd }) => {
426
+ async ({ markdown, to, cwd, parent }) => {
190
427
  try {
191
428
  const { guide, url, synced, notified, path } = await passalong.share(markdown, {
192
429
  cwd: cwd || process.cwd(),
193
430
  to,
431
+ follows: parent,
194
432
  });
195
433
  return json({
196
434
  id: guide.meta.id,
@@ -199,6 +437,7 @@ export async function serve() {
199
437
  synced,
200
438
  team: guide.meta.team || "",
201
439
  to: guide.meta.to || "",
440
+ parent: guide.meta.parent || "",
202
441
  notified,
203
442
  path,
204
443
  });
@@ -208,14 +447,106 @@ export async function serve() {
208
447
  },
209
448
  );
210
449
 
450
+ server.registerTool(
451
+ "file_bugs",
452
+ {
453
+ title: "File bugs",
454
+ description:
455
+ "File one or more bugs you found but are not fixing, as a set. Opens a report and " +
456
+ "publishes each issue as its own guide — its own id, share link, and verdict — so a " +
457
+ "reviewer can hand any one of them to whoever fixes it. Use this after a test run, a QA " +
458
+ "pass, or a review that turned up defects; use publish_guide instead for work you " +
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`).",
463
+ inputSchema: {
464
+ title: z
465
+ .string()
466
+ .optional()
467
+ .describe('what the sweep was, e.g. "Checkout regression pass, 8 Sep build"'),
468
+ environment: z
469
+ .string()
470
+ .optional()
471
+ .describe("production, staging or development — where you saw these"),
472
+ to: z
473
+ .string()
474
+ .optional()
475
+ .describe("team slug, team/@handle for one teammate, or team/#group for a set of them"),
476
+ issues: z
477
+ .array(
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
+ ),
486
+ title: z.string().describe("what is broken, in one line"),
487
+ problem: z
488
+ .string()
489
+ .describe(
490
+ "what is broken and what it stops someone doing; the error if there is one",
491
+ ),
492
+ reproduce: z
493
+ .string()
494
+ .describe(
495
+ "numbered steps that show the bug — these produce it, they are not a fix",
496
+ ),
497
+ verification: z
498
+ .string()
499
+ .optional()
500
+ .describe(
501
+ "the behaviour that should have happened, as something a fixer can check",
502
+ ),
503
+ gotchas: z
504
+ .string()
505
+ .optional()
506
+ .describe("anything already ruled out, or that made it hard to pin down"),
507
+ area: z
508
+ .string()
509
+ .optional()
510
+ .describe(`which surface it is on — known: ${AREAS.map((a) => a.slug).join(", ")}`),
511
+ severity: z
512
+ .string()
513
+ .optional()
514
+ .describe("s1 blocker, s2 major, s3 minor, s4 cosmetic (default s3)"),
515
+ }),
516
+ )
517
+ .min(1)
518
+ .describe("one entry per defect; file them together rather than one call each"),
519
+ cwd: z.string().optional().describe("directory the bugs were found in"),
520
+ },
521
+ },
522
+ async ({ title, environment, to, issues, cwd }) => {
523
+ try {
524
+ const filed = await passalong.fileBugs(
525
+ { title: title || "", environment: environment || "", issues },
526
+ { cwd: cwd || process.cwd(), to },
527
+ );
528
+ return json(filed);
529
+ } catch (err) {
530
+ return fail(err);
531
+ }
532
+ },
533
+ );
534
+
211
535
  server.registerTool(
212
536
  "guide_template",
213
537
  {
214
538
  title: "Guide template",
215
- description: "The empty transfer guide skeleton with guidance comments for each section.",
216
- inputSchema: {},
539
+ description:
540
+ "The empty guide skeleton with guidance comments for each section. `kind: bug` gives the " +
541
+ "bug report skeleton instead, which has a Reproduce section and no Steps.",
542
+ inputSchema: {
543
+ kind: z
544
+ .enum(["transfer", "bug"])
545
+ .optional()
546
+ .describe("transfer (default) for finished work to repeat; bug for a defect to fix"),
547
+ },
217
548
  },
218
- async () => text(template()),
549
+ async ({ kind }) => text(template(kind === "bug" ? { kind: "bug" } : {})),
219
550
  );
220
551
 
221
552
  server.registerTool(
@@ -223,9 +554,11 @@ export async function serve() {
223
554
  {
224
555
  title: "Set guide status",
225
556
  description:
226
- "Mark a guide consumed (implemented on the receiving side) or promoted (a reusable reference). " +
227
- "Only the author can promote.",
228
- 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"]) },
229
562
  },
230
563
  async ({ id, status }) => {
231
564
  try {