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 +6 -4
- package/bin/passalong +7 -4
- package/package.json +1 -1
- package/skill/SKILL.md +1 -1
- package/src/checks.js +32 -5
- package/src/guide.js +31 -0
- package/src/passalong.js +37 -3
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>
|
|
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
|
|
71
|
-
That is separate from `done`, which means *implemented*: a verdict
|
|
72
|
-
|
|
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>
|
|
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
|
|
608
|
-
|
|
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
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
|
|
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:
|
|
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 =
|
|
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
|
-
|
|
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
|
/**
|