passalong 0.8.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/bin/passalong CHANGED
@@ -12,7 +12,7 @@
12
12
  // passalong work [--once] [--agent CMD] run an agent on each ready task here, one after another
13
13
  // passalong approve <id> the work in review is done
14
14
  // passalong reject <id> <why> not done: back to Ready, and the next agent reads why
15
- // passalong release <id> take a task back from the agent holding it, stalled or not
15
+ // passalong release <id> take work back from whoever holds it, of any kind
16
16
  // passalong attach <image> upload evidence and print the markdown that points at it
17
17
  // passalong pull <id|link> fetch a guide into ./.passalong/ and print it
18
18
  // passalong start <id|link> pull it and take it — one command for "I am doing this"
@@ -332,8 +332,12 @@ const commands = {
332
332
 
333
333
  async release(f) {
334
334
  const id = f._[0] || die("usage: passalong release <id>");
335
- await passalong.releaseTask(id).catch((e) => die(e.message));
336
- console.error(`${id} is back in Ready — it says where the last agent left it`);
335
+ const r = await passalong.release(id).catch((e) => die(e.message));
336
+ const from = Number(r?.from) || 1;
337
+ console.error(
338
+ `${id} is back in Ready${from > 1 ? `, taken back from ${from} places` : ""}` +
339
+ " — whoever had it has been told",
340
+ );
337
341
  },
338
342
 
339
343
  async ready(f) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "passalong",
3
- "version": "0.8.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/api.js CHANGED
@@ -176,8 +176,11 @@ export const setStatus = (id, status) =>
176
176
  call(`/v1/guides/${id}/status`, { method: "PATCH", body: { status } });
177
177
  export const remove = (id) => call(`/v1/guides/${id}`, { method: "DELETE" });
178
178
  /** Does it actually work? `note` is required when it does not. */
179
- export const verdict = (id, ok, note = "") =>
180
- call(`/v1/guides/${id}/verdict`, { method: "PUT", body: { ok, note } });
179
+ export const verdict = (id, ok, note = "", detail = "") =>
180
+ call(`/v1/guides/${id}/verdict`, {
181
+ method: "PUT",
182
+ body: { ok, note, ...(detail ? { detail } : {}) },
183
+ });
181
184
 
182
185
  export const tasks = () => call("/v1/tasks");
183
186
  export const nextTask = (body) => call("/v1/tasks/next", { method: "POST", body });
@@ -189,8 +192,9 @@ export const approveTask = (id) =>
189
192
  call(`/v1/tasks/${encodeURIComponent(id)}/approve`, { method: "POST", body: {} });
190
193
  export const rejectTask = (id, why) =>
191
194
  call(`/v1/tasks/${encodeURIComponent(id)}/reject`, { method: "POST", body: { why } });
192
- export const releaseTask = (id) =>
193
- call(`/v1/tasks/${encodeURIComponent(id)}/release`, { method: "POST", body: {} });
195
+ /** Take work back from whoever holds it, of any kind. `/v1/tasks/…/release` is the old path. */
196
+ export const release = (id) =>
197
+ call(`/v1/guides/${encodeURIComponent(id)}/release`, { method: "POST", body: {} });
194
198
  /** The four verbs every guide answers to. See docs/V2.md §11. */
195
199
  export const working = () => call("/v1/working");
196
200
  export const handedIn = () => call("/v1/handed_in");
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
@@ -326,7 +381,7 @@ export function buildServer() {
326
381
  }
327
382
  }
328
383
 
