passalong 0.12.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.12.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/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
@@ -290,6 +290,37 @@ export function parseFrontmatter(text) {
290
290
  return meta;
291
291
  }
292
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
+
293
324
  function quote(v) {
294
325
  const s = String(v ?? "");
295
326
  // Quote anything YAML would misread: colons, leading symbols, or empty.
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
@@ -908,13 +915,40 @@ export async function activity({ all = false, limit = 50 } = {}) {
908
915
  * "Correction: …" instead, because measurements, commands and commit ids do not fit in a tweet.
909
916
  * The first line is the summary a row shows; the whole thing is what the guide shows.
910
917
  */
911
- export async function verdict(id, ok, note = "") {
918
+ export async function verdict(id, ok, note = "", { images = [] } = {}) {
912
919
  if (!api.loggedIn()) throw new PassalongError("verdicts need sync — run `passalong login` first");
913
920
  const said = note.trim();
914
921
  if (!ok && !said)
915
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);
916
932
  const line = said.split("\n")[0].slice(0, 280);
917
- 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(" ") };
918
952
  }
919
953
 
920
954
  /**