passalong 0.9.0 → 0.11.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.11.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/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
@@ -686,14 +741,16 @@ export function buildServer() {
686
741
  "the shell command that proves this line, run here before the hand-in lands — " +
687
742
  "its exit code decides the check and its output is recorded as the evidence. " +
688
743
  "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',
744
+ '"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
745
  ),
691
746
  }),
692
747
  )
693
748
  .optional()
694
749
  .describe(
695
750
  "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",
751
+ "handoff or a bug — in the order you worked them. Each is RUN (`cmd`) or SHOWN (an " +
752
+ "attach_screenshot line in `ran`). Pasting a command you typed into `ran` is not " +
753
+ "running it and is refused",
697
754
  ),
698
755
  ok: z.boolean().optional().describe("handoff or bug: did its Verification hold"),
699
756
  note: z.string().optional().describe("one line; required when ok is false"),
@@ -801,19 +858,44 @@ export function buildServer() {
801
858
  title: "Attach a screenshot",
802
859
  annotations: { ...ADDS, openWorldHint: true },
803
860
  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.",
861
+ "Upload an image as evidence — a path in `file`, or the bytes in `data` when you have " +
862
+ "the image and no file — and get back the markdown line that points at it. " +
863
+ WHERE_THE_LINE_GOES,
809
864
  inputSchema: {
810
- file: z.string().describe("path to the image on this machine"),
865
+ file: z
866
+ .string()
867
+ .default("")
868
+ .describe("path to the image on this machine; leave out when sending `data`"),
869
+ data: z
870
+ .string()
871
+ .default("")
872
+ .describe(
873
+ "the image itself, base64 (a data: URL is fine), for when you have the bytes and no " +
874
+ "file — a browser tool that hands back an image inline and never writes to disk. " +
875
+ "5MB; larger belongs in create_upload",
876
+ ),
877
+ type: z
878
+ .string()
879
+ .default("image/png")
880
+ .describe("media type of `data`: image/png, image/jpeg, image/webp or image/gif"),
811
881
  name: z.string().default("").describe("label for the image; defaults to its filename"),
812
882
  },
813
883
  },
814
- async ({ file, name }) => {
884
+ async ({ file, data, type, name }) => {
815
885
  try {
816
- const shot = await passalong.attach(file, { name: name || "" });
886
+ if (!file && !data) {
887
+ return fail(
888
+ new Error(
889
+ "send `file` (a path on this machine) or `data` (the image, base64). If your " +
890
+ "screenshot is a tool result you cannot write out, capture it to a file instead " +
891
+ "— a headless browser's page.screenshot({ path }) or your platform's capture " +
892
+ "command — and pass that path.",
893
+ ),
894
+ );
895
+ }
896
+ const shot = data
897
+ ? await passalong.attachBytes(data, { name: name || "", type: type || "image/png" })
898
+ : await passalong.attach(file, { name: name || "" });
817
899
  return text(
818
900
  `${JSON.stringify({ id: shot.id, url: shot.url, bytes: shot.bytes }, null, 2)}\n\n` +
819
901
  `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,
@@ -420,6 +421,21 @@ export async function handIn(
420
421
  } = {},
421
422
  ) {
422
423
  needsSync("handing work in");
424
+ // A check that names a command is run here, before anything is sent.
425
+ //
426
+ // The MCP server did this and this function did not, so "put the command in `cmd` and it is
427
+ // executed here" was true over one transport and a quiet lie over the other: a `cmd` handed to
428
+ // this function was passed along as a string nothing had executed. Checks that already carry an
429
+ // exit came from the server's own runner and are left alone, so nothing runs twice.
430
+ const pending = checks.filter((c) => c?.cmd && typeof c?.exit !== "number" && c?.exit !== null);
431
+ if (pending.length) {
432
+ const done = runChecks(pending, { cwd });
433
+ const byCheck = new Map(done.checks.map((c) => [c.check, c]));
434
+ checks = checks.map((c) => byCheck.get(c.check) ?? c);
435
+ if (done.failed) {
436
+ throw new Error(refusal(done.failed, { ran: done.ran, total: checks.length }));
437
+ }
438
+ }
423
439
  // Checks are evidence, sorted against the lines they answer: bringing them is bringing it. A
424
440
  // check whose command was actually run is exempt from the length rule — its exit code is the
425
441
  // evidence, and `test -f dist/app.js` exiting 0 says more than any sentence about it would.
@@ -635,6 +651,46 @@ export const SHOT_TYPES = {
635
651
  * Local because it reads a path. An agent in a terminal has one; a hosted assistant does not, and
636
652
  * reaches the same route through `attach_screenshot` on the HTTP server instead.
637
653
  */
654
+ /**
655
+ * The same upload, for an agent that has the image and not a path to it.
656
+ *
657
+ * `attach()` takes a path because a screenshot usually lands on disk. An agent driving a browser
658
+ * often has the opposite: the pane hands back an image as a tool result and never writes a file, so
659
+ * the only door into evidence was one it could not open. It did what anyone would — swapped to
660
+ * `grep` and `tsc` checks and put what it saw in `writeup` — and said so itself: "a grep proves the
661
+ * code changed, not that the screen renders right."
662
+ *
663
+ * Base64 in a tool call, which the HTTP server's instructions rightly warn against, because there
664
+ * the alternative is `create_upload` and a curl. Here there is no alternative for bytes held in
665
+ * memory, and this server is a local process talking to the same upload the file path uses.
666
+ */
667
+ export async function attachBytes(data, { name = "", type = "image/png" } = {}) {
668
+ if (!api.loggedIn())
669
+ throw new PassalongError("attaching evidence needs sync — run `passalong login` first");
670
+ const allowed = new Set(Object.values(SHOT_TYPES));
671
+ if (!allowed.has(type))
672
+ throw new PassalongError(`screenshots must be ${[...allowed].join(", ")} — ${type} is not one`);
673
+ let bytes;
674
+ try {
675
+ // A data: URL is what several browser tools hand back, so it is accepted rather than made the
676
+ // caller's problem to strip.
677
+ bytes = Buffer.from(String(data).replace(/^data:[^,]*,/, ""), "base64");
678
+ } catch {
679
+ throw new PassalongError("`data` is not base64");
680
+ }
681
+ if (!bytes.length) throw new PassalongError("`data` decoded to nothing");
682
+ // The server's own ceiling, checked here so a large paste fails before it is sent rather than
683
+ // after. Anything bigger belongs in create_upload, which streams from a file.
684
+ if (bytes.length > 5 * 1024 * 1024)
685
+ throw new PassalongError(
686
+ `that image is ${Math.round(bytes.length / 1024 / 1024)}MB and the limit is 5MB — ` +
687
+ "write it to a file and use create_upload, which streams",
688
+ );
689
+ const label = name || "screenshot";
690
+ const shot = await api.uploadShot(bytes, type, label);
691
+ return { ...shot, markdown: `![${label}](${shot.url})` };
692
+ }
693
+
638
694
  export async function attach(file, { name = "" } = {}) {
639
695
  if (!api.loggedIn())
640
696
  throw new PassalongError("attaching evidence needs sync — run `passalong login` first");