329
- async function doHandIn({ id, ok, note, evidence, checks, markdown, report, pr, cwd }) {
384
+ async function doHandIn({ id, ok, note, evidence, checks, writeup, markdown, report, pr, cwd }) {
330
385
  try {
331
386
  // A check that names a command is run here, before anything is recorded, and its exit code
332
387
  // decides it rather than the agent's account of it. A failure is refused with the command's
@@ -346,6 +401,7 @@ export function buildServer() {
346
401
  note: note || "",
347
402
  evidence: evidence || "",
348
403
  checks: done.checks,
404
+ writeup: writeup || "",
349
405
  markdown,
350
406
  report,
351
407
  pr: pr || "",
@@ -640,18 +696,22 @@ export function buildServer() {
640
696
  outputSchema: handInOut,
641
697
  annotations: ADDS,
642
698
  description:
643
- "Done here, with proof. Every hand-in carries what you ran and what came back. On a TASK " +
644
- "send `checks`: one entry per Acceptance line, each with that line and the evidence for " +
645
- "it — that is what its author reads, line against line, instead of hunting through a wall " +
646
- "of output for the part that answers each one. Give a check `cmd` when a command proves " +
647
- "it: the command is run here before the hand-in lands, a non-zero exit refuses it, and " +
648
- "what it printed is recorded instead of your account of it. Use `ran` for a check no " +
649
- "command can settle. `evidence` is the same thing as one block, " +
650
- "for a handoff or a bug, which have no Acceptance lines. A task also takes `markdown`, a " +
651
- "transfer guide about what you did and decided — this publishes it and attaches it. A " +
652
- "handoff or a bug takes `ok`, whether its Verification held here, and `note` saying what " +
653
- "went wrong when it did not. More context than a note holds goes in a follow-up: " +
654
- "publish_guide with `parent` set to this id.",
699
+ "Done here, with proof, sorted against the line it answers. Send `checks`: one entry per " +
700
+ "line the guide asks for — `## Acceptance` on a task, `## Verification` on a handoff or a " +
701
+ "bug — each with that line and the evidence for it. That is what its author reads, line " +
702
+ "against line, instead of hunting through a wall of output for the part that answers each " +
703
+ "one, AND IT IS HOW YOU KNOW YOU ARE DONE: every line has one. Give a check `cmd` when a " +
704
+ "command proves it: the command is run here before the hand-in lands, a non-zero exit " +
705
+ "refuses it, and what it printed is recorded instead of your account of it. Use `ran` for " +
706
+ "a check no command can settle. `evidence` as one block is the older shape and still " +
707
+ "works. A task also takes `markdown`, a transfer guide about what you did and decided — " +
708
+ "this publishes it and attaches it. A handoff or a bug takes `ok`, whether its " +
709
+ "Verification held here, and `note` saying what went wrong when it did not. DO NOT " +
710
+ "publish a guide to carry your evidence: it belongs on this call, and a second document " +
711
+ 'titled "Hand-in evidence" is the thing `checks` exists to replace. If it worked but you ' +
712
+ "had to adapt something, that goes in `writeup` and the next reader of the guide is shown " +
713
+ "it — that is its home. A follow-up guide is for work somebody else should now do, not " +
714
+ "for answering.",
655
715
  inputSchema: {
656
716
  id: z.string().describe("passalong id"),
657
717
  evidence: z
@@ -664,7 +724,12 @@ export function buildServer() {
664
724
  checks: z
665
725
  .array(
666
726
  z.object({
667
- check: z.string().describe("the Acceptance line this answers, in the task's words"),
727
+ check: z
728
+ .string()
729
+ .describe(
730
+ "the line this answers, in the guide's own words: an Acceptance line on a task, " +
731
+ "a Verification line on a handoff or a bug",
732
+ ),
668
733
  ran: z
669
734
  .string()
670
735
  .optional()
@@ -676,14 +741,28 @@ export function buildServer() {
676
741
  "the shell command that proves this line, run here before the hand-in lands — " +
677
742
  "its exit code decides the check and its output is recorded as the evidence. " +
678
743
  "Non-zero refuses the hand-in. Leave it out for a check nobody can run, like " +
679
- '"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',
680
745
  ),
681
746
  }),
682
747
  )
683
748
  .optional()
684
- .describe("task: one entry per Acceptance line, in the order you worked them"),
749
+ .describe(
750
+ "one entry per line the guide asks for — Acceptance on a task, Verification on a " +
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",
754
+ ),
685
755
  ok: z.boolean().optional().describe("handoff or bug: did its Verification hold"),
686
756
  note: z.string().optional().describe("one line; required when ok is false"),
757
+ writeup: z
758
+ .string()
759
+ .optional()
760
+ .describe(
761
+ "handoff or bug: what you had to adapt to make it work here — a version, a name, a " +
762
+ "step that needed something the guide does not mention. Prose, and optional: leave " +
763
+ "it out when it worked as written. The next person to open the guide is shown it, " +
764
+ "which is why it does not need to be a guide of its own",
765
+ ),
687
766
  markdown: z
688
767
  .string()
689
768
  .optional()
@@ -779,19 +858,44 @@ export function buildServer() {
779
858
  title: "Attach a screenshot",
780
859
  annotations: { ...ADDS, openWorldHint: true },
781
860
  description:
782
- "Upload an image from this machine as evidence, and get back the markdown line that " +
783
- "points at it. Put that line in the guide body — a guide travels as markdown to whoever " +
784
- "holds its link, so evidence beside the document does not travel at all. Publishing " +
785
- "claims whatever the markdown names, so attach first and publish after. png, jpg, webp " +
786
- "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,
787
864
  inputSchema: {
788
- 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"),
789
881
  name: z.string().default("").describe("label for the image; defaults to its filename"),
790
882
  },
791
883
  },
792
- async ({ file, name }) => {
884
+ async ({ file, data, type, name }) => {
793
885
  try {
794
- 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 || "" });
795
899
  return text(
796
900
  `${JSON.stringify({ id: shot.id, url: shot.url, bytes: shot.bytes }, null, 2)}\n\n` +
797
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,
@@ -412,6 +413,7 @@ export async function handIn(
412
413
  note = "",
413
414
  evidence = "",
414
415
  checks = [],
416
+ writeup = "",
415
417
  markdown,
416
418
  report,
417
419
  pr = "",
@@ -419,6 +421,21 @@ export async function handIn(
419
421
  } = {},
420
422
  ) {
421
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
+ }
422
439
  // Checks are evidence, sorted against the lines they answer: bringing them is bringing it. A
423
440
  // check whose command was actually run is exempt from the length rule — its exit code is the
424
441
  // evidence, and `test -f dist/app.js` exiting 0 says more than any sentence about it would.
@@ -446,6 +463,9 @@ export async function handIn(
446
463
  }
447
464
  : {}),
448
465
  ...(report ? { report, pr } : {}),
466
+ // Left out when there is nothing to say, rather than sent as "": most hand-ins have nothing
467
+ // to adapt, and the field is for the ones that do.
468
+ ...(writeup ? { writeup } : {}),
449
469
  ...(typeof ok === "boolean" ? { ok } : {}),
450
470
  });
451
471
  }
@@ -539,9 +559,16 @@ export async function rejectTask(id, why) {
539
559
  return r;
540
560
  }
541
561
 
542
- export async function releaseTask(id) {
543
- needsSync("releasing a task");
544
- const r = await api.releaseTask(id);
562
+ /**
563
+ * Take it back from whoever is holding it — a task, a handoff or a bug, and from all of them at
564
+ * once when several people hold the same handoff in different repos.
565
+ *
566
+ * It was tasks only, which left an asymmetry nobody chose: the author of a handoff could mark it
567
+ * done or give it to somebody else, and had no way to simply have it back.
568
+ */
569
+ export async function release(id) {
570
+ needsSync("taking work back");
571
+ const r = await api.release(id);
545
572
  store.remove(id);
546
573
  return r;
547
574
  }
@@ -624,6 +651,46 @@ export const SHOT_TYPES = {
624
651
  * Local because it reads a path. An agent in a terminal has one; a hosted assistant does not, and
625
652
  * reaches the same route through `attach_screenshot` on the HTTP server instead.
626
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
+
627
694
  export async function attach(file, { name = "" } = {}) {
628
695
  if (!api.loggedIn())
629
696
  throw new PassalongError("attaching evidence needs sync — run `passalong login` first");
@@ -808,11 +875,21 @@ export async function activity({ all = false, limit = 50 } = {}) {
808
875
  * `done` says "I implemented it", a verdict says "I tried it and it holds up" — or does not.
809
876
  * A failing verdict needs a note; "it doesn't work" without a reason helps nobody.
810
877
  */
878
+ /**
879
+ * Say whether a guide held, and why at the length that takes.
880
+ *
881
+ * The reason used to be one field, capped at 280 characters by the row it was written for — so an
882
+ * agent that had run the Verification and found it did not hold published a guide called
883
+ * "Correction: …" instead, because measurements, commands and commit ids do not fit in a tweet.
884
+ * The first line is the summary a row shows; the whole thing is what the guide shows.
885
+ */
811
886
  export async function verdict(id, ok, note = "") {
812
887
  if (!api.loggedIn()) throw new PassalongError("verdicts need sync — run `passalong login` first");
813
- if (!ok && !note.trim())
814
- throw new PassalongError("say what went wrong: passalong failed <id> <what happened>");
815
- return api.verdict(await resolveId(id), ok, note.trim());
888
+ const said = note.trim();
889
+ if (!ok && !said)
890
+ throw new PassalongError("say what went wrong: passalong broken <id> <what happened>");
891
+ const line = said.split("\n")[0].slice(0, 280);
892
+ return api.verdict(await resolveId(id), ok, line, said.length > line.length ? said : "");
816
893
  }
817
894
 
818
895
  /**