passalong 0.11.0 → 0.13.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/README.md CHANGED
@@ -33,7 +33,7 @@ passalong activity [--all] what happened while you were away; clears unless -
33
33
  passalong list [query] your guides and your teams', local and synced
34
34
  passalong open <id> [--print] view a guide in the browser (or the terminal)
35
35
  passalong hub open your synced guides in the browser
36
- passalong works <id> you tried it and it holds up
36
+ passalong works <id> <shot> you tried it and it holds up, with a screenshot of it working
37
37
  passalong broken <id> <why> you tried it and it does not — the author is told, with your reason
38
38
  passalong archive <id> off your board and out of the free tier's count (was `done`)
39
39
  passalong promote <id> deprecated: mark promoted
@@ -67,9 +67,11 @@ passalong inbox # on Ada's side: what was handed to you
67
67
  passalong activity # back on your side: it landed, and who pulled it
68
68
  ```
69
69
 
70
- A receiver who tries the work reports back with `passalong works <id>` or `passalong broken <id> <why>`.
71
- That is separate from `done`, which means *implemented*: a verdict means it actually runs, a failing
72
- one has to say why, and the author sees it on their board and in their inbox.
70
+ A receiver who tries the work reports back with `passalong works <id> <screenshot.png>` or
71
+ `passalong broken <id> <why>`. That is separate from `done`, which means *implemented*: a verdict
72
+ means it actually runs. A passing one has to show it — at least one screenshot of it working, kept
73
+ until 5 days after the guide is closed — a failing one has to say why, and the author sees it on
74
+ their board and in their inbox.
73
75
 
74
76
  Sign in at [the hub](https://passalong.dev/hub) with an email and password, and mint
75
77
  an API token there for the CLI and MCP servers — named, revocable, and shown once. Your password
package/bin/passalong CHANGED
@@ -33,7 +33,7 @@
33
33
  // give Solo to @handle, or Team to team/slug, until a date (operator only)
34
34
  // passalong gifts what has been given away, and what is still running
35
35
  // passalong ungift <gift id> take one back now
36
- // passalong works <id> you tried it and it holds up
36
+ // passalong works <id> <shot.png...> you tried it and it holds up — with a screenshot of it working
37
37
  // passalong broken <id> <why> you tried it and it does not — the author is told, with your reason
38
38
  // passalong archive <id> off your board and out of the free tier's count (was `done`)
39
39
  // passalong rm <id> delete a guide locally and from sync
@@ -603,9 +603,12 @@ const commands = {
603
603
  },
604
604
 
605
605
  async works(f) {
606
- const id = f._[0] || die("usage: passalong works <id>");
607
- const r = await passalong.verdict(id, true, f._.slice(1).join(" ")).catch((e) => die(e.message));
608
- console.error(`${r.id} verified — the author has been told`);
606
+ const id = f._[0] || die("usage: passalong works <id> <screenshot.png...> [what you checked]");
607
+ const { images, note } = passalong.proofArgs(f._.slice(1));
608
+ const r = await passalong.verdict(id, true, note, { images }).catch((e) => die(e.message));
609
+ console.error(
610
+ `${r.id} verified with ${images.length || "your"} screenshot${images.length === 1 ? "" : "s"} — the author has been told`,
611
+ );
609
612
  },
610
613
 
611
614
  async broken(f) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "passalong",
3
- "version": "0.11.0",
3
+ "version": "0.13.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/skill/SKILL.md CHANGED
@@ -66,7 +66,7 @@ machine, another agent session, or a teammate without your branch.
66
66
  follow-up gets the guide it came out of, and where that guide has got to — so write it as a
67
67
  note on that work rather than as a standalone brief. A session that started from a passalong guide
68
68
  (`passalong start` or `pull` on an id, or an agent opened one) usually has context worth adding
69
- this way. Whether the guide worked is still its own answer: `passalong works <id>` or
69
+ this way. Whether the guide worked is still its own answer: `passalong works <id> <screenshot>` or
70
70
  `passalong broken <id> <why>`.
71
71
  6. **Report** the id (and link if synced) and one line on how to use it on the other side:
72
72
  `passalong start <id>` in the target repo, or "start passalong <id>" to an agent with the
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/checks.js CHANGED
@@ -22,6 +22,26 @@
22
22
  * exit code is the whole verdict here, which is also how a CI step decides.
23
23
  */
24
24
  import { spawnSync } from "node:child_process";
25
+ import { existsSync } from "node:fs";
26
+
27
+ /**
28
+ * The shell a check runs in: bash with `pipefail` wherever there is one.
29
+ *
30
+ * A plain shell reports a pipeline's last command. `npx jest 2>&1 | grep Tests:` then passes when
31
+ * jest fails, because grep found the summary line, and `git log main..HEAD | head -60` passes when
32
+ * git dies with "fatal: ambiguous argument", because head exited 0 — which is exactly how a hand-in
33
+ * came back marked "ran, it worked here" with the fatal error sitting in its own output. With
34
+ * pipefail, any failing step fails the check.
35
+ */
36
+ const BASH =
37
+ process.platform === "win32" ? "" : ["/bin/bash", "/usr/bin/bash"].find(existsSync) || "";
38
+
39
+ /**
40
+ * 141 is 128 + SIGPIPE: the command was still writing when a later step stopped reading, which is
41
+ * what `| head` does on purpose. Under pipefail that is the pipeline's status, and it is not a
42
+ * failure — git printed what head wanted and was told to stop.
43
+ */
44
+ const SIGPIPE = 141;
25
45
 
26
46
  /** How long a check may run before it is killed. Long enough for a real suite, short of a lease. */
27
47
  export const CHECK_TIMEOUT_MS = 10 * 60 * 1000;
@@ -58,13 +78,15 @@ export function runCheck(check, { cwd = process.cwd(), timeoutMs = CHECK_TIMEOUT
58
78
  const cmd = String(check?.cmd ?? "").trim();
59
79
  if (!cmd) return { check: check.check, ran: check.ran };
60
80
 
61
- const r = spawnSync(cmd, {
62
- shell: true,
81
+ const opts = {
63
82
  cwd,
64
83
  timeout: timeoutMs,
65
84
  encoding: "utf8",
66
85
  maxBuffer: 64 * 1024 * 1024,
67
- });
86
+ };
87
+ const r = BASH
88
+ ? spawnSync(BASH, ["-o", "pipefail", "-c", cmd], opts)
89
+ : spawnSync(cmd, { ...opts, shell: true });
68
90
 
69
91
  const out = tail(`${r.stdout ?? ""}${r.stderr ?? ""}`);
70
92
  // `status` is null when the child was killed rather than exited: a timeout, or a signal.
@@ -84,15 +106,20 @@ export function runCheck(check, { cwd = process.cwd(), timeoutMs = CHECK_TIMEOUT
84
106
  };
85
107
  }
86
108
 
109
+ const cutShort = Boolean(BASH) && r.status === SIGPIPE;
87
110
  return {
88
111
  check: check.check,
89
112
  cmd,
90
113
  exit: r.status,
91
- ok: r.status === 0,
114
+ ok: r.status === 0 || cutShort,
92
115
  // The recorded evidence is what the process printed, not what the agent said about it. An
93
116
  // empty run is written out rather than left blank: the exit code is what decided it, and a
94
117
  // blank `ran` reads like evidence nobody supplied.
95
- ran: out ? `$ ${cmd}\n${out}` : `$ ${cmd}\n(no output; exited ${r.status})`,
118
+ ran:
119
+ (out ? `$ ${cmd}\n${out}` : `$ ${cmd}\n(no output; exited ${r.status})`) +
120
+ (cutShort
121
+ ? "\n(exit 141: a later step stopped reading early, as `head` does; not a failure)"
122
+ : ""),
96
123
  };
97
124
  }
98
125
 
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,16 +269,58 @@ 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);
265
290
  return meta;
266
291
  }
267
292
 
293
+ /**
294
+ * Frontmatter fields holding more than this format can: a nested map, a block scalar (`|`), a
295
+ * sequence under a field that is not a list. `parseFrontmatter` keeps only the key's own line, so
296
+ * each of these would come back as `""` with its content gone — a design.md's `colors:` block did
297
+ * exactly that, and a publish would have stored the document with every token wiped. The format
298
+ * does not grow to hold them (strings and string lists only); the publish refuses them by name
299
+ * instead, so nothing is lost without its author being told. Mirrors `unheldFields` in
300
+ * apps/api/src/guide.ts, which is the refusal every client meets.
301
+ */
302
+ export function unheldFields(markdown) {
303
+ const m = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(markdown);
304
+ if (!m) return [];
305
+ const out = new Set();
306
+ let key = null;
307
+ let listKey = null;
308
+ for (const line of m[1].split(/\r?\n/)) {
309
+ if (line.trim() === "" || line.trim().startsWith("#")) continue;
310
+ if (listKey && /^\s*-\s*/.test(line)) continue;
311
+ const kv = /^([A-Za-z_][A-Za-z0-9_]*):\s*(.*)$/.exec(line);
312
+ if (kv) {
313
+ key = kv[1];
314
+ listKey = kv[2].trim() === "" && LIST_FIELDS.has(key) ? key : null;
315
+ continue;
316
+ }
317
+ // Anything else is a line the parser skips: an indented child, or an item under a field
318
+ // that is not a list. It belongs to the key above it, and that is the key to name.
319
+ out.add(key ?? line.trim());
320
+ }
321
+ return [...out];
322
+ }
323
+
268
324
  function quote(v) {
269
325
  const s = String(v ?? "");
270
326
  // Quote anything YAML would misread: colons, leading symbols, or empty.
@@ -305,6 +361,10 @@ export function serializeFrontmatter(meta) {
305
361
  ];
306
362
  const out = [];
307
363
  for (const key of keys) {
364
+ // A kind nobody stated is left unstated. Writing `kind: ""` would put an answer in the
365
+ // document that its author never gave, and the next parse would read it back as a stated
366
+ // empty rather than an absent one — which is the whole distinction validate() turns on.
367
+ if (key === "kind" && !String(meta[key] ?? "").trim()) continue;
308
368
  const v = key === "tags" ? tagList(meta[key]) : meta[key];
309
369
  if (Array.isArray(v)) {
310
370
  out.push(v.length ? `${key}: [${v.map(quote).join(", ")}]` : `${key}: []`);
@@ -497,8 +557,9 @@ export function bugGuide({
497
557
 
498
558
  /** A draft with the section skeleton. Placeholders are HTML comments so they vanish when rendered. */
499
559
  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.
560
+ // A task unless asked otherwise: most guides assign work. Every skeleton writes its kind out —
561
+ // absent is now refused rather than read as transfer, so a template that left it off would
562
+ // produce a document its own author could not publish.
502
563
  if (meta.kind === "bug") return bugTemplate(meta);
503
564
  if (meta.kind !== "transfer") return taskTemplate(meta);
504
565
  const body = [
package/src/mcp.js CHANGED
@@ -261,7 +261,9 @@ export function buildServer() {
261
261
  "Collect it as you work rather than writing it from memory at the end. hand_in without " +
262
262
  "it is refused, because the write-up and the verdict are both your word for your own " +
263
263
  "work and evidence is the part the person reviewing it can check.\n" +
264
- `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(", ")}. ` +
265
267
  "It has no Steps: work out how to reach Goal within Constraints, leave Out of scope " +
266
268
  "alone, and treat Acceptance as the definition of done. hand_in with `markdown`: a " +
267
269
  "transfer guide about what you did (guide_template kind transfer shows the shape) — its " +
@@ -381,7 +383,19 @@ export function buildServer() {
381
383
  }
382
384
  }
383
385
 
384
- 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
+ }) {
385
399
  try {
386
400
  // A check that names a command is run here, before anything is recorded, and its exit code
387
401
  // decides it rather than the agent's account of it. A failure is refused with the command's
@@ -402,6 +416,7 @@ export function buildServer() {
402
416
  evidence: evidence || "",
403
417
  checks: done.checks,
404
418
  writeup: writeup || "",
419
+ risk: risk || "",
405
420
  markdown,
406
421
  report,
407
422
  pr: pr || "",
@@ -763,6 +778,15 @@ export function buildServer() {
763
778
  "it out when it worked as written. The next person to open the guide is shown it, " +
764
779
  "which is why it does not need to be a guide of its own",
765
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
+ ),
766
790
  markdown: z
767
791
  .string()
768
792
  .optional()
package/src/passalong.js CHANGED
@@ -16,6 +16,7 @@ import {
16
16
  serialize,
17
17
  stamp,
18
18
  stripPlaceholders,
19
+ unheldFields,
19
20
  validate,
20
21
  } from "./guide.js";
21
22
  import * as store from "./store.js";
@@ -60,7 +61,13 @@ export async function share(markdown, { cwd = process.cwd(), to, follows } = {})
60
61
  if (follows) guide.meta.parent = await resolveId(String(follows).trim());
61
62
  if (guide.meta.to && !guide.meta.team)
62
63
  throw new PassalongError("`to:` needs a team — address a handoff as team/handle");
63
- const errors = validate(guide);
64
+ const errors = [
65
+ ...unheldFields(markdown).map(
66
+ (k) =>
67
+ `frontmatter "${k}" holds more than a string or a list of strings, and it would be dropped — move it into the body (a fenced yaml block keeps it as written)`,
68
+ ),
69
+ ...validate(guide),
70
+ ];
64
71
  if (errors.length)
65
72
  throw new PassalongError(`guide is not ready to share:\n - ${errors.join("\n - ")}`);
66
73
  // A task stays a draft when it is shared. Draft is the column a task waits in until a person
@@ -358,8 +365,16 @@ export async function finishTask(
358
365
  requireEvidence(evidence);
359
366
  if (!report && markdown) {
360
367
  const g = parse(markdown);
361
- report = (await share(serialize({ meta: { ...g.meta, parent: id }, body: g.body }), { cwd }))
362
- .guide.meta.id;
368
+ // A task's report is a transfer guide, and this code is what is making one — so it says so
369
+ // rather than hoping the agent's frontmatter did. Absent is refused now, and refusing an
370
+ // agent's hand-in because its write-up left a line off would be this function's omission
371
+ // charged to the agent. Stated, not defaulted: `kind` is the one field here that is not the
372
+ // author's to leave open, because the caller already knows the answer.
373
+ report = (
374
+ await share(serialize({ meta: { ...g.meta, kind: "transfer", parent: id }, body: g.body }), {
375
+ cwd,
376
+ })
377
+ ).guide.meta.id;
363
378
  }
364
379
  if (!report)
365
380
  throw new PassalongError(
@@ -414,6 +429,7 @@ export async function handIn(
414
429
  evidence = "",
415
430
  checks = [],
416
431
  writeup = "",
432
+ risk = "",
417
433
  markdown,
418
434
  report,
419
435
  pr = "",
@@ -443,8 +459,16 @@ export async function handIn(
443
459
  else requireEvidence(evidence);
444
460
  if (!report && markdown) {
445
461
  const g = parse(markdown);
446
- report = (await share(serialize({ meta: { ...g.meta, parent: id }, body: g.body }), { cwd }))
447
- .guide.meta.id;
462
+ // A task's report is a transfer guide, and this code is what is making one — so it says so
463
+ // rather than hoping the agent's frontmatter did. Absent is refused now, and refusing an
464
+ // agent's hand-in because its write-up left a line off would be this function's omission
465
+ // charged to the agent. Stated, not defaulted: `kind` is the one field here that is not the
466
+ // author's to leave open, because the caller already knows the answer.
467
+ report = (
468
+ await share(serialize({ meta: { ...g.meta, kind: "transfer", parent: id }, body: g.body }), {
469
+ cwd,
470
+ })
471
+ ).guide.meta.id;
448
472
  }
449
473
  const fence = fenceFor(id, cwd);
450
474
  return api.handIn(id, {
@@ -466,6 +490,8 @@ export async function handIn(
466
490
  // Left out when there is nothing to say, rather than sent as "": most hand-ins have nothing
467
491
  // to adapt, and the field is for the ones that do.
468
492
  ...(writeup ? { writeup } : {}),
493
+ // What it could break, for the reviewer. Left out when empty, like the write-up.
494
+ ...(risk ? { risk } : {}),
469
495
  ...(typeof ok === "boolean" ? { ok } : {}),
470
496
  });
471
497
  }
@@ -713,6 +739,11 @@ export async function attach(file, { name = "" } = {}) {
713
739
  * found doing it. Whoever opens the original, person or agent, gets its follow-ups with it, which
714
740
  * is why context belongs there rather than in a one-line verdict note.
715
741
  *
742
+ * What an agent did and found working on the guide is not context for it: that is the hand-in,
743
+ * and the note says so. It used to list "what you found doing it" among the things to publish, on
744
+ * every `take`, while hand_in said a follow-up was not for answering — the agent followed the one
745
+ * it read last, and the author got a second guide to review with the evidence inside it.
746
+ *
716
747
  * Trailing, like the handoff nudge, so it never sits in front of the frontmatter. It is here and
717
748
  * not only in the server's instructions because an agent reads the payload it is working from and
718
749
  * skims everything else — the same reason the bug lead is inside the document. For a bug, the
@@ -729,9 +760,10 @@ export function followUpNote(meta = {}) {
729
760
  );
730
761
  return (
731
762
  "<!-- passalong: a follow-up is more context for this guide, written as its own guide. If " +
732
- "this guide needs more — a missing detail, a step that needed explaining, what changed since, " +
733
- `what you found doing it — publish that with publish_guide parent=${meta.id}, and whoever ` +
734
- "opens this guide gets it too. -->"
763
+ "this guide needs more — a missing detail, a step that needed explaining, what changed since " +
764
+ `— publish that with publish_guide parent=${meta.id}, and whoever opens this guide gets it ` +
765
+ "too. What you did and found working on it is not a follow-up: it goes on hand_in, in " +
766
+ "`checks` and `writeup`. -->"
735
767
  );
736
768
  }
737
769
 
@@ -883,13 +915,40 @@ export async function activity({ all = false, limit = 50 } = {}) {
883
915
  * "Correction: …" instead, because measurements, commands and commit ids do not fit in a tweet.
884
916
  * The first line is the summary a row shows; the whole thing is what the guide shows.
885
917
  */
886
- export async function verdict(id, ok, note = "") {
918
+ export async function verdict(id, ok, note = "", { images = [] } = {}) {
887
919
  if (!api.loggedIn()) throw new PassalongError("verdicts need sync — run `passalong login` first");
888
920
  const said = note.trim();
889
921
  if (!ok && !said)
890
922
  throw new PassalongError("say what went wrong: passalong broken <id> <what happened>");
923
+ // "It works" is shown, not said: the server refuses one with no screenshot of it working, so
924
+ // the refusal is said here before anything is uploaded, in the words of the command that fixes it.
925
+ if (ok && !images.length && !/\/v1\/shots\/[a-z0-9]{6,16}/.test(said))
926
+ throw new PassalongError(
927
+ "show it working: passalong works <id> <screenshot.png> [what you checked]",
928
+ );
929
+ const guide = await resolveId(id);
930
+ const shots = [];
931
+ for (const file of images) shots.push((await attach(file)).markdown);
891
932
  const line = said.split("\n")[0].slice(0, 280);
892
- return api.verdict(await resolveId(id), ok, line, said.length > line.length ? said : "");
933
+ const detail = [said.length > line.length || shots.length ? said : "", ...shots]
934
+ .filter(Boolean)
935
+ .join("\n");
936
+ return api.verdict(guide, ok, line, detail);
937
+ }
938
+
939
+ /**
940
+ * The words after `passalong works <id>`, split into the screenshots and the note: an argument is a
941
+ * screenshot when it names an image file that exists, and everything else is what was checked.
942
+ */
943
+ export function proofArgs(args) {
944
+ const images = [];
945
+ const words = [];
946
+ for (const a of args) {
947
+ const ext = a.slice(a.lastIndexOf(".")).toLowerCase();
948
+ if (SHOT_TYPES[ext] && existsSync(a)) images.push(a);
949
+ else words.push(a);
950
+ }
951
+ return { images, note: words.join(" ") };
893
952
  }
894
953
 
895
954
  /**