passalong 0.9.0 → 0.12.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "passalong",
3
- "version": "0.9.0",
3
+ "version": "0.12.0",
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/api.js CHANGED
@@ -1,7 +1,18 @@
1
1
  // Client for the hosted sync API (apps/api). Everything here is optional: with no token the
2
2
  // CLI is a purely local tool, and every function throws an ApiError the CLI turns into a hint.
3
+ import { readFileSync } from "node:fs";
3
4
  import { readConfig } from "./store.js";
4
5
 
6
+ /**
7
+ * This build's version, sent on every authenticated call as `x-passalong-version`. The server
8
+ * refuses a CLI below its floor (apps/api/src/clients.ts), because the rules it enforces deploy on
9
+ * merge and the text telling an agent how to follow them only arrives with a reinstall.
10
+ */
11
+ export const VERSION = JSON.parse(
12
+ readFileSync(new URL("../package.json", import.meta.url), "utf8"),
13
+ ).version;
14
+ const CLIENT = { "x-passalong-version": VERSION };
15
+
5
16
  export const DEFAULT_API = "https://passalong.dev";
6
17
 
7
18
  export class ApiError extends Error {
@@ -24,7 +35,7 @@ export function loggedIn() {
24
35
  }
25
36
 
26
37
  async function call(path, { method = "GET", body, auth = true, raw = false } = {}) {
27
- const headers = {};
38
+ const headers = { ...CLIENT };
28
39
  if (auth) {
29
40
  const t = token();
30
41
  if (!t) throw new ApiError(401, "not logged in — run `passalong login` to enable sync");
@@ -220,7 +231,7 @@ export const ack = (id, taken, note = "") =>
220
231
  export async function uploadShot(bytes, type, name = "") {
221
232
  const t = token();
222
233
  if (!t) throw new ApiError(401, "not logged in — run `passalong login` to enable sync");
223
- const headers = { authorization: `Bearer ${t}`, "content-type": type };
234
+ const headers = { ...CLIENT, authorization: `Bearer ${t}`, "content-type": type };
224
235
  // A header is latin-1: a filename with an accent in it throws on the way out rather than at the
225
236
  // server, and the label is not worth failing an upload over.
226
237
  if (name) headers["x-shot-name"] = String(name).replace(/[^\x20-\x7e]/g, "");
package/src/guide.js CHANGED
@@ -127,8 +127,22 @@ export function sectionsFor(kind) {
127
127
  }
128
128
 
129
129
  /** The sections it cannot be published without. */
130
+ /**
131
+ * The sections a kind cannot do without, and the one that has none.
132
+ *
133
+ * A transfer is context exchange. Context is whatever the next agent needs to not start cold —
134
+ * a decision and why, a constraint, where the bodies are — and none of that arrives in six fixed
135
+ * headings. Requiring `## Problem` and `## Steps` made every short note into a document: of 56
136
+ * follow-ups in real use, the ones that fill this template average 599 words and ten of sixteen
137
+ * were never opened by anyone, while the ones that ignored it average 137 words and every single
138
+ * one was opened. The template was not raising the floor, it was padding to reach it.
139
+ *
140
+ * A task and a bug keep theirs, because theirs are not prose. `## Acceptance` is the list a
141
+ * hand-in answers line by line, and `## Reproduce` is how a reader sees the defect. Those earn
142
+ * their requirement by being read mechanically; `## Steps` on a piece of context does not.
143
+ */
130
144
  const REQUIRED = {
131
- transfer: ["Problem", "Steps"],
145
+ transfer: [],
132
146
  bug: ["Problem", "Reproduce"],
133
147
  task: ["Goal", "Acceptance"],
134
148
  };
@@ -255,10 +269,21 @@ export function parseFrontmatter(text) {
255
269
  // Only the lists every guide has. `blocked_by` is a task's, and defaulting it would write an
256
270
  // empty one into every guide anybody re-shares.
257
271
  for (const k of ["stack_assumptions", "tags"]) if (meta[k] === undefined) meta[k] = [];
258
- // What it is, said rather than inferred. An unknown spelling is left alone for `validate()` to
259
- // refuse by name: quietly turning `kind: buggy` into a transfer guide is the same guess this is
260
- // here to remove.
261
- if (!String(meta.kind ?? "").trim()) meta.kind = "transfer";
272
+ // Absent is absent. It is NOT a transfer guide.
273
+ //
274
+ // This line, and the sentence publish_guide tells every agent — "kind: task (the default)" —
275
+ // disagreed for thirteen days. An agent that left `kind:` off, believing what it had been told,
276
+ // got a transfer guide. 162 of 200 guides in real use are labelled transfer; their titles are
277
+ // tasks and bug reports. Everything downstream followed from that: a transfer has no states, so
278
+ // nothing could show as in progress; it demands Problem and Steps, so a two-line correction was
279
+ // padded to six sections; and there is nowhere to report into one, so agents published a second
280
+ // guide to carry what they had found.
281
+ //
282
+ // So it stays empty and `validate()` refuses it by name. The back-compat this default protected
283
+ // is a guide written before kinds existed being re-shared; that guide is re-shared once with an
284
+ // explicit `kind: transfer`, which is a sentence its author can write, unlike thirteen days of
285
+ // mislabelled work nobody could see.
286
+ meta.kind = String(meta.kind ?? "").trim();
262
287
  // Both ends of this module: what it reads and what it writes are in one style, so a guide
263
288
  // written before there was a rule comes back normalised and goes out normalised.
264
289
  meta.tags = tagList(meta.tags);
@@ -305,6 +330,10 @@ export function serializeFrontmatter(meta) {
305
330
  ];
306
331
  const out = [];
307
332
  for (const key of keys) {
333
+ // A kind nobody stated is left unstated. Writing `kind: ""` would put an answer in the
334
+ // document that its author never gave, and the next parse would read it back as a stated
335
+ // empty rather than an absent one — which is the whole distinction validate() turns on.
336
+ if (key === "kind" && !String(meta[key] ?? "").trim()) continue;
308
337
  const v = key === "tags" ? tagList(meta[key]) : meta[key];
309
338
  if (Array.isArray(v)) {
310
339
  out.push(v.length ? `${key}: [${v.map(quote).join(", ")}]` : `${key}: []`);
@@ -497,8 +526,9 @@ export function bugGuide({
497
526
 
498
527
  /** A draft with the section skeleton. Placeholders are HTML comments so they vanish when rendered. */
499
528
  export function template(meta = {}) {
500
- // A task unless asked otherwise: most guides assign work. The transfer skeleton writes its kind
501
- // out, since an absent kind: still means transfer to every client already installed.
529
+ // A task unless asked otherwise: most guides assign work. Every skeleton writes its kind out —
530
+ // absent is now refused rather than read as transfer, so a template that left it off would
531
+ // produce a document its own author could not publish.
502
532
  if (meta.kind === "bug") return bugTemplate(meta);
503
533
  if (meta.kind !== "transfer") return taskTemplate(meta);
504
534
  const body = [
package/src/mcp.js CHANGED
@@ -29,11 +29,62 @@ const text = (s) => ({ content: [{ type: "text", text: s }] });
29
29
  * evidence is collected while the work happens, not reconstructed from memory once it is done.
30
30
  * Mirrors KEEP_EVIDENCE in apps/api/src/mcp-http.ts.
31
31
  */
32
+ const WHERE_THE_LINE_GOES =
33
+ "WHERE THE LINE GOES DEPENDS ON WHAT THE IMAGE IS EVIDENCE OF. Proof that work you are handing i" +
34
+ "n holds — a screen that renders right, a total that matches — goes in that check's `ran` on han" +
35
+ "d_in, and nothing is published: a check answered with a picture is shown, which is what the han" +
36
+ "d-in asks for. Only an image that is part of a DOCUMENT goes in a guide body — the screenshot i" +
37
+ "n a bug report, a diagram a guide is explaining — and then it must be in the markdown, because " +
38
+ "a guide travels as markdown to whoever holds its link and evidence beside the document does not" +
39
+ " travel at all; publishing claims whatever the markdown names, so attach first and publish afte" +
40
+ "r. DO NOT PUBLISH A GUIDE TO CARRY SCREENSHOTS. Six images and a caption each is a hand-in, not" +
41
+ " a document, and wrapping them in Problem / Solution shape / Decisions / Steps / Verification t" +
42
+ "o make them publishable is how a set of pictures becomes fifteen hundred words nobody asked for" +
43
+ ". png, jpg, webp or gif.";
44
+
45
+ /**
46
+ * What a screenshot has to be a screenshot OF.
47
+ *
48
+ * "A check is run or shown" made a picture mandatory, and an agent short of one built a page to
49
+ * photograph: it added `src/app/notes-preview`, shot that, then `rm -rf`'d it and published the
50
+ * shots as evidence that the real screen rendered. The rule was satisfied and almost nothing was
51
+ * proved — the measure had become the target, and the page the picture showed no longer existed.
52
+ *
53
+ * Mirrors SHOOT_THE_REAL_THING in apps/api/src/mcp-http.ts.
54
+ */
55
+ const SHOOT_THE_REAL_THING =
56
+ "SHOOT THE RUNNING THING, NOT SOMETHING YOU BUILT TO SHOOT. A screenshot is worth what the sc" +
57
+ "reen behind it is worth: the real route in the real app, reached the way a reader would reac" +
58
+ "h it. A scratch page, a story, a harness or a component rendered on its own is a picture of " +
59
+ "your scaffolding, and it can look right while the thing the guide is about does not. If the " +
60
+ "only way you could get a picture was to build something, that is not evidence the real scree" +
61
+ "n works — say what you built and why in `writeup`, and leave the check to the commands that " +
62
+ "do hold. NEVER DELETE WHAT YOUR EVIDENCE POINTS AT: a shot of a page you removed afterwards " +
63
+ "is one nobody can take again, including you.";
64
+
32
65
  const KEEP_EVIDENCE =
33
66
  "KEEP YOUR EVIDENCE AS YOU GO. hand_in needs it: the commands you ran and what came back, the " +
34
67
  "test summary, the link to the change. Copy each one when it happens — at the end you will be " +
35
68
  "writing from memory, which is the thing evidence is here to replace.\n\n";
36
69
 
70
+ /**
71
+ * Said in front of every guide an agent has to locate things in for itself — a bug and a task.
72
+ *
73
+ * The failure it is here to stop: an agent lists one directory, or opens the one file it expected,
74
+ * and concludes from that. It then reports that a thing is absent, or that it found the only
75
+ * occurrence, having never looked past the place it happened to start. The conclusion is confident
76
+ * and wrong, and nothing downstream can tell, because the evidence it brings is real — it is just
77
+ * evidence of a search that was too small.
78
+ *
79
+ * Mirrors SEARCH_WIDE in apps/api/src/mcp-http.ts.
80
+ */
81
+ const SEARCH_WIDE =
82
+ "SEARCH THE WHOLE TREE BEFORE YOU CONCLUDE. Listing a directory, or reading the one file you " +
83
+ "expected, tells you what is in that directory — not whether the thing exists, and not whether " +
84
+ "you have found all of it. Before you report that something is absent, is the only one, or is " +
85
+ 'already handled, search across the repository and let the output be your reason. "I looked" ' +
86
+ "is a claim; the search and what it printed is evidence.\n\n";
87
+
37
88
  /**
38
89
  * What to say in front of a guide whose kind changes what the reader should do with it.
39
90
  *
@@ -48,6 +99,8 @@ function leadFor(meta) {
48
99
  "THIS IS A BUG REPORT, NOT WORK TO REPEAT. Do not follow Reproduce as instructions " +
49
100
  "— those steps produce the defect. Fix what Problem describes, then check " +
50
101
  "Verification and answer with hand_in.\n\n" +
102
+ SEARCH_WIDE +
103
+ SHOOT_THE_REAL_THING +
51
104
  KEEP_EVIDENCE
52
105
  );
53
106
  if (meta.kind === "task")
@@ -57,6 +110,8 @@ function leadFor(meta) {
57
110
  "under Acceptance holds. Opening it here does not make it yours: to work on it, call take " +
58
111
  "with its id in the repo it is for, so no other agent can. Then progress, and hand_in " +
59
112
  "with a write-up.\n\n" +
113
+ SEARCH_WIDE +
114
+ SHOOT_THE_REAL_THING +
60
115
  KEEP_EVIDENCE
61
116
  );
62
117
  // A transfer guide is handed over untouched: it is a document to follow, and anything in front
@@ -206,7 +261,9 @@ export function buildServer() {
206
261
  "Collect it as you work rather than writing it from memory at the end. hand_in without " +
207
262
  "it is refused, because the write-up and the verdict are both your word for your own " +
208
263
  "work and evidence is the part the person reviewing it can check.\n" +
209
- `kind: task (the default) is work nobody has done yet. Sections: ${TASK_SECTIONS.join(", ")}. ` +
264
+ "SAY WHICH KIND IT IS. There is no default: a guide with no `kind:` line is refused, and so " +
265
+ "is a spelling that is not one of the three. " +
266
+ `kind: task is work nobody has done yet. Sections: ${TASK_SECTIONS.join(", ")}. ` +
210
267
  "It has no Steps: work out how to reach Goal within Constraints, leave Out of scope " +
211
268
  "alone, and treat Acceptance as the definition of done. hand_in with `markdown`: a " +
212
269
  "transfer guide about what you did (guide_template kind transfer shows the shape) — its " +
@@ -326,7 +383,19 @@ export function buildServer() {
326
383
  }
327
384
  }
328
385
 
329
- async function doHandIn({ id, ok, note, evidence, checks, writeup, markdown, report, pr, cwd }) {
386
+ async function doHandIn({
387
+ id,
388
+ ok,
389
+ note,
390
+ evidence,
391
+ checks,
392
+ writeup,
393
+ risk,
394
+ markdown,
395
+ report,
396
+ pr,
397
+ cwd,
398
+ }) {
330
399
  try {
331
400
  // A check that names a command is run here, before anything is recorded, and its exit code
332
401
  // decides it rather than the agent's account of it. A failure is refused with the command's
@@ -347,6 +416,7 @@ export function buildServer() {
347
416
  evidence: evidence || "",
348
417
  checks: done.checks,
349
418
  writeup: writeup || "",
419
+ risk: risk || "",
350
420
  markdown,
351
421
  report,
352
422
  pr: pr || "",
@@ -686,14 +756,16 @@ export function buildServer() {
686
756
  "the shell command that proves this line, run here before the hand-in lands — " +
687
757
  "its exit code decides the check and its output is recorded as the evidence. " +
688
758
  "Non-zero refuses the hand-in. Leave it out for a check nobody can run, like " +
689
- '"the badge reads 3", and write `ran` instead',
759
+ '"the badge reads 3" — then SHOW it with attach_screenshot and put the line it returns in `ran`; a check is run or shown, and describing what you saw is refused',
690
760
  ),
691
761
  }),
692
762
  )
693
763
  .optional()
694
764
  .describe(
695
765
  "one entry per line the guide asks for — Acceptance on a task, Verification on a " +
696
- "handoff or a bug — in the order you worked them",
766
+ "handoff or a bug — in the order you worked them. Each is RUN (`cmd`) or SHOWN (an " +
767
+ "attach_screenshot line in `ran`). Pasting a command you typed into `ran` is not " +
768
+ "running it and is refused",
697
769
  ),
698
770
  ok: z.boolean().optional().describe("handoff or bug: did its Verification hold"),
699
771
  note: z.string().optional().describe("one line; required when ok is false"),
@@ -706,6 +778,15 @@ export function buildServer() {
706
778
  "it out when it worked as written. The next person to open the guide is shown it, " +
707
779
  "which is why it does not need to be a guide of its own",
708
780
  ),
781
+ risk: z
782
+ .string()
783
+ .optional()
784
+ .describe(
785
+ "what this could break, in one line — the part of a PR template's Risk that is " +
786
+ "worth reading: a shared helper changed, a migration that cannot be undone, a " +
787
+ "route other clients call. Optional; leave it out when there is nothing specific, " +
788
+ 'rather than writing "low risk". Its author reads it before accepting the work',
789
+ ),
709
790
  markdown: z
710
791
  .string()
711
792
  .optional()
@@ -801,19 +882,44 @@ export function buildServer() {
801
882
  title: "Attach a screenshot",
802
883
  annotations: { ...ADDS, openWorldHint: true },
803
884
  description:
804
- "Upload an image from this machine as evidence, and get back the markdown line that " +
805
- "points at it. Put that line in the guide body — a guide travels as markdown to whoever " +
806
- "holds its link, so evidence beside the document does not travel at all. Publishing " +
807
- "claims whatever the markdown names, so attach first and publish after. png, jpg, webp " +
808
- "or gif.",
885
+ "Upload an image as evidence — a path in `file`, or the bytes in `data` when you have " +
886
+ "the image and no file — and get back the markdown line that points at it. " +
887
+ WHERE_THE_LINE_GOES,
809
888
  inputSchema: {
810
- file: z.string().describe("path to the image on this machine"),
889
+ file: z
890
+ .string()
891
+ .default("")
892
+ .describe("path to the image on this machine; leave out when sending `data`"),
893
+ data: z
894
+ .string()
895
+ .default("")
896
+ .describe(
897
+ "the image itself, base64 (a data: URL is fine), for when you have the bytes and no " +
898
+ "file — a browser tool that hands back an image inline and never writes to disk. " +
899
+ "5MB; larger belongs in create_upload",
900
+ ),
901
+ type: z
902
+ .string()
903
+ .default("image/png")
904
+ .describe("media type of `data`: image/png, image/jpeg, image/webp or image/gif"),
811
905
  name: z.string().default("").describe("label for the image; defaults to its filename"),
812
906
  },
813
907
  },
814
- async ({ file, name }) => {
908
+ async ({ file, data, type, name }) => {
815
909
  try {
816
- const shot = await passalong.attach(file, { name: name || "" });
910
+ if (!file && !data) {
911
+ return fail(
912
+ new Error(
913
+ "send `file` (a path on this machine) or `data` (the image, base64). If your " +
914
+ "screenshot is a tool result you cannot write out, capture it to a file instead " +
915
+ "— a headless browser's page.screenshot({ path }) or your platform's capture " +
916
+ "command — and pass that path.",
917
+ ),
918
+ );
919
+ }
920
+ const shot = data
921
+ ? await passalong.attachBytes(data, { name: name || "", type: type || "image/png" })
922
+ : await passalong.attach(file, { name: name || "" });
817
923
  return text(
818
924
  `${JSON.stringify({ id: shot.id, url: shot.url, bytes: shot.bytes }, null, 2)}\n\n` +
819
925
  `Put this in the guide body:\n${shot.markdown}`,
package/src/passalong.js CHANGED
@@ -7,6 +7,7 @@ import { hostname } from "node:os";
7
7
  import { basename, join } from "node:path";
8
8
  import * as api from "./api.js";
9
9
  import { context } from "./capture.js";
10
+ import { refusal, runChecks } from "./checks.js";
10
11
  import {
11
12
  bugGuide,
12
13
  ID_RE,
@@ -357,8 +358,16 @@ export async function finishTask(
357
358
  requireEvidence(evidence);
358
359
  if (!report && markdown) {
359
360
  const g = parse(markdown);
360
- report = (await share(serialize({ meta: { ...g.meta, parent: id }, body: g.body }), { cwd }))
361
- .guide.meta.id;
361
+ // A task's report is a transfer guide, and this code is what is making one — so it says so
362
+ // rather than hoping the agent's frontmatter did. Absent is refused now, and refusing an
363
+ // agent's hand-in because its write-up left a line off would be this function's omission
364
+ // charged to the agent. Stated, not defaulted: `kind` is the one field here that is not the
365
+ // author's to leave open, because the caller already knows the answer.
366
+ report = (
367
+ await share(serialize({ meta: { ...g.meta, kind: "transfer", parent: id }, body: g.body }), {
368
+ cwd,
369
+ })
370
+ ).guide.meta.id;
362
371
  }
363
372
  if (!report)
364
373
  throw new PassalongError(
@@ -413,6 +422,7 @@ export async function handIn(
413
422
  evidence = "",
414
423
  checks = [],
415
424
  writeup = "",
425
+ risk = "",
416
426
  markdown,
417
427
  report,
418
428
  pr = "",
@@ -420,6 +430,21 @@ export async function handIn(
420
430
  } = {},
421
431
  ) {
422
432
  needsSync("handing work in");
433
+ // A check that names a command is run here, before anything is sent.
434
+ //
435
+ // The MCP server did this and this function did not, so "put the command in `cmd` and it is
436
+ // executed here" was true over one transport and a quiet lie over the other: a `cmd` handed to
437
+ // this function was passed along as a string nothing had executed. Checks that already carry an
438
+ // exit came from the server's own runner and are left alone, so nothing runs twice.
439
+ const pending = checks.filter((c) => c?.cmd && typeof c?.exit !== "number" && c?.exit !== null);
440
+ if (pending.length) {
441
+ const done = runChecks(pending, { cwd });
442
+ const byCheck = new Map(done.checks.map((c) => [c.check, c]));
443
+ checks = checks.map((c) => byCheck.get(c.check) ?? c);
444
+ if (done.failed) {
445
+ throw new Error(refusal(done.failed, { ran: done.ran, total: checks.length }));
446
+ }
447
+ }
423
448
  // Checks are evidence, sorted against the lines they answer: bringing them is bringing it. A
424
449
  // check whose command was actually run is exempt from the length rule — its exit code is the
425
450
  // evidence, and `test -f dist/app.js` exiting 0 says more than any sentence about it would.
@@ -427,8 +452,16 @@ export async function handIn(
427
452
  else requireEvidence(evidence);
428
453
  if (!report && markdown) {
429
454
  const g = parse(markdown);
430
- report = (await share(serialize({ meta: { ...g.meta, parent: id }, body: g.body }), { cwd }))
431
- .guide.meta.id;
455
+ // A task's report is a transfer guide, and this code is what is making one — so it says so
456
+ // rather than hoping the agent's frontmatter did. Absent is refused now, and refusing an
457
+ // agent's hand-in because its write-up left a line off would be this function's omission
458
+ // charged to the agent. Stated, not defaulted: `kind` is the one field here that is not the
459
+ // author's to leave open, because the caller already knows the answer.
460
+ report = (
461
+ await share(serialize({ meta: { ...g.meta, kind: "transfer", parent: id }, body: g.body }), {
462
+ cwd,
463
+ })
464
+ ).guide.meta.id;
432
465
  }
433
466
  const fence = fenceFor(id, cwd);
434
467
  return api.handIn(id, {
@@ -450,6 +483,8 @@ export async function handIn(
450
483
  // Left out when there is nothing to say, rather than sent as "": most hand-ins have nothing
451
484
  // to adapt, and the field is for the ones that do.
452
485
  ...(writeup ? { writeup } : {}),
486
+ // What it could break, for the reviewer. Left out when empty, like the write-up.
487
+ ...(risk ? { risk } : {}),
453
488
  ...(typeof ok === "boolean" ? { ok } : {}),
454
489
  });
455
490
  }
@@ -635,6 +670,46 @@ export const SHOT_TYPES = {
635
670
  * Local because it reads a path. An agent in a terminal has one; a hosted assistant does not, and
636
671
  * reaches the same route through `attach_screenshot` on the HTTP server instead.
637
672
  */
673
+ /**
674
+ * The same upload, for an agent that has the image and not a path to it.
675
+ *
676
+ * `attach()` takes a path because a screenshot usually lands on disk. An agent driving a browser
677
+ * often has the opposite: the pane hands back an image as a tool result and never writes a file, so
678
+ * the only door into evidence was one it could not open. It did what anyone would — swapped to
679
+ * `grep` and `tsc` checks and put what it saw in `writeup` — and said so itself: "a grep proves the
680
+ * code changed, not that the screen renders right."
681
+ *
682
+ * Base64 in a tool call, which the HTTP server's instructions rightly warn against, because there
683
+ * the alternative is `create_upload` and a curl. Here there is no alternative for bytes held in
684
+ * memory, and this server is a local process talking to the same upload the file path uses.
685
+ */
686
+ export async function attachBytes(data, { name = "", type = "image/png" } = {}) {
687
+ if (!api.loggedIn())
688
+ throw new PassalongError("attaching evidence needs sync — run `passalong login` first");
689
+ const allowed = new Set(Object.values(SHOT_TYPES));
690
+ if (!allowed.has(type))
691
+ throw new PassalongError(`screenshots must be ${[...allowed].join(", ")} — ${type} is not one`);
692
+ let bytes;
693
+ try {
694
+ // A data: URL is what several browser tools hand back, so it is accepted rather than made the
695
+ // caller's problem to strip.
696
+ bytes = Buffer.from(String(data).replace(/^data:[^,]*,/, ""), "base64");
697
+ } catch {
698
+ throw new PassalongError("`data` is not base64");
699
+ }
700
+ if (!bytes.length) throw new PassalongError("`data` decoded to nothing");
701
+ // The server's own ceiling, checked here so a large paste fails before it is sent rather than
702
+ // after. Anything bigger belongs in create_upload, which streams from a file.
703
+ if (bytes.length > 5 * 1024 * 1024)
704
+ throw new PassalongError(
705
+ `that image is ${Math.round(bytes.length / 1024 / 1024)}MB and the limit is 5MB — ` +
706
+ "write it to a file and use create_upload, which streams",
707
+ );
708
+ const label = name || "screenshot";
709
+ const shot = await api.uploadShot(bytes, type, label);
710
+ return { ...shot, markdown: `![${label}](${shot.url})` };
711
+ }
712
+
638
713
  export async function attach(file, { name = "" } = {}) {
639
714
  if (!api.loggedIn())
640
715
  throw new PassalongError("attaching evidence needs sync — run `passalong login` first");
@@ -657,6 +732,11 @@ export async function attach(file, { name = "" } = {}) {
657
732
  * found doing it. Whoever opens the original, person or agent, gets its follow-ups with it, which
658
733
  * is why context belongs there rather than in a one-line verdict note.
659
734
  *
735
+ * What an agent did and found working on the guide is not context for it: that is the hand-in,
736
+ * and the note says so. It used to list "what you found doing it" among the things to publish, on
737
+ * every `take`, while hand_in said a follow-up was not for answering — the agent followed the one
738
+ * it read last, and the author got a second guide to review with the evidence inside it.
739
+ *
660
740
  * Trailing, like the handoff nudge, so it never sits in front of the frontmatter. It is here and
661
741
  * not only in the server's instructions because an agent reads the payload it is working from and
662
742
  * skims everything else — the same reason the bug lead is inside the document. For a bug, the
@@ -673,9 +753,10 @@ export function followUpNote(meta = {}) {
673
753
  );
674
754
  return (
675
755
  "<!-- passalong: a follow-up is more context for this guide, written as its own guide. If " +
676
- "this guide needs more — a missing detail, a step that needed explaining, what changed since, " +
677
- `what you found doing it — publish that with publish_guide parent=${meta.id}, and whoever ` +
678
- "opens this guide gets it too. -->"
756
+ "this guide needs more — a missing detail, a step that needed explaining, what changed since " +
757
+ `— publish that with publish_guide parent=${meta.id}, and whoever opens this guide gets it ` +
758
+ "too. What you did and found working on it is not a follow-up: it goes on hand_in, in " +
759
+ "`checks` and `writeup`. -->"
679
760
  );
680
761
  }
681
